Compare commits
No commits in common. "df49ce75451cbea683b7941a849a4c4e736888f5" and "5983ce50fda7f1f15db71c1fb9f62db0e4655e0e" have entirely different histories.
df49ce7545
...
5983ce50fd
|
|
@ -9,11 +9,10 @@
|
||||||
"src/"
|
"src/"
|
||||||
],
|
],
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"build": "mkdir -p dist/ribbit && npm run build:check && npm run build:js && npm run build:min && npm run build:css",
|
"build": "npm run build:check && npm run build:js && npm run build:min",
|
||||||
"build:check": "tsc --noEmit",
|
"build:check": "tsc --noEmit",
|
||||||
"build:js": "esbuild src/ts/ribbit-editor.ts --bundle --format=iife --global-name=ribbit --sourcemap --outfile=dist/ribbit/ribbit.js",
|
"build:js": "esbuild src/ribbit-editor.ts --bundle --format=iife --sourcemap --outfile=dist/ribbit.js",
|
||||||
"build:min": "esbuild src/ts/ribbit-editor.ts --bundle --format=iife --global-name=ribbit --minify --outfile=dist/ribbit/ribbit.min.js",
|
"build:min": "esbuild src/ribbit-editor.ts --bundle --format=iife --minify --outfile=dist/ribbit.min.js",
|
||||||
"build:css": "cp src/static/ribbit-core.css dist/ribbit/ && cp -r src/static/themes dist/ribbit/",
|
|
||||||
"test": "npm run build && node test/test_hopdown.js"
|
"test": "npm run build && node test/test_hopdown.js"
|
||||||
},
|
},
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
|
|
|
||||||
|
|
@ -2,9 +2,9 @@
|
||||||
* ribbit-editor.ts — WYSIWYG editing extension for Ribbit.
|
* ribbit-editor.ts — WYSIWYG editing extension for Ribbit.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
|
import hopdown from './hopdown';
|
||||||
import { HopDown } from './hopdown';
|
import { HopDown } from './hopdown';
|
||||||
import { defaultTags, defaultBlockTags, defaultInlineTags, inlineTag } from './tags';
|
import { defaultTags, defaultBlockTags, defaultInlineTags, inlineTag } from './tags';
|
||||||
import { defaultTheme } from './default-theme';
|
|
||||||
import { Ribbit, RibbitPlugin, RibbitSettings, camelCase, decodeHtmlEntities, encodeHtmlEntities } from './ribbit';
|
import { Ribbit, RibbitPlugin, RibbitSettings, camelCase, decodeHtmlEntities, encodeHtmlEntities } from './ribbit';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
@ -40,13 +40,13 @@ export class RibbitEditor extends Ribbit {
|
||||||
#bindEvents(): void {
|
#bindEvents(): void {
|
||||||
this.element.addEventListener('input', () => {
|
this.element.addEventListener('input', () => {
|
||||||
if (this.state !== this.states.VIEW) {
|
if (this.state !== this.states.VIEW) {
|
||||||
this.notifyChange();
|
this.changed = true;
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
htmlToMarkdown(html?: string): string {
|
htmlToMarkdown(html?: string): string {
|
||||||
return this.converter.toMarkdown(html || this.element.innerHTML);
|
return hopdown.toMarkdown(html || this.element.innerHTML);
|
||||||
}
|
}
|
||||||
|
|
||||||
getMarkdown(): string {
|
getMarkdown(): string {
|
||||||
|
|
@ -80,9 +80,6 @@ export class RibbitEditor extends Ribbit {
|
||||||
}
|
}
|
||||||
|
|
||||||
edit(): void {
|
edit(): void {
|
||||||
if (!this.theme.features?.sourceMode) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (this.state === this.states.EDIT) return;
|
if (this.state === this.states.EDIT) return;
|
||||||
this.changed = false;
|
this.changed = false;
|
||||||
this.element.contentEditable = 'true';
|
this.element.contentEditable = 'true';
|
||||||
|
|
@ -102,12 +99,16 @@ export class RibbitEditor extends Ribbit {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Public API — accessed as ribbit.Editor, ribbit.HopDown, etc.
|
// Attach public API to window for <script> tag usage.
|
||||||
export { RibbitEditor as Editor };
|
(window as any).HopDown = HopDown;
|
||||||
export { Ribbit as Viewer };
|
(window as any).hopdown = hopdown;
|
||||||
export { RibbitPlugin as Plugin };
|
(window as any).inlineTag = inlineTag;
|
||||||
export { HopDown };
|
(window as any).defaultTags = defaultTags;
|
||||||
export { inlineTag };
|
(window as any).defaultBlockTags = defaultBlockTags;
|
||||||
export { defaultTags, defaultBlockTags, defaultInlineTags };
|
(window as any).defaultInlineTags = defaultInlineTags;
|
||||||
export { defaultTheme };
|
(window as any).Ribbit = Ribbit;
|
||||||
export { camelCase, decodeHtmlEntities, encodeHtmlEntities };
|
(window as any).RibbitEditor = RibbitEditor;
|
||||||
|
(window as any).RibbitPlugin = RibbitPlugin;
|
||||||
|
(window as any).camelCase = camelCase;
|
||||||
|
(window as any).decodeHtmlEntities = decodeHtmlEntities;
|
||||||
|
(window as any).encodeHtmlEntities = encodeHtmlEntities;
|
||||||
58
src/ribbit.css
Normal file
58
src/ribbit.css
Normal file
|
|
@ -0,0 +1,58 @@
|
||||||
|
/*
|
||||||
|
* ribbit.css — editor styles for the ribbit WYSIWYG markdown editor.
|
||||||
|
*
|
||||||
|
* Provides base content formatting and editor state styles.
|
||||||
|
* Override with your own theme CSS for custom look and feel.
|
||||||
|
*/
|
||||||
|
|
||||||
|
/* ── Content formatting ──────────────────────────────── */
|
||||||
|
|
||||||
|
a { text-decoration: none; }
|
||||||
|
|
||||||
|
q, blockquote {
|
||||||
|
margin-left: 30px;
|
||||||
|
font-size: 1.3em;
|
||||||
|
font-style: italic;
|
||||||
|
color: #555;
|
||||||
|
}
|
||||||
|
|
||||||
|
table { width: 100%; }
|
||||||
|
th { border-bottom: 1px solid #000; padding: 3px; }
|
||||||
|
th, td { padding: 2px; }
|
||||||
|
table td table { max-width: 95%; }
|
||||||
|
|
||||||
|
pre {
|
||||||
|
border: 1px dashed black;
|
||||||
|
border-radius: 5px;
|
||||||
|
padding: 10px;
|
||||||
|
margin: 5px;
|
||||||
|
background: #EEE;
|
||||||
|
}
|
||||||
|
|
||||||
|
code {
|
||||||
|
display: inline-block;
|
||||||
|
border: 1px dashed black;
|
||||||
|
border-radius: 5px;
|
||||||
|
padding: 5px;
|
||||||
|
background: #EEE;
|
||||||
|
margin: 3px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Editor states ───────────────────────────────────── */
|
||||||
|
|
||||||
|
#ribbit {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
#ribbit.loaded {
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
|
|
||||||
|
#ribbit.edit {
|
||||||
|
font-family: monospace;
|
||||||
|
white-space: pre;
|
||||||
|
}
|
||||||
|
|
||||||
|
#ribbit.wysiwyg .md {
|
||||||
|
opacity: 0.5;
|
||||||
|
}
|
||||||
|
|
@ -2,20 +2,12 @@
|
||||||
* ribbit.ts — core editor classes for the ribbit WYSIWYG markdown editor.
|
* ribbit.ts — core editor classes for the ribbit WYSIWYG markdown editor.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { HopDown } from './hopdown';
|
import hopdown from './hopdown';
|
||||||
import { defaultTheme } from './default-theme';
|
|
||||||
import { ThemeManager } from './theme-manager';
|
|
||||||
import { RibbitEmitter, type RibbitEventMap } from './events';
|
|
||||||
import type { RibbitTheme } from './types';
|
|
||||||
|
|
||||||
export interface RibbitSettings {
|
export interface RibbitSettings {
|
||||||
api?: unknown;
|
api?: unknown;
|
||||||
editorId?: string;
|
editorId?: string;
|
||||||
plugins?: Array<{ new(settings: { name: string; wiki: Ribbit }): RibbitPlugin; name: string }>;
|
plugins?: Array<{ new(settings: { name: string; wiki: Ribbit }): RibbitPlugin; name: string }>;
|
||||||
currentTheme?: string;
|
|
||||||
themes?: RibbitTheme[];
|
|
||||||
themesPath?: string;
|
|
||||||
on?: Partial<RibbitEventMap>;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
@ -49,12 +41,7 @@ export class RibbitPlugin {
|
||||||
* Read-only markdown viewer. Renders markdown content into an HTML element.
|
* Read-only markdown viewer. Renders markdown content into an HTML element.
|
||||||
*
|
*
|
||||||
* Usage:
|
* Usage:
|
||||||
* const viewer = new Ribbit({
|
* const viewer = new Ribbit({ editorId: 'my-element' });
|
||||||
* editorId: 'my-element',
|
|
||||||
* on: {
|
|
||||||
* ready: ({ mode, theme }) => console.log(`Ready in ${mode}`),
|
|
||||||
* },
|
|
||||||
* });
|
|
||||||
* viewer.run();
|
* viewer.run();
|
||||||
*/
|
*/
|
||||||
export class Ribbit {
|
export class Ribbit {
|
||||||
|
|
@ -66,17 +53,10 @@ export class Ribbit {
|
||||||
state: string | null;
|
state: string | null;
|
||||||
changed: boolean;
|
changed: boolean;
|
||||||
enabledPlugins: Record<string, RibbitPlugin>;
|
enabledPlugins: Record<string, RibbitPlugin>;
|
||||||
theme: RibbitTheme;
|
|
||||||
themes: ThemeManager;
|
|
||||||
converter: HopDown;
|
|
||||||
themesPath: string;
|
|
||||||
private emitter: RibbitEmitter;
|
|
||||||
|
|
||||||
constructor(settings: RibbitSettings) {
|
constructor(settings: RibbitSettings) {
|
||||||
this.api = settings.api || null;
|
this.api = settings.api || null;
|
||||||
this.element = document.getElementById(settings.editorId || 'ribbit')!;
|
this.element = document.getElementById(settings.editorId || 'ribbit')!;
|
||||||
this.themesPath = settings.themesPath || './themes';
|
|
||||||
this.emitter = new RibbitEmitter();
|
|
||||||
this.states = {
|
this.states = {
|
||||||
VIEW: 'view',
|
VIEW: 'view',
|
||||||
};
|
};
|
||||||
|
|
@ -86,78 +66,17 @@ export class Ribbit {
|
||||||
this.changed = false;
|
this.changed = false;
|
||||||
this.enabledPlugins = {};
|
this.enabledPlugins = {};
|
||||||
|
|
||||||
this.themes = new ThemeManager(defaultTheme, this.themesPath, (theme, previous) => {
|
|
||||||
this.theme = theme;
|
|
||||||
this.converter = theme.tags
|
|
||||||
? new HopDown({ tags: theme.tags })
|
|
||||||
: new HopDown();
|
|
||||||
this.cachedHTML = null;
|
|
||||||
this.emitter.emit('themeChange', {
|
|
||||||
current: theme,
|
|
||||||
previous,
|
|
||||||
});
|
|
||||||
if (this.getState() === this.states.VIEW) {
|
|
||||||
this.state = null;
|
|
||||||
this.view();
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
(settings.themes || []).forEach(theme => {
|
|
||||||
this.themes.add(theme);
|
|
||||||
});
|
|
||||||
|
|
||||||
const activeName = settings.currentTheme || defaultTheme.name;
|
|
||||||
this.themes.set(activeName);
|
|
||||||
this.theme = this.themes.current();
|
|
||||||
this.converter = this.theme.tags
|
|
||||||
? new HopDown({ tags: this.theme.tags })
|
|
||||||
: new HopDown();
|
|
||||||
|
|
||||||
(settings.plugins || []).forEach(plugin => {
|
(settings.plugins || []).forEach(plugin => {
|
||||||
this.enabledPlugins[plugin.name] = new plugin({
|
this.enabledPlugins[plugin.name] = new plugin({
|
||||||
name: plugin.name,
|
name: plugin.name,
|
||||||
wiki: this,
|
wiki: this,
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
if (settings.on) {
|
|
||||||
for (const [event, handler] of Object.entries(settings.on)) {
|
|
||||||
if (handler) {
|
|
||||||
this.on(event as keyof RibbitEventMap, handler as any);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Register a callback for an event.
|
|
||||||
*
|
|
||||||
* editor.on('save', ({ markdown }) => {
|
|
||||||
* fetch('/api/save', { method: 'POST', body: markdown });
|
|
||||||
* });
|
|
||||||
*/
|
|
||||||
on<K extends keyof RibbitEventMap>(event: K, callback: RibbitEventMap[K]): void {
|
|
||||||
this.emitter.on(event, callback);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Remove a previously registered callback.
|
|
||||||
*
|
|
||||||
* editor.off('change', myHandler);
|
|
||||||
*/
|
|
||||||
off<K extends keyof RibbitEventMap>(event: K, callback: RibbitEventMap[K]): void {
|
|
||||||
this.emitter.off(event, callback);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
run(): void {
|
run(): void {
|
||||||
this.element.classList.add('loaded');
|
this.element.classList.add('loaded');
|
||||||
this.view();
|
this.view();
|
||||||
this.emitter.emit('ready', {
|
|
||||||
markdown: this.getMarkdown(),
|
|
||||||
html: this.getHTML(),
|
|
||||||
mode: this.state || 'view',
|
|
||||||
theme: this.theme,
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
plugins(): RibbitPlugin[] {
|
plugins(): RibbitPlugin[] {
|
||||||
|
|
@ -169,7 +88,6 @@ export class Ribbit {
|
||||||
}
|
}
|
||||||
|
|
||||||
setState(newState: string): void {
|
setState(newState: string): void {
|
||||||
const previous = this.state;
|
|
||||||
this.state = newState;
|
this.state = newState;
|
||||||
Object.values(this.states).forEach(state => {
|
Object.values(this.states).forEach(state => {
|
||||||
if (state === newState) {
|
if (state === newState) {
|
||||||
|
|
@ -178,14 +96,10 @@ export class Ribbit {
|
||||||
this.element.classList.remove(state);
|
this.element.classList.remove(state);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
this.emitter.emit('modeChange', {
|
|
||||||
current: newState,
|
|
||||||
previous,
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
markdownToHTML(md: string): string {
|
markdownToHTML(md: string): string {
|
||||||
return this.converter.toHTML(md);
|
return hopdown.toHTML(md);
|
||||||
}
|
}
|
||||||
|
|
||||||
getHTML(): string {
|
getHTML(): string {
|
||||||
|
|
@ -202,37 +116,12 @@ export class Ribbit {
|
||||||
return this.cachedMarkdown;
|
return this.cachedMarkdown;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Request a save. Fires the 'save' event with the current content.
|
|
||||||
* The consumer's callback handles persistence.
|
|
||||||
*
|
|
||||||
* editor.save(); // triggers on.save({ markdown, html })
|
|
||||||
*/
|
|
||||||
save(): void {
|
|
||||||
this.emitter.emit('save', {
|
|
||||||
markdown: this.getMarkdown(),
|
|
||||||
html: this.getHTML(),
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
view(): void {
|
view(): void {
|
||||||
if (this.getState() === this.states.VIEW) return;
|
if (this.getState() === this.states.VIEW) return;
|
||||||
this.element.innerHTML = this.getHTML();
|
this.element.innerHTML = this.getHTML();
|
||||||
this.setState(this.states.VIEW);
|
this.setState(this.states.VIEW);
|
||||||
this.element.contentEditable = 'false';
|
this.element.contentEditable = 'false';
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Notify that content has changed. Called internally by the editor
|
|
||||||
* on input events. Fires the 'change' event with current content.
|
|
||||||
*/
|
|
||||||
notifyChange(): void {
|
|
||||||
this.changed = true;
|
|
||||||
this.emitter.emit('change', {
|
|
||||||
markdown: this.getMarkdown(),
|
|
||||||
html: this.getHTML(),
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
@ -1,22 +0,0 @@
|
||||||
/*
|
|
||||||
* ribbit-core.css — functional editor styles. Always load this.
|
|
||||||
* These styles control editor state visibility and behavior.
|
|
||||||
* They should not be overridden by themes.
|
|
||||||
*/
|
|
||||||
|
|
||||||
#ribbit {
|
|
||||||
display: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
#ribbit.loaded {
|
|
||||||
display: block;
|
|
||||||
}
|
|
||||||
|
|
||||||
#ribbit.edit {
|
|
||||||
font-family: monospace;
|
|
||||||
white-space: pre;
|
|
||||||
}
|
|
||||||
|
|
||||||
#ribbit.wysiwyg .md {
|
|
||||||
opacity: 0.5;
|
|
||||||
}
|
|
||||||
|
|
@ -1,52 +0,0 @@
|
||||||
/*
|
|
||||||
* default.css — the default ribbit theme.
|
|
||||||
* Provides basic aesthetic styling for rendered markdown content.
|
|
||||||
* Replace this file with your own theme to customize the look.
|
|
||||||
*/
|
|
||||||
|
|
||||||
@import "../ribbit-core.css";
|
|
||||||
|
|
||||||
a {
|
|
||||||
text-decoration: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
blockquote {
|
|
||||||
margin-left: 30px;
|
|
||||||
font-size: 1.3em;
|
|
||||||
font-style: italic;
|
|
||||||
color: #555;
|
|
||||||
}
|
|
||||||
|
|
||||||
table {
|
|
||||||
width: 100%;
|
|
||||||
}
|
|
||||||
|
|
||||||
th {
|
|
||||||
border-bottom: 1px solid #000;
|
|
||||||
padding: 3px;
|
|
||||||
}
|
|
||||||
|
|
||||||
th, td {
|
|
||||||
padding: 2px;
|
|
||||||
}
|
|
||||||
|
|
||||||
table td table {
|
|
||||||
max-width: 95%;
|
|
||||||
}
|
|
||||||
|
|
||||||
pre {
|
|
||||||
border: 1px dashed black;
|
|
||||||
border-radius: 5px;
|
|
||||||
padding: 10px;
|
|
||||||
margin: 5px;
|
|
||||||
background: #EEE;
|
|
||||||
}
|
|
||||||
|
|
||||||
code {
|
|
||||||
display: inline-block;
|
|
||||||
border: 1px dashed black;
|
|
||||||
border-radius: 5px;
|
|
||||||
padding: 5px;
|
|
||||||
background: #EEE;
|
|
||||||
margin: 3px;
|
|
||||||
}
|
|
||||||
|
|
@ -1,16 +0,0 @@
|
||||||
/*
|
|
||||||
* default-theme.ts — the default ribbit theme.
|
|
||||||
*
|
|
||||||
* Enables all default tags and all editor features.
|
|
||||||
*/
|
|
||||||
|
|
||||||
import type { RibbitTheme } from './types';
|
|
||||||
import { defaultTags } from './tags';
|
|
||||||
|
|
||||||
export const defaultTheme: RibbitTheme = {
|
|
||||||
name: 'ribbit-default',
|
|
||||||
tags: defaultTags,
|
|
||||||
features: {
|
|
||||||
sourceMode: true,
|
|
||||||
},
|
|
||||||
};
|
|
||||||
111
src/ts/events.ts
111
src/ts/events.ts
|
|
@ -1,111 +0,0 @@
|
||||||
/*
|
|
||||||
* events.ts — typed event emitter for the ribbit editor.
|
|
||||||
*/
|
|
||||||
|
|
||||||
import type { RibbitTheme } from './types';
|
|
||||||
|
|
||||||
export interface ContentPayload {
|
|
||||||
markdown: string;
|
|
||||||
html: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface ModeChangePayload {
|
|
||||||
current: string;
|
|
||||||
previous: string | null;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface ThemeChangePayload {
|
|
||||||
current: RibbitTheme;
|
|
||||||
previous: RibbitTheme;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface ReadyPayload {
|
|
||||||
markdown: string;
|
|
||||||
html: string;
|
|
||||||
mode: string;
|
|
||||||
theme: RibbitTheme;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface RibbitEventMap {
|
|
||||||
/*
|
|
||||||
* Content was modified. Fires on every edit.
|
|
||||||
*
|
|
||||||
* editor.on('change', ({ markdown }) => {
|
|
||||||
* localStorage.setItem('draft', markdown);
|
|
||||||
* });
|
|
||||||
*/
|
|
||||||
change: (payload: ContentPayload) => void;
|
|
||||||
|
|
||||||
/*
|
|
||||||
* Save requested via editor.save(), toolbar button, or Ctrl+S.
|
|
||||||
*
|
|
||||||
* editor.on('save', ({ markdown, html }) => {
|
|
||||||
* fetch('/api/save', { method: 'POST', body: markdown });
|
|
||||||
* });
|
|
||||||
*/
|
|
||||||
save: (payload: ContentPayload) => void;
|
|
||||||
|
|
||||||
/*
|
|
||||||
* Editor mode switched between view, edit, and wysiwyg.
|
|
||||||
*
|
|
||||||
* editor.on('modeChange', ({ current, previous }) => {
|
|
||||||
* toolbar.toggle(current !== 'view');
|
|
||||||
* main.classList.toggle('editing', current !== 'view');
|
|
||||||
* });
|
|
||||||
*/
|
|
||||||
modeChange: (payload: ModeChangePayload) => void;
|
|
||||||
|
|
||||||
/*
|
|
||||||
* Theme switched via editor.themes.set().
|
|
||||||
*
|
|
||||||
* editor.on('themeChange', ({ current, previous }) => {
|
|
||||||
* analytics.track('theme_switch', { from: previous.name, to: current.name });
|
|
||||||
* });
|
|
||||||
*/
|
|
||||||
themeChange: (payload: ThemeChangePayload) => void;
|
|
||||||
|
|
||||||
/*
|
|
||||||
* Editor initialized and first render complete.
|
|
||||||
*
|
|
||||||
* editor.on('ready', ({ mode, theme }) => {
|
|
||||||
* console.log(`Editor ready in ${mode} mode with ${theme.name} theme`);
|
|
||||||
* });
|
|
||||||
*/
|
|
||||||
ready: (payload: ReadyPayload) => void;
|
|
||||||
}
|
|
||||||
|
|
||||||
type EventName = keyof RibbitEventMap;
|
|
||||||
|
|
||||||
export class RibbitEmitter {
|
|
||||||
private listeners: Map<string, Set<Function>>;
|
|
||||||
|
|
||||||
constructor() {
|
|
||||||
this.listeners = new Map();
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Register a callback for an event.
|
|
||||||
*/
|
|
||||||
on<K extends EventName>(event: K, callback: RibbitEventMap[K]): void {
|
|
||||||
if (!this.listeners.has(event)) {
|
|
||||||
this.listeners.set(event, new Set());
|
|
||||||
}
|
|
||||||
this.listeners.get(event)!.add(callback);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Remove a previously registered callback.
|
|
||||||
*/
|
|
||||||
off<K extends EventName>(event: K, callback: RibbitEventMap[K]): void {
|
|
||||||
this.listeners.get(event)?.delete(callback);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Emit an event, calling all registered callbacks with the payload.
|
|
||||||
*/
|
|
||||||
emit<K extends EventName>(event: K, ...args: Parameters<RibbitEventMap[K]>): void {
|
|
||||||
for (const callback of this.listeners.get(event) || []) {
|
|
||||||
callback(...args);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,115 +0,0 @@
|
||||||
/*
|
|
||||||
* theme-manager.ts — manages theme registration and activation for a Ribbit instance.
|
|
||||||
*/
|
|
||||||
|
|
||||||
import type { RibbitTheme } from './types';
|
|
||||||
import { HopDown } from './hopdown';
|
|
||||||
|
|
||||||
export class ThemeManager {
|
|
||||||
private registered: Map<string, RibbitTheme>;
|
|
||||||
private disabled: Set<string>;
|
|
||||||
private active: RibbitTheme;
|
|
||||||
private themeLink: HTMLLinkElement | null;
|
|
||||||
private themesPath: string;
|
|
||||||
private onSwitch: (theme: RibbitTheme, previous: RibbitTheme) => void;
|
|
||||||
|
|
||||||
constructor(initial: RibbitTheme, themesPath: string, onSwitch: (theme: RibbitTheme, previous: RibbitTheme) => void) {
|
|
||||||
this.registered = new Map();
|
|
||||||
this.disabled = new Set();
|
|
||||||
this.themeLink = null;
|
|
||||||
this.themesPath = themesPath;
|
|
||||||
this.onSwitch = onSwitch;
|
|
||||||
this.active = initial;
|
|
||||||
this.add(initial);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Register a theme. Themes must be added before they can be enabled.
|
|
||||||
*/
|
|
||||||
add(theme: RibbitTheme): void {
|
|
||||||
this.registered.set(theme.name, theme);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Unregister a theme by name. Cannot remove the active theme.
|
|
||||||
*/
|
|
||||||
remove(name: string): void {
|
|
||||||
if (this.active.name === name) {
|
|
||||||
throw new Error(`Cannot remove the active theme "${name}".`);
|
|
||||||
}
|
|
||||||
this.registered.delete(name);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Return the names of all registered and enabled themes.
|
|
||||||
*/
|
|
||||||
list(): string[] {
|
|
||||||
return Array.from(this.registered.keys()).filter(name => !this.disabled.has(name));
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Get a registered theme by name, or undefined if not found.
|
|
||||||
*/
|
|
||||||
get(name: string): RibbitTheme | undefined {
|
|
||||||
return this.registered.get(name);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Return the currently active theme.
|
|
||||||
*/
|
|
||||||
current(): RibbitTheme {
|
|
||||||
return this.active;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Switch to a registered theme by name. The theme must be
|
|
||||||
* registered and enabled. Loads the theme's CSS and notifies
|
|
||||||
* the editor to rebuild its converter.
|
|
||||||
*/
|
|
||||||
set(name: string): void {
|
|
||||||
const theme = this.registered.get(name);
|
|
||||||
if (!theme) {
|
|
||||||
throw new Error(`Theme "${name}" is not registered. Call add() first.`);
|
|
||||||
}
|
|
||||||
if (this.disabled.has(name)) {
|
|
||||||
throw new Error(`Theme "${name}" is disabled. Call enable() first.`);
|
|
||||||
}
|
|
||||||
const previous = this.active;
|
|
||||||
this.active = theme;
|
|
||||||
this.loadCSS(name);
|
|
||||||
this.onSwitch(theme, previous);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Mark a theme as available for selection via set().
|
|
||||||
* Themes are enabled by default when added.
|
|
||||||
*/
|
|
||||||
enable(name: string): void {
|
|
||||||
if (!this.registered.has(name)) {
|
|
||||||
throw new Error(`Theme "${name}" is not registered. Call add() first.`);
|
|
||||||
}
|
|
||||||
this.disabled.delete(name);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Mark a theme as unavailable for selection via set().
|
|
||||||
* Does not affect the current theme if it is already active.
|
|
||||||
*/
|
|
||||||
disable(name: string): void {
|
|
||||||
if (!this.registered.has(name)) {
|
|
||||||
throw new Error(`Theme "${name}" is not registered.`);
|
|
||||||
}
|
|
||||||
this.disabled.add(name);
|
|
||||||
}
|
|
||||||
|
|
||||||
private loadCSS(name: string): void {
|
|
||||||
if (this.themeLink) {
|
|
||||||
this.themeLink.remove();
|
|
||||||
}
|
|
||||||
const link = document.createElement('link');
|
|
||||||
link.rel = 'stylesheet';
|
|
||||||
link.href = `${this.themesPath}/${name}/theme.css`;
|
|
||||||
document.head.appendChild(link);
|
|
||||||
this.themeLink = link;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -54,13 +54,3 @@ export interface InlineTagDef {
|
||||||
/** Process inner content for nested markdown? Default true. False for code spans. */
|
/** Process inner content for nested markdown? Default true. False for code spans. */
|
||||||
recursive?: boolean;
|
recursive?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface RibbitThemeFeatures {
|
|
||||||
sourceMode?: boolean;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface RibbitTheme {
|
|
||||||
name: string;
|
|
||||||
tags?: Record<string, Tag>;
|
|
||||||
features?: RibbitThemeFeatures;
|
|
||||||
}
|
|
||||||
|
|
@ -12,12 +12,11 @@ global.document = dom.window.document;
|
||||||
global.HTMLElement = dom.window.HTMLElement;
|
global.HTMLElement = dom.window.HTMLElement;
|
||||||
global.Node = dom.window.Node;
|
global.Node = dom.window.Node;
|
||||||
|
|
||||||
// Load the compiled bundle — esbuild IIFE assigns to var ribbit,
|
// Load the compiled bundle (attaches globals to window)
|
||||||
// but eval in jsdom doesn't attach vars to window, so we patch it.
|
const bundle = fs.readFileSync(path.join(__dirname, '..', 'dist', 'ribbit.js'), 'utf8');
|
||||||
const bundle = fs.readFileSync(path.join(__dirname, '..', 'dist', 'ribbit', 'ribbit.js'), 'utf8');
|
dom.window.eval(bundle);
|
||||||
dom.window.eval(bundle.replace('var ribbit =', 'window.ribbit ='));
|
|
||||||
|
|
||||||
const hopdown = new dom.window.ribbit.HopDown();
|
const hopdown = dom.window.hopdown;
|
||||||
const H = hopdown.toHTML.bind(hopdown);
|
const H = hopdown.toHTML.bind(hopdown);
|
||||||
const M = hopdown.toMarkdown.bind(hopdown);
|
const M = hopdown.toMarkdown.bind(hopdown);
|
||||||
function rt(md) { return M(H(md)); }
|
function rt(md) { return M(H(md)); }
|
||||||
|
|
@ -288,22 +287,22 @@ eq('td link>bold rt', rt('| h |\n|---|\n| [**t**](u) |'), '| h |\n| --- |\n| [**
|
||||||
eq('multi-cell rt', rt('| **a** | *b* |\n|---|---|\n| `c` | [d](e) |'), '| **a** | *b* |\n| --- | --- |\n| `c` | [d](e) |');
|
eq('multi-cell rt', rt('| **a** | *b* |\n|---|---|\n| `c` | [d](e) |'), '| **a** | *b* |\n| --- | --- |\n| `c` | [d](e) |');
|
||||||
|
|
||||||
// ── 18. inlineTag() factory ─────────────────────────────
|
// ── 18. inlineTag() factory ─────────────────────────────
|
||||||
const strikethrough = dom.window.ribbit.inlineTag({
|
const strikethrough = dom.window.inlineTag({
|
||||||
name: 'strikethrough',
|
name: 'strikethrough',
|
||||||
delimiter: '~~',
|
delimiter: '~~',
|
||||||
htmlTag: 'del',
|
htmlTag: 'del',
|
||||||
aliases: 'S,STRIKE',
|
aliases: 'S,STRIKE',
|
||||||
precedence: 45,
|
precedence: 45,
|
||||||
});
|
});
|
||||||
const customInline = new dom.window.ribbit.HopDown({
|
const customInline = new dom.window.HopDown({
|
||||||
tags: { ...dom.window.ribbit.defaultTags, 'DEL,S,STRIKE': strikethrough },
|
tags: { ...dom.window.defaultTags, 'DEL,S,STRIKE': strikethrough },
|
||||||
});
|
});
|
||||||
eq('factory: md→html', customInline.toHTML('~~struck~~'), '<p><del>struck</del></p>');
|
eq('factory: md→html', customInline.toHTML('~~struck~~'), '<p><del>struck</del></p>');
|
||||||
has('factory: html→md', customInline.toMarkdown('<p><del>struck</del></p>'), '~~struck~~');
|
has('factory: html→md', customInline.toMarkdown('<p><del>struck</del></p>'), '~~struck~~');
|
||||||
eq('factory: round-trip', customInline.toMarkdown(customInline.toHTML('~~struck~~')), '~~struck~~');
|
eq('factory: round-trip', customInline.toMarkdown(customInline.toHTML('~~struck~~')), '~~struck~~');
|
||||||
has('factory: mixed with bold', customInline.toHTML('**bold** and ~~struck~~'), '<del>struck</del>');
|
has('factory: mixed with bold', customInline.toHTML('**bold** and ~~struck~~'), '<del>struck</del>');
|
||||||
has('factory: mixed with bold', customInline.toHTML('**bold** and ~~struck~~'), '<strong>bold</strong>');
|
has('factory: mixed with bold', customInline.toHTML('**bold** and ~~struck~~'), '<strong>bold</strong>');
|
||||||
eq('factory: non-recursive', dom.window.ribbit.inlineTag({
|
eq('factory: non-recursive', dom.window.inlineTag({
|
||||||
name: 'test',
|
name: 'test',
|
||||||
delimiter: '%%',
|
delimiter: '%%',
|
||||||
htmlTag: 'mark',
|
htmlTag: 'mark',
|
||||||
|
|
@ -325,8 +324,8 @@ const spoiler = {
|
||||||
selector: 'DETAILS',
|
selector: 'DETAILS',
|
||||||
toMarkdown: (element, convert) => '\n\n|||\n' + convert.children(element).trim() + '\n|||\n\n',
|
toMarkdown: (element, convert) => '\n\n|||\n' + convert.children(element).trim() + '\n|||\n\n',
|
||||||
};
|
};
|
||||||
const customBlock = new dom.window.ribbit.HopDown({
|
const customBlock = new dom.window.HopDown({
|
||||||
tags: { 'DETAILS': spoiler, ...dom.window.ribbit.defaultTags },
|
tags: { 'DETAILS': spoiler, ...dom.window.defaultTags },
|
||||||
});
|
});
|
||||||
has('custom block: md→html', customBlock.toHTML('|||\nhidden\n|||'), '<details>');
|
has('custom block: md→html', customBlock.toHTML('|||\nhidden\n|||'), '<details>');
|
||||||
has('custom block: content', customBlock.toHTML('|||\nhidden\n|||'), 'hidden');
|
has('custom block: content', customBlock.toHTML('|||\nhidden\n|||'), 'hidden');
|
||||||
|
|
@ -334,22 +333,22 @@ has('custom block: html→md', customBlock.toMarkdown('<details><summary>Spoiler
|
||||||
has('custom block: nested md', customBlock.toHTML('|||\n**bold** inside\n|||'), '<strong>bold</strong>');
|
has('custom block: nested md', customBlock.toHTML('|||\n**bold** inside\n|||'), '<strong>bold</strong>');
|
||||||
|
|
||||||
// ── 20. HopDown({ exclude }) ────────────────────────────
|
// ── 20. HopDown({ exclude }) ────────────────────────────
|
||||||
const noTables = new dom.window.ribbit.HopDown({ exclude: ['table'] });
|
const noTables = new dom.window.HopDown({ exclude: ['table'] });
|
||||||
// With table excluded, pipe lines fall through to paragraph but isBlockStart
|
// With table excluded, pipe lines fall through to paragraph but isBlockStart
|
||||||
// still detects table-like patterns, so lines are split across paragraphs.
|
// still detects table-like patterns, so lines are split across paragraphs.
|
||||||
has('exclude: table not rendered', noTables.toHTML('| a | b |\n|---|---|\n| 1 | 2 |'), '<p>');
|
has('exclude: table not rendered', noTables.toHTML('| a | b |\n|---|---|\n| 1 | 2 |'), '<p>');
|
||||||
not('exclude: no table tag', noTables.toHTML('| a | b |\n|---|---|\n| 1 | 2 |'), '<table>');
|
not('exclude: no table tag', noTables.toHTML('| a | b |\n|---|---|\n| 1 | 2 |'), '<table>');
|
||||||
has('exclude: bold still works', noTables.toHTML('**bold**'), '<strong>bold</strong>');
|
has('exclude: bold still works', noTables.toHTML('**bold**'), '<strong>bold</strong>');
|
||||||
|
|
||||||
const noCode = new dom.window.ribbit.HopDown({ exclude: ['code'] });
|
const noCode = new dom.window.HopDown({ exclude: ['code'] });
|
||||||
eq('exclude: code not processed', noCode.toHTML('`code`'), '<p>`code`</p>');
|
eq('exclude: code not processed', noCode.toHTML('`code`'), '<p>`code`</p>');
|
||||||
has('exclude: bold still works', noCode.toHTML('**bold**'), '<strong>bold</strong>');
|
has('exclude: bold still works', noCode.toHTML('**bold**'), '<strong>bold</strong>');
|
||||||
|
|
||||||
// ── 21. Collision detection: delimiter ───────────────────
|
// ── 21. Collision detection: delimiter ───────────────────
|
||||||
let threw = false;
|
let threw = false;
|
||||||
try {
|
try {
|
||||||
const bad = dom.window.ribbit.inlineTag({ name: 'bad', delimiter: '*', htmlTag: 'span', precedence: 10 });
|
const bad = dom.window.inlineTag({ name: 'bad', delimiter: '*', htmlTag: 'span', precedence: 10 });
|
||||||
new dom.window.ribbit.HopDown({ tags: { ...dom.window.ribbit.defaultTags, 'SPAN': bad } });
|
new dom.window.HopDown({ tags: { ...dom.window.defaultTags, 'SPAN': bad } });
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
threw = true;
|
threw = true;
|
||||||
}
|
}
|
||||||
|
|
@ -358,8 +357,8 @@ eq('delimiter collision throws', String(threw), 'true');
|
||||||
threw = false;
|
threw = false;
|
||||||
try {
|
try {
|
||||||
// Same delimiter, higher precedence than existing — should throw
|
// Same delimiter, higher precedence than existing — should throw
|
||||||
const bad = dom.window.ribbit.inlineTag({ name: 'bad', delimiter: '**', htmlTag: 'span', precedence: 60 });
|
const bad = dom.window.inlineTag({ name: 'bad', delimiter: '**', htmlTag: 'span', precedence: 60 });
|
||||||
new dom.window.ribbit.HopDown({ tags: { ...dom.window.ribbit.defaultTags, 'SPAN': bad } });
|
new dom.window.HopDown({ tags: { ...dom.window.defaultTags, 'SPAN': bad } });
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
threw = true;
|
threw = true;
|
||||||
}
|
}
|
||||||
|
|
@ -369,7 +368,7 @@ eq('duplicate delimiter collision throws', String(threw), 'true');
|
||||||
threw = false;
|
threw = false;
|
||||||
try {
|
try {
|
||||||
const dup = { name: 'dup', match: () => null, toHTML: () => '', selector: 'STRONG', toMarkdown: () => '' };
|
const dup = { name: 'dup', match: () => null, toHTML: () => '', selector: 'STRONG', toMarkdown: () => '' };
|
||||||
new dom.window.ribbit.HopDown({ tags: { ...dom.window.ribbit.defaultTags, 'STRONG': dup } });
|
new dom.window.HopDown({ tags: { ...dom.window.defaultTags, 'STRONG': dup } });
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
threw = true;
|
threw = true;
|
||||||
}
|
}
|
||||||
|
|
@ -377,10 +376,10 @@ eq('selector collision throws', String(threw), 'true');
|
||||||
|
|
||||||
// ── 23. Precedence ordering ─────────────────────────────
|
// ── 23. Precedence ordering ─────────────────────────────
|
||||||
// Longer delimiter with lower precedence should win
|
// Longer delimiter with lower precedence should win
|
||||||
const tilde = dom.window.ribbit.inlineTag({ name: 'tilde', delimiter: '~', htmlTag: 's', precedence: 45 });
|
const tilde = dom.window.inlineTag({ name: 'tilde', delimiter: '~', htmlTag: 's', precedence: 45 });
|
||||||
const doubleTilde = dom.window.ribbit.inlineTag({ name: 'doubleTilde', delimiter: '~~', htmlTag: 'del', precedence: 35 });
|
const doubleTilde = dom.window.inlineTag({ name: 'doubleTilde', delimiter: '~~', htmlTag: 'del', precedence: 35 });
|
||||||
const precTest = new dom.window.ribbit.HopDown({
|
const precTest = new dom.window.HopDown({
|
||||||
tags: { ...dom.window.ribbit.defaultTags, 'S': tilde, 'DEL': doubleTilde },
|
tags: { ...dom.window.defaultTags, 'S': tilde, 'DEL': doubleTilde },
|
||||||
});
|
});
|
||||||
has('precedence: ~~ matches before ~', precTest.toHTML('~~struck~~'), '<del>struck</del>');
|
has('precedence: ~~ matches before ~', precTest.toHTML('~~struck~~'), '<del>struck</del>');
|
||||||
has('precedence: ~ still works', precTest.toHTML('~light~'), '<s>light</s>');
|
has('precedence: ~ still works', precTest.toHTML('~light~'), '<s>light</s>');
|
||||||
|
|
@ -388,9 +387,9 @@ has('precedence: ~ still works', precTest.toHTML('~light~'), '<s>light</s>');
|
||||||
// Valid: longer delimiter has lower precedence
|
// Valid: longer delimiter has lower precedence
|
||||||
threw = false;
|
threw = false;
|
||||||
try {
|
try {
|
||||||
const short = dom.window.ribbit.inlineTag({ name: 'short', delimiter: '~', htmlTag: 's', precedence: 50 });
|
const short = dom.window.inlineTag({ name: 'short', delimiter: '~', htmlTag: 's', precedence: 50 });
|
||||||
const long = dom.window.ribbit.inlineTag({ name: 'long', delimiter: '~~', htmlTag: 'del', precedence: 40 });
|
const long = dom.window.inlineTag({ name: 'long', delimiter: '~~', htmlTag: 'del', precedence: 40 });
|
||||||
new dom.window.ribbit.HopDown({ tags: { ...dom.window.ribbit.defaultTags, 'S': short, 'DEL': long } });
|
new dom.window.HopDown({ tags: { ...dom.window.defaultTags, 'S': short, 'DEL': long } });
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
threw = true;
|
threw = true;
|
||||||
}
|
}
|
||||||
|
|
@ -399,9 +398,9 @@ eq('valid precedence does not throw', String(threw), 'false');
|
||||||
// Invalid: longer delimiter has higher precedence
|
// Invalid: longer delimiter has higher precedence
|
||||||
threw = false;
|
threw = false;
|
||||||
try {
|
try {
|
||||||
const short = dom.window.ribbit.inlineTag({ name: 'short', delimiter: '~', htmlTag: 's', precedence: 30 });
|
const short = dom.window.inlineTag({ name: 'short', delimiter: '~', htmlTag: 's', precedence: 30 });
|
||||||
const long = dom.window.ribbit.inlineTag({ name: 'long', delimiter: '~~', htmlTag: 'del', precedence: 50 });
|
const long = dom.window.inlineTag({ name: 'long', delimiter: '~~', htmlTag: 'del', precedence: 50 });
|
||||||
new dom.window.ribbit.HopDown({ tags: { ...dom.window.ribbit.defaultTags, 'S': short, 'DEL': long } });
|
new dom.window.HopDown({ tags: { ...dom.window.defaultTags, 'S': short, 'DEL': long } });
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
threw = true;
|
threw = true;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -5,10 +5,10 @@
|
||||||
"module": "ESNext",
|
"module": "ESNext",
|
||||||
"moduleResolution": "bundler",
|
"moduleResolution": "bundler",
|
||||||
"outDir": "dist",
|
"outDir": "dist",
|
||||||
"rootDir": "src/ts",
|
"rootDir": "src",
|
||||||
"declaration": true,
|
"declaration": true,
|
||||||
"sourceMap": true,
|
"sourceMap": true,
|
||||||
"lib": ["ES2019", "DOM"]
|
"lib": ["ES2019", "DOM"]
|
||||||
},
|
},
|
||||||
"include": ["src/ts/**/*.ts"]
|
"include": ["src/**/*.ts"]
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue
Block a user