Compare commits

...

3 Commits

Author SHA1 Message Date
gsb
df49ce7545 Single ribbit namespace instead of window globals
Use esbuild --global-name=ribbit to expose a single namespace.
2026-04-29 01:40:18 +00:00
gsb
f76ebbf2e5 feat: Add typed event system with on/off/emit
New events with structured payloads:

  change({ markdown, html })
    Fires on every content edit.

  save({ markdown, html })
    Fires when editor.save() is called. Consumer handles persistence.

  modeChange({ current, previous })
    Fires on VIEW/EDIT/WYSIWYG transitions.

  themeChange({ current, previous })
    Fires when themes.set() switches the active theme.

  ready({ markdown, html, mode, theme })
    Fires after editor.run() completes first render.

Events can be registered in the constructor via the 'on' setting
or at any time via editor.on(event, callback) / editor.off().

202/202 tests passing.
2026-04-29 01:35:06 +00:00
gsb
ac7a698c4f Add themes support
Usage:

  const editor = new RibbitEditor({
    themes: [
        { name: 'dark', features: { sourceMode: false } },
        { name: 'minimal', tags: minimalTags },
    ],
    currentTheme: 'dark',
  });

The built-in theme is 'ribbit-default' and is always available.
Additional themes from the themes array are registered on top.
2026-04-29 01:20:55 +00:00
14 changed files with 489 additions and 109 deletions

View File

@ -9,10 +9,11 @@
"src/" "src/"
], ],
"scripts": { "scripts": {
"build": "npm run build:check && npm run build:js && npm run build:min", "build": "mkdir -p dist/ribbit && npm run build:check && npm run build:js && npm run build:min && npm run build:css",
"build:check": "tsc --noEmit", "build:check": "tsc --noEmit",
"build:js": "esbuild src/ribbit-editor.ts --bundle --format=iife --sourcemap --outfile=dist/ribbit.js", "build:js": "esbuild src/ts/ribbit-editor.ts --bundle --format=iife --global-name=ribbit --sourcemap --outfile=dist/ribbit/ribbit.js",
"build:min": "esbuild src/ribbit-editor.ts --bundle --format=iife --minify --outfile=dist/ribbit.min.js", "build:min": "esbuild src/ts/ribbit-editor.ts --bundle --format=iife --global-name=ribbit --minify --outfile=dist/ribbit/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",

View File

@ -1,58 +0,0 @@
/*
* 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;
}

View File

@ -0,0 +1,22 @@
/*
* 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;
}

View File

@ -0,0 +1,52 @@
/*
* 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;
}

16
src/ts/default-theme.ts Normal file
View File

@ -0,0 +1,16 @@
/*
* 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 Normal file
View File

@ -0,0 +1,111 @@
/*
* 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);
}
}
}

View File

@ -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.changed = true; this.notifyChange();
} }
}); });
} }
htmlToMarkdown(html?: string): string { htmlToMarkdown(html?: string): string {
return hopdown.toMarkdown(html || this.element.innerHTML); return this.converter.toMarkdown(html || this.element.innerHTML);
} }
getMarkdown(): string { getMarkdown(): string {
@ -80,6 +80,9 @@ 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';
@ -99,16 +102,12 @@ export class RibbitEditor extends Ribbit {
} }
} }
// Attach public API to window for <script> tag usage. // Public API — accessed as ribbit.Editor, ribbit.HopDown, etc.
(window as any).HopDown = HopDown; export { RibbitEditor as Editor };
(window as any).hopdown = hopdown; export { Ribbit as Viewer };
(window as any).inlineTag = inlineTag; export { RibbitPlugin as Plugin };
(window as any).defaultTags = defaultTags; export { HopDown };
(window as any).defaultBlockTags = defaultBlockTags; export { inlineTag };
(window as any).defaultInlineTags = defaultInlineTags; export { defaultTags, defaultBlockTags, defaultInlineTags };
(window as any).Ribbit = Ribbit; export { defaultTheme };
(window as any).RibbitEditor = RibbitEditor; export { camelCase, decodeHtmlEntities, encodeHtmlEntities };
(window as any).RibbitPlugin = RibbitPlugin;
(window as any).camelCase = camelCase;
(window as any).decodeHtmlEntities = decodeHtmlEntities;
(window as any).encodeHtmlEntities = encodeHtmlEntities;

View File

@ -2,12 +2,20 @@
* 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>;
} }
/** /**
@ -41,7 +49,12 @@ 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({ editorId: 'my-element' }); * const viewer = new Ribbit({
* editorId: 'my-element',
* on: {
* ready: ({ mode, theme }) => console.log(`Ready in ${mode}`),
* },
* });
* viewer.run(); * viewer.run();
*/ */
export class Ribbit { export class Ribbit {
@ -53,10 +66,17 @@ 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',
}; };
@ -66,17 +86,78 @@ 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[] {
@ -88,6 +169,7 @@ 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) {
@ -96,10 +178,14 @@ 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 hopdown.toHTML(md); return this.converter.toHTML(md);
} }
getHTML(): string { getHTML(): string {
@ -116,12 +202,37 @@ 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(),
});
}
} }
/** /**

115
src/ts/theme-manager.ts Normal file
View File

@ -0,0 +1,115 @@
/*
* 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;
}
}

View File

@ -54,3 +54,13 @@ 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;
}

View File

@ -12,11 +12,12 @@ 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 (attaches globals to window) // Load the compiled bundle — esbuild IIFE assigns to var ribbit,
const bundle = fs.readFileSync(path.join(__dirname, '..', 'dist', 'ribbit.js'), 'utf8'); // but eval in jsdom doesn't attach vars to window, so we patch it.
dom.window.eval(bundle); const bundle = fs.readFileSync(path.join(__dirname, '..', 'dist', 'ribbit', 'ribbit.js'), 'utf8');
dom.window.eval(bundle.replace('var ribbit =', 'window.ribbit ='));
const hopdown = dom.window.hopdown; const hopdown = new dom.window.ribbit.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)); }
@ -287,22 +288,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.inlineTag({ const strikethrough = dom.window.ribbit.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.HopDown({ const customInline = new dom.window.ribbit.HopDown({
tags: { ...dom.window.defaultTags, 'DEL,S,STRIKE': strikethrough }, tags: { ...dom.window.ribbit.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.inlineTag({ eq('factory: non-recursive', dom.window.ribbit.inlineTag({
name: 'test', name: 'test',
delimiter: '%%', delimiter: '%%',
htmlTag: 'mark', htmlTag: 'mark',
@ -324,8 +325,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.HopDown({ const customBlock = new dom.window.ribbit.HopDown({
tags: { 'DETAILS': spoiler, ...dom.window.defaultTags }, tags: { 'DETAILS': spoiler, ...dom.window.ribbit.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');
@ -333,22 +334,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.HopDown({ exclude: ['table'] }); const noTables = new dom.window.ribbit.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.HopDown({ exclude: ['code'] }); const noCode = new dom.window.ribbit.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.inlineTag({ name: 'bad', delimiter: '*', htmlTag: 'span', precedence: 10 }); const bad = dom.window.ribbit.inlineTag({ name: 'bad', delimiter: '*', htmlTag: 'span', precedence: 10 });
new dom.window.HopDown({ tags: { ...dom.window.defaultTags, 'SPAN': bad } }); new dom.window.ribbit.HopDown({ tags: { ...dom.window.ribbit.defaultTags, 'SPAN': bad } });
} catch (e) { } catch (e) {
threw = true; threw = true;
} }
@ -357,8 +358,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.inlineTag({ name: 'bad', delimiter: '**', htmlTag: 'span', precedence: 60 }); const bad = dom.window.ribbit.inlineTag({ name: 'bad', delimiter: '**', htmlTag: 'span', precedence: 60 });
new dom.window.HopDown({ tags: { ...dom.window.defaultTags, 'SPAN': bad } }); new dom.window.ribbit.HopDown({ tags: { ...dom.window.ribbit.defaultTags, 'SPAN': bad } });
} catch (e) { } catch (e) {
threw = true; threw = true;
} }
@ -368,7 +369,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.HopDown({ tags: { ...dom.window.defaultTags, 'STRONG': dup } }); new dom.window.ribbit.HopDown({ tags: { ...dom.window.ribbit.defaultTags, 'STRONG': dup } });
} catch (e) { } catch (e) {
threw = true; threw = true;
} }
@ -376,10 +377,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.inlineTag({ name: 'tilde', delimiter: '~', htmlTag: 's', precedence: 45 }); const tilde = dom.window.ribbit.inlineTag({ name: 'tilde', delimiter: '~', htmlTag: 's', precedence: 45 });
const doubleTilde = dom.window.inlineTag({ name: 'doubleTilde', delimiter: '~~', htmlTag: 'del', precedence: 35 }); const doubleTilde = dom.window.ribbit.inlineTag({ name: 'doubleTilde', delimiter: '~~', htmlTag: 'del', precedence: 35 });
const precTest = new dom.window.HopDown({ const precTest = new dom.window.ribbit.HopDown({
tags: { ...dom.window.defaultTags, 'S': tilde, 'DEL': doubleTilde }, tags: { ...dom.window.ribbit.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>');
@ -387,9 +388,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.inlineTag({ name: 'short', delimiter: '~', htmlTag: 's', precedence: 50 }); const short = dom.window.ribbit.inlineTag({ name: 'short', delimiter: '~', htmlTag: 's', precedence: 50 });
const long = dom.window.inlineTag({ name: 'long', delimiter: '~~', htmlTag: 'del', precedence: 40 }); const long = dom.window.ribbit.inlineTag({ name: 'long', delimiter: '~~', htmlTag: 'del', precedence: 40 });
new dom.window.HopDown({ tags: { ...dom.window.defaultTags, 'S': short, 'DEL': long } }); new dom.window.ribbit.HopDown({ tags: { ...dom.window.ribbit.defaultTags, 'S': short, 'DEL': long } });
} catch (e) { } catch (e) {
threw = true; threw = true;
} }
@ -398,9 +399,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.inlineTag({ name: 'short', delimiter: '~', htmlTag: 's', precedence: 30 }); const short = dom.window.ribbit.inlineTag({ name: 'short', delimiter: '~', htmlTag: 's', precedence: 30 });
const long = dom.window.inlineTag({ name: 'long', delimiter: '~~', htmlTag: 'del', precedence: 50 }); const long = dom.window.ribbit.inlineTag({ name: 'long', delimiter: '~~', htmlTag: 'del', precedence: 50 });
new dom.window.HopDown({ tags: { ...dom.window.defaultTags, 'S': short, 'DEL': long } }); new dom.window.ribbit.HopDown({ tags: { ...dom.window.ribbit.defaultTags, 'S': short, 'DEL': long } });
} catch (e) { } catch (e) {
threw = true; threw = true;
} }

View File

@ -5,10 +5,10 @@
"module": "ESNext", "module": "ESNext",
"moduleResolution": "bundler", "moduleResolution": "bundler",
"outDir": "dist", "outDir": "dist",
"rootDir": "src", "rootDir": "src/ts",
"declaration": true, "declaration": true,
"sourceMap": true, "sourceMap": true,
"lib": ["ES2019", "DOM"] "lib": ["ES2019", "DOM"]
}, },
"include": ["src/**/*.ts"] "include": ["src/ts/**/*.ts"]
} }