Compare commits

...

2 Commits

Author SHA1 Message Date
gsb
2b88d2c10b Reimplement tests 2026-04-29 05:02:25 +00:00
gsb
4237a3f6a2 Add support for wysiwyg markdown preview 2026-04-29 05:01:51 +00:00
18 changed files with 6724 additions and 1550 deletions

103
README.md
View File

@ -1,44 +1,97 @@
# ribbit # ribbit
Zero-dependency WYSIWYG markdown editor Zero-dependency WYSIWYG markdown editor for the browser.
## Files ## Source Layout
- `src/hopdown.js` — Markdown ↔ HTML converter (`HopDown.toHTML()`, `HopDown.toMarkdown()`) - `src/ts/` — TypeScript source files
- `src/ribbit.js` — Base viewer class (`Ribbit`), plugin base class (`RibbitPlugin`), utilities - `types.ts` — shared interfaces (Tag, SourceToken, Converter, etc.)
- `src/ribbit-editor.js` — Editor class (`RibbitEditor`) with VIEW/EDIT/WYSIWYG modes - `tags.ts` — tag definitions and `inlineTag()` factory
- `src/ribbit.css` — Editor and content styles - `hopdown.ts` — configurable markdown↔HTML converter (HopDown class)
- `macros.ts` — macro parsing and Tag generation
- `ribbit.ts` — Ribbit viewer, RibbitPlugin, utilities
- `ribbit-editor.ts` — RibbitEditor with WYSIWYG support, public API exports
- `default-theme.ts` — built-in theme definition
- `theme-manager.ts` — theme registration and switching
- `events.ts` — typed event emitter
- `src/static/` — CSS and static assets
- `ribbit-core.css` — functional editor styles (always load)
- `themes/ribbit-default/theme.css` — default theme
## Build Output
```
dist/ribbit/
├── ribbit.js # readable IIFE bundle + source map
├── ribbit.min.js # minified bundle
├── ribbit-core.css # functional styles
└── themes/
└── ribbit-default/
└── theme.css # default theme (imports ribbit-core.css)
```
## Usage ## Usage
```html ```html
<link rel="stylesheet" href="ribbit/src/ribbit.css"> <link rel="stylesheet" href="ribbit/themes/ribbit-default/theme.css">
<article id="ribbit">your markdown here</article> <article id="ribbit">your markdown here</article>
<script src="ribbit/src/hopdown.js"></script> <script src="ribbit/ribbit.js"></script>
<script src="ribbit/src/ribbit.js"></script>
<script src="ribbit/src/ribbit-editor.js"></script>
<script> <script>
const editor = new RibbitEditor({ plugins: [] }); const editor = new ribbit.Editor({
on: {
save: ({ markdown }) => {
fetch('/api/save', { method: 'POST', body: markdown });
},
},
macros: [
{
name: 'npc',
toHTML: ({ keywords }) => {
const name = keywords.join(' ');
return `<a href="/NPC/${name}">${name}</a>`;
},
},
],
});
editor.run(); editor.run();
editor.wysiwyg();
// Switch modes
editor.wysiwyg(); // WYSIWYG editing
editor.edit(); // Source editing
editor.view(); // Read-only view
// Get content
editor.getMarkdown();
editor.getHTML();
</script> </script>
``` ```
## Custom Block Tags
```javascript
const spoiler = {
name: 'spoiler',
match: (context) => {
if (!/^\|{3,}/.test(context.lines[context.index])) return null;
const content = [];
let i = context.index + 1;
while (i < context.lines.length && !/^\|{3,}/.test(context.lines[i]))
content.push(context.lines[i++]);
return { content: content.join('\n'), raw: '', consumed: i + 1 - context.index };
},
toHTML: (token, convert) =>
'<details><summary>Spoiler</summary>' + convert.block(token.content) + '</details>',
selector: 'DETAILS',
toMarkdown: (element, convert) =>
'\n\n|||\n' + convert.children(element).trim() + '\n|||\n\n',
};
const converter = new ribbit.HopDown({
tags: { ...ribbit.defaultTags, 'DETAILS': spoiler },
});
```
## Tests
```
npm test
```
## Supported Markdown ## Supported Markdown
Bold, italic, inline code, links, headings (h1-h6), unordered/ordered/nested lists, Bold, italic, inline code, links, headings (h1-h6), unordered/ordered/nested lists,
blockquotes, fenced code blocks with language, horizontal rules, GFM tables with blockquotes, fenced code blocks with language, horizontal rules, GFM tables with
column alignment, and paragraphs. Arbitrary nesting of all inline formatting. column alignment, paragraphs, and macros (@name syntax).
## Tests
Open `test/test_ribbit-down.html` in a browser.

22
jest.config.js Normal file
View File

@ -0,0 +1,22 @@
/** @type {import('ts-jest').JestConfigWithTsJest} */
module.exports = {
preset: 'ts-jest',
testEnvironment: 'node',
roots: ['<rootDir>/test'],
moduleNameMapper: {
'^(\\.{1,2}/.*)\\.js$': '$1',
},
transform: {
'^.+\\.tsx?$': ['ts-jest', {
tsconfig: {
strict: true,
target: 'ES2017',
module: 'CommonJS',
moduleResolution: 'node',
esModuleInterop: true,
lib: ['ES2019', 'DOM'],
types: ['jest'],
},
}],
},
};

6744
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@ -2,11 +2,9 @@
"name": "ribbit", "name": "ribbit",
"version": "1.0.0", "version": "1.0.0",
"description": "Zero-dependency WYSIWYG markdown editor for the browser", "description": "Zero-dependency WYSIWYG markdown editor for the browser",
"main": "dist/ribbit.js", "main": "dist/ribbit/ribbit.js",
"types": "dist/ribbit.d.ts",
"files": [ "files": [
"dist/", "dist/ribbit/"
"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": "mkdir -p dist/ribbit && npm run build:check && npm run build:js && npm run build:min && npm run build:css",
@ -14,14 +12,17 @@
"build:js": "esbuild src/ts/ribbit-editor.ts --bundle --format=iife --global-name=ribbit --sourcemap --outfile=dist/ribbit/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/ts/ribbit-editor.ts --bundle --format=iife --global-name=ribbit --minify --outfile=dist/ribbit/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/", "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 && jest --verbose",
"test:coverage": "npm run build && jest --coverage"
}, },
"license": "MIT", "license": "MIT",
"author": "evilchili", "author": "evilchili",
"devDependencies": { "devDependencies": {
"@types/jsdom": "^28.0.1", "@types/jest": "^29.5.14",
"esbuild": "^0.28.0", "esbuild": "^0.28.0",
"jsdom": "^20.0.3", "happy-dom": "^14.12.3",
"jest": "^29.7.0",
"ts-jest": "^29.4.9",
"typescript": "^6.0.3" "typescript": "^6.0.3"
} }
} }

View File

@ -20,3 +20,38 @@
#ribbit.wysiwyg .md { #ribbit.wysiwyg .md {
opacity: 0.5; opacity: 0.5;
} }
.ribbit-editing::before,
.ribbit-editing::after {
opacity: 0.3;
font-weight: normal;
font-style: normal;
font-family: monospace;
font-size: 0.85em;
}
#ribbit.wysiwyg strong.ribbit-editing::before,
#ribbit.wysiwyg strong.ribbit-editing::after {
content: "**";
}
#ribbit.wysiwyg em.ribbit-editing::before,
#ribbit.wysiwyg em.ribbit-editing::after {
content: "*";
}
#ribbit.wysiwyg code.ribbit-editing::before,
#ribbit.wysiwyg code.ribbit-editing::after {
content: "\`";
}
#ribbit.wysiwyg h1.ribbit-editing::before { content: "# "; font-size: 0.5em; }
#ribbit.wysiwyg h2.ribbit-editing::before { content: "## "; font-size: 0.5em; }
#ribbit.wysiwyg h3.ribbit-editing::before { content: "### "; font-size: 0.5em; }
#ribbit.wysiwyg h4.ribbit-editing::before { content: "#### "; font-size: 0.5em; }
#ribbit.wysiwyg h5.ribbit-editing::before { content: "##### "; font-size: 0.5em; }
#ribbit.wysiwyg h6.ribbit-editing::before { content: "###### "; font-size: 0.5em; }
#ribbit.wysiwyg blockquote.ribbit-editing::before {
content: "> ";
}

View File

@ -68,7 +68,7 @@ export class HopDown {
this.blockTags = allTags.filter(tag => this.blockTags = allTags.filter(tag =>
defaultBlockNames.has(tag.name) || tag.name === 'macro' || defaultBlockNames.has(tag.name) || tag.name === 'macro' ||
(!defaultInlineNames.has(tag.name) && !(tag as any).pattern) (!defaultInlineNames.has(tag.name) && !tag.pattern)
); );
// Ensure macro block tag runs after fencedCode but before everything else // Ensure macro block tag runs after fencedCode but before everything else
@ -83,7 +83,7 @@ export class HopDown {
}); });
this.inlineTags = allTags.filter(tag => this.inlineTags = allTags.filter(tag =>
defaultInlineNames.has(tag.name) || (tag as any).pattern defaultInlineNames.has(tag.name) || tag.pattern
); );
this.tags = new Map(); this.tags = new Map();
@ -113,11 +113,11 @@ export class HopDown {
*/ */
private validateInlineTags(): void { private validateInlineTags(): void {
const withDelimiters = this.inlineTags const withDelimiters = this.inlineTags
.filter(tag => (tag as any).delimiter) .filter(tag => tag.delimiter)
.map(tag => ({ .map(tag => ({
name: tag.name, name: tag.name,
delimiter: (tag as any).delimiter as string, delimiter: tag.delimiter as string,
precedence: (tag as any).precedence as number ?? 50, precedence: tag.precedence as number ?? 50,
})); }));
for (let i = 0; i < withDelimiters.length; i++) { for (let i = 0; i < withDelimiters.length; i++) {
@ -159,6 +159,20 @@ export class HopDown {
return this.nodeToMd(container).replace(/\n{3,}/g, '\n\n').trim(); return this.nodeToMd(container).replace(/\n{3,}/g, '\n\n').trim();
} }
/**
* Return the block tags for external iteration (e.g. speculative rendering).
*/
getBlockTags(): Tag[] {
return this.blockTags;
}
/**
* Return the inline tags for external iteration (e.g. speculative rendering).
*/
getInlineTags(): Tag[] {
return this.inlineTags;
}
private processBlocks(md: string): string { private processBlocks(md: string): string {
const lines = md.replace(/\r\n/g, '\n').split('\n'); const lines = md.replace(/\r\n/g, '\n').split('\n');
const output: string[] = []; const output: string[] = [];
@ -186,6 +200,7 @@ export class HopDown {
output.push(result.html); output.push(result.html);
index = result.end; index = result.end;
} else { } else {
output.push(tag.toHTML(token, this.makeConverter())); output.push(tag.toHTML(token, this.makeConverter()));
index += token.consumed; index += token.consumed;
} }
@ -216,13 +231,11 @@ export class HopDown {
// Pass 1: extract links and non-recursive tags into placeholders before escaping // Pass 1: extract links and non-recursive tags into placeholders before escaping
for (const tag of sorted) { for (const tag of sorted) {
const recursive = (tag as any).recursive ?? true; const recursive = tag.recursive ?? true;
if (tag.name === 'link') { if (tag.name === 'link') {
text = text.replace(/\[([^\]]+)\]\(([^)]+)\)/g, (_, linkText: string, href: string) => { text = text.replace(/\[([^\]]+)\]\(([^)]+)\)/g, (_, linkText: string, href: string) => {
// Process link text: restore earlier placeholders, then run inline on any remaining markdown
let inner = linkText; let inner = linkText;
// Check if link text contains placeholders (already-processed content)
const hasPlaceholders = /\x00P\d+\x00/.test(inner); const hasPlaceholders = /\x00P\d+\x00/.test(inner);
if (hasPlaceholders) { if (hasPlaceholders) {
inner = inner.replace(/\x00P(\d+)\x00/g, (__, idx: string) => placeholders[parseInt(idx)]); inner = inner.replace(/\x00P(\d+)\x00/g, (__, idx: string) => placeholders[parseInt(idx)]);
@ -232,9 +245,10 @@ export class HopDown {
placeholders.push('<a href="' + escapeHtml(href) + '">' + inner + '</a>'); placeholders.push('<a href="' + escapeHtml(href) + '">' + inner + '</a>');
return '\x00P' + (placeholders.length - 1) + '\x00'; return '\x00P' + (placeholders.length - 1) + '\x00';
}); });
} else if (!recursive && (tag as any).pattern) { } else if (!recursive && tag.pattern) {
const globalPattern = (tag as any).pattern as RegExp; const globalPattern = tag.pattern as RegExp;
globalPattern.lastIndex = 0; globalPattern.lastIndex = 0;
text = text.replace(globalPattern, (_, content: string) => { text = text.replace(globalPattern, (_, content: string) => {
placeholders.push(tag.toHTML( placeholders.push(tag.toHTML(
{ content, raw: '', consumed: 0 }, { content, raw: '', consumed: 0 },
@ -247,21 +261,20 @@ export class HopDown {
text = escapeHtml(text); text = escapeHtml(text);
// Pass 2: apply recursive tags in precedence order (longest delimiter first). // Pass 2: apply recursive tags in precedence order.
// Content matched here is already HTML-escaped and has had earlier // Content is already HTML-escaped from pass 1, so we wrap directly
// passes applied, so we wrap directly without re-processing. // without re-processing through convert.inline().
for (const tag of sorted) { for (const tag of sorted) {
const recursive = (tag as any).recursive ?? true; const recursive = tag.recursive ?? true;
if (tag.name === 'link' || !recursive) { if (tag.name === 'link' || !recursive) {
continue; continue;
} }
const globalPattern = (tag as any).pattern as RegExp | undefined; const globalPattern = tag.pattern as RegExp | undefined;
if (globalPattern) { if (globalPattern) {
globalPattern.lastIndex = 0; globalPattern.lastIndex = 0;
text = text.replace(globalPattern, (_, content: string) => { text = text.replace(globalPattern, (_, content: string) => {
// Restore any placeholders in the captured content
const restored = content.replace(/\x00P(\d+)\x00/g, (__, idx: string) => placeholders[parseInt(idx)]); const restored = content.replace(/\x00P(\d+)\x00/g, (__, idx: string) => placeholders[parseInt(idx)]);
const htmlTag = (tag as any).name === 'boldItalic' const htmlTag = tag.name === 'boldItalic'
? null ? null
: ((tag.selector as string) || '').split(',')[0].toLowerCase(); : ((tag.selector as string) || '').split(',')[0].toLowerCase();
if (tag.name === 'boldItalic') { if (tag.name === 'boldItalic') {
@ -272,7 +285,6 @@ export class HopDown {
} }
} }
// Restore placeholders
text = text.replace(/\x00P(\d+)\x00/g, (_, index: string) => placeholders[parseInt(index)]); text = text.replace(/\x00P(\d+)\x00/g, (_, index: string) => placeholders[parseInt(index)]);
return text; return text;
} }

View File

@ -6,6 +6,7 @@ import { HopDown } from './hopdown';
import { defaultTags, defaultBlockTags, defaultInlineTags, inlineTag } from './tags'; import { defaultTags, defaultBlockTags, defaultInlineTags, inlineTag } from './tags';
import { defaultTheme } from './default-theme'; import { defaultTheme } from './default-theme';
import { Ribbit, RibbitPlugin, RibbitSettings, camelCase, decodeHtmlEntities, encodeHtmlEntities } from './ribbit'; import { Ribbit, RibbitPlugin, RibbitSettings, camelCase, decodeHtmlEntities, encodeHtmlEntities } from './ribbit';
import { type MacroDef } from './macros';
/** /**
* WYSIWYG markdown editor with VIEW, EDIT, and WYSIWYG modes. * WYSIWYG markdown editor with VIEW, EDIT, and WYSIWYG modes.
@ -38,18 +39,156 @@ export class RibbitEditor extends Ribbit {
} }
#bindEvents(): void { #bindEvents(): void {
let debounceTimer: number | undefined;
let lastThrottle = 0;
this.element.addEventListener('input', () => { this.element.addEventListener('input', () => {
if (this.state !== this.states.VIEW) { if (this.state === this.states.VIEW) {
this.notifyChange(); return;
} }
this.invalidateCache();
const now = Date.now();
if (now - lastThrottle >= 150) {
lastThrottle = now;
this.refreshPreview();
}
clearTimeout(debounceTimer);
debounceTimer = window.setTimeout(() => {
this.refreshPreview();
this.notifyChange();
}, 150);
}); });
} }
/**
* Re-render the WYSIWYG preview from the current content.
* Applies speculative rendering for unclosed inline delimiters
* at the cursor position, and uses toHtmlPreview for visible syntax.
*/
refreshPreview(): void {
if (this.state !== this.states.WYSIWYG) {
return;
}
const cursorInfo = this.getCursorInfo();
const text = this.element.textContent || '';
const lines = text.split('\n');
// Speculatively close unclosed delimiters on the cursor line
if (cursorInfo) {
const inlineTags = this.converter.getInlineTags();
const sorted = [...inlineTags].sort((a, b) =>
((a as any).precedence ?? 50) - ((b as any).precedence ?? 50)
);
for (const tag of sorted) {
if (tag.openPattern && tag.delimiter) {
const before = lines[cursorInfo.lineIndex].slice(0, cursorInfo.offset);
const escaped = tag.delimiter.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
const re = new RegExp(escaped, 'g');
const count = (before.match(re) || []).length;
if (count % 2 === 1) {
lines[cursorInfo.lineIndex] = lines[cursorInfo.lineIndex] + tag.delimiter;
break;
}
}
}
}
const html = this.converter.toHTML(lines.join('\n'));
this.updatePreview(html, cursorInfo);
}
/**
* Track which formatting element contains the cursor and toggle
* the .ribbit-editing class so CSS ::before/::after show delimiters.
*/
private updateEditingContext(): void {
const prev = this.element.querySelector('.ribbit-editing');
if (prev) {
prev.classList.remove('ribbit-editing');
}
const sel = window.getSelection();
if (!sel || sel.rangeCount === 0) {
return;
}
let node: Node | null = sel.anchorNode;
while (node && node !== this.element) {
if (node.nodeType === 1) {
const el = node as HTMLElement;
if (el.matches('strong, b, em, i, code, h1, h2, h3, h4, h5, h6, blockquote')) {
el.classList.add('ribbit-editing');
return;
}
}
node = node.parentNode;
}
}
/**
* Get the cursor's line index and offset within that line.
*/
private getCursorInfo(): { lineIndex: number; offset: number; absoluteOffset: number } | null {
const sel = window.getSelection();
if (!sel || sel.rangeCount === 0) {
return null;
}
const range = sel.getRangeAt(0);
const preRange = document.createRange();
preRange.selectNodeContents(this.element);
preRange.setEnd(range.startContainer, range.startOffset);
const absoluteOffset = preRange.toString().length;
const text = this.element.textContent || '';
const beforeCursor = text.slice(0, absoluteOffset);
const lineIndex = beforeCursor.split('\n').length - 1;
const lineStart = beforeCursor.lastIndexOf('\n') + 1;
const offset = absoluteOffset - lineStart;
return { lineIndex, offset, absoluteOffset };
}
/**
* Replace the editor's HTML and restore the cursor to its
* previous text offset position.
*/
private updatePreview(html: string, cursorInfo: { absoluteOffset: number } | null): void {
this.element.innerHTML = html;
if (!cursorInfo) {
return;
}
const walker = document.createTreeWalker(this.element, NodeFilter.SHOW_TEXT);
let remaining = cursorInfo.absoluteOffset;
let node: Text | null;
while ((node = walker.nextNode() as Text | null)) {
if (remaining <= node.length) {
const sel = window.getSelection()!;
const range = document.createRange();
range.setStart(node, remaining);
range.collapse(true);
sel.removeAllRanges();
sel.addRange(range);
break;
}
remaining -= node.length;
}
this.updateEditingContext();
}
htmlToMarkdown(html?: string): string { htmlToMarkdown(html?: string): string {
return this.converter.toMarkdown(html || this.element.innerHTML); return this.converter.toMarkdown(html || this.element.innerHTML);
} }
getMarkdown(): string { getMarkdown(): string {
if (this.cachedMarkdown !== null) {
return this.cachedMarkdown;
}
if (this.getState() === this.states.EDIT) { if (this.getState() === this.states.EDIT) {
let html = this.element.innerHTML; let html = this.element.innerHTML;
html = html.replace(/<(?:div|br)>/ig, ''); html = html.replace(/<(?:div|br)>/ig, '');
@ -57,8 +196,7 @@ export class RibbitEditor extends Ribbit {
this.cachedMarkdown = decodeHtmlEntities(html); this.cachedMarkdown = decodeHtmlEntities(html);
} else if (this.getState() === this.states.WYSIWYG) { } else if (this.getState() === this.states.WYSIWYG) {
this.cachedMarkdown = this.htmlToMarkdown(this.element.innerHTML); this.cachedMarkdown = this.htmlToMarkdown(this.element.innerHTML);
} } else {
if (!this.cachedMarkdown) {
this.cachedMarkdown = this.element.textContent || ''; this.cachedMarkdown = this.element.textContent || '';
} }
return this.cachedMarkdown; return this.cachedMarkdown;
@ -66,7 +204,6 @@ export class RibbitEditor extends Ribbit {
wysiwyg(): void { wysiwyg(): void {
if (this.getState() === this.states.WYSIWYG) return; if (this.getState() === this.states.WYSIWYG) return;
this.changed = false;
this.element.contentEditable = 'true'; this.element.contentEditable = 'true';
this.element.innerHTML = this.getHTML(); this.element.innerHTML = this.getHTML();
Array.from(this.element.querySelectorAll('.macro')).forEach(el => { Array.from(this.element.querySelectorAll('.macro')).forEach(el => {
@ -84,7 +221,6 @@ export class RibbitEditor extends Ribbit {
return; return;
} }
if (this.state === this.states.EDIT) return; if (this.state === this.states.EDIT) return;
this.changed = false;
this.element.contentEditable = 'true'; this.element.contentEditable = 'true';
this.element.innerHTML = encodeHtmlEntities(this.getMarkdown()); this.element.innerHTML = encodeHtmlEntities(this.getMarkdown());
this.setState(this.states.EDIT); this.setState(this.states.EDIT);
@ -102,8 +238,6 @@ export class RibbitEditor extends Ribbit {
} }
} }
import { type MacroDef } from './macros';
// Public API — accessed as ribbit.Editor, ribbit.HopDown, etc. // Public API — accessed as ribbit.Editor, ribbit.HopDown, etc.
export { RibbitEditor as Editor }; export { RibbitEditor as Editor };
export { Ribbit as Viewer }; export { Ribbit as Viewer };

View File

@ -6,7 +6,7 @@ import { HopDown } from './hopdown';
import { defaultTheme } from './default-theme'; import { defaultTheme } from './default-theme';
import { ThemeManager } from './theme-manager'; import { ThemeManager } from './theme-manager';
import { RibbitEmitter, type RibbitEventMap } from './events'; import { RibbitEmitter, type RibbitEventMap } from './events';
import { buildMacroTags, type MacroDef } from './macros'; import { type MacroDef } from './macros';
import type { RibbitTheme } from './types'; import type { RibbitTheme } from './types';
export interface RibbitSettings { export interface RibbitSettings {
@ -174,14 +174,11 @@ export class Ribbit {
setState(newState: string): void { setState(newState: string): void {
const previous = this.state; const previous = this.state;
this.state = newState; if (previous) {
Object.values(this.states).forEach(state => { this.element.classList.remove(previous);
if (state === newState) {
this.element.classList.add(state);
} else {
this.element.classList.remove(state);
} }
}); this.state = newState;
this.element.classList.add(newState);
this.emitter.emit('modeChange', { this.emitter.emit('modeChange', {
current: newState, current: newState,
previous, previous,
@ -193,14 +190,14 @@ export class Ribbit {
} }
getHTML(): string { getHTML(): string {
if (this.changed || !this.cachedHTML) { if (this.cachedHTML === null) {
this.cachedHTML = this.markdownToHTML(this.getMarkdown()); this.cachedHTML = this.markdownToHTML(this.getMarkdown());
} }
return this.cachedHTML; return this.cachedHTML;
} }
getMarkdown(): string { getMarkdown(): string {
if (!this.cachedMarkdown) { if (this.cachedMarkdown === null) {
this.cachedMarkdown = this.element.textContent || ''; this.cachedMarkdown = this.element.textContent || '';
} }
return this.cachedMarkdown; return this.cachedMarkdown;
@ -226,12 +223,21 @@ export class Ribbit {
this.element.contentEditable = 'false'; this.element.contentEditable = 'false';
} }
/**
* Invalidate cached markdown and HTML. Called when content changes.
* The next call to getMarkdown() or getHTML() will recompute.
*/
invalidateCache(): void {
this.changed = true;
this.cachedMarkdown = null;
this.cachedHTML = null;
}
/** /**
* Notify that content has changed. Called internally by the editor * Notify that content has changed. Called internally by the editor
* on input events. Fires the 'change' event with current content. * on input events. Fires the 'change' event with current content.
*/ */
notifyChange(): void { notifyChange(): void {
this.changed = true;
this.emitter.emit('change', { this.emitter.emit('change', {
markdown: this.getMarkdown(), markdown: this.getMarkdown(),
html: this.getHTML(), html: this.getHTML(),

View File

@ -19,10 +19,11 @@ import type { Tag, MatchContext, SourceToken, Converter, ListItem, ListResult, I
* inlineTag({ name: 'code', delimiter: '`', htmlTag: 'code', recursive: false, precedence: 10 }) * inlineTag({ name: 'code', delimiter: '`', htmlTag: 'code', recursive: false, precedence: 10 })
* inlineTag({ name: 'strikethrough', delimiter: '~~', htmlTag: 'del', aliases: 'S,STRIKE' }) * inlineTag({ name: 'strikethrough', delimiter: '~~', htmlTag: 'del', aliases: 'S,STRIKE' })
*/ */
export function inlineTag(def: InlineTagDef): Tag & { precedence: number; recursive: boolean; pattern: RegExp; delimiter: string } { export function inlineTag(def: InlineTagDef): Tag {
const escaped = def.delimiter.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); const escaped = def.delimiter.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
const matchPattern = new RegExp('^' + escaped + '(.+?)' + escaped); const matchPattern = new RegExp('^' + escaped + '(.+?)' + escaped);
const globalPattern = new RegExp(escaped + '(.+?)' + escaped, 'g'); const globalPattern = new RegExp(escaped + '(.+?)' + escaped, 'g');
const openPattern = new RegExp(escaped + '(.+)$');
const upperTag = def.htmlTag.toUpperCase(); const upperTag = def.htmlTag.toUpperCase();
const selector = [upperTag, ...(def.aliases || '').split(',').filter(Boolean)].join(','); const selector = [upperTag, ...(def.aliases || '').split(',').filter(Boolean)].join(',');
const recursive = def.recursive !== false; const recursive = def.recursive !== false;
@ -32,6 +33,7 @@ export function inlineTag(def: InlineTagDef): Tag & { precedence: number; recurs
precedence: def.precedence ?? 50, precedence: def.precedence ?? 50,
recursive, recursive,
pattern: globalPattern, pattern: globalPattern,
openPattern,
delimiter: def.delimiter, delimiter: def.delimiter,
match: (context) => { match: (context) => {
const matched = context.text.slice(context.offset).match(matchPattern); const matched = context.text.slice(context.offset).match(matchPattern);
@ -69,7 +71,7 @@ export function escapeHtml(source: string): string {
/** /**
* Generate a camelCase ID from heading text, for use as an anchor. * Generate a camelCase ID from heading text, for use as an anchor.
*/ */
export function camelId(text: string): string { function camelId(text: string): string {
return text.trim().split(/\s+/).map(word => return text.trim().split(/\s+/).map(word =>
word.charAt(0).toUpperCase() + word.slice(1).toLowerCase() word.charAt(0).toUpperCase() + word.slice(1).toLowerCase()
).join(''); ).join('');
@ -115,7 +117,7 @@ export function parseListBlock(lines: string[], start: number, indent: number, i
* Convert an HTML list element back to markdown, recursing into * Convert an HTML list element back to markdown, recursing into
* nested sublists with 2-space indentation per depth level. * nested sublists with 2-space indentation per depth level.
*/ */
export function listToMd(node: HTMLElement, depth: number, convert: Converter): string { function listToMd(node: HTMLElement, depth: number, convert: Converter): string {
const isOl = node.nodeName === 'OL'; const isOl = node.nodeName === 'OL';
const indent = ' '.repeat(depth); const indent = ' '.repeat(depth);
const lines: string[] = []; const lines: string[] = [];
@ -141,7 +143,7 @@ export function listToMd(node: HTMLElement, depth: number, convert: Converter):
* Test whether a line begins a block-level element (used to detect * Test whether a line begins a block-level element (used to detect
* paragraph boundaries). * paragraph boundaries).
*/ */
export function isBlockStart(lines: string[], index: number): boolean { function isBlockStart(lines: string[], index: number): boolean {
const line = lines[index]; const line = lines[index];
if (/^(`{3,})/.test(line)) return true; if (/^(`{3,})/.test(line)) return true;
if (/^(\*{3,}|-{3,}|_{3,})\s*$/.test(line)) return true; if (/^(\*{3,}|-{3,}|_{3,})\s*$/.test(line)) return true;

View File

@ -3,7 +3,6 @@
*/ */
import type { RibbitTheme } from './types'; import type { RibbitTheme } from './types';
import { HopDown } from './hopdown';
export class ThemeManager { export class ThemeManager {
private registered: Map<string, RibbitTheme>; private registered: Map<string, RibbitTheme>;

View File

@ -29,6 +29,22 @@ export interface Tag {
toHTML: (token: SourceToken, convert: Converter) => string; toHTML: (token: SourceToken, convert: Converter) => string;
selector: string | ((element: HTMLElement) => boolean); selector: string | ((element: HTMLElement) => boolean);
toMarkdown: (element: HTMLElement, convert: Converter) => string; toMarkdown: (element: HTMLElement, convert: Converter) => string;
/**
* The regex pattern that matches an unclosed opening delimiter.
* Used by the live preview to speculatively close incomplete syntax.
* Auto-generated by inlineTag().
*/
openPattern?: RegExp;
/**
* The markdown delimiter string. Auto-generated by inlineTag().
*/
delimiter?: string;
/** Lower runs first in inline processing. Default 50. Auto-generated by inlineTag(). */
precedence?: number;
/** Whether inner content is processed for nested markdown. Auto-generated by inlineTag(). */
recursive?: boolean;
/** Global regex for matching this tag's delimiter pair. Auto-generated by inlineTag(). */
pattern?: RegExp;
} }
export interface ListItem { export interface ListItem {

68
test/custom-tags.test.ts Normal file
View File

@ -0,0 +1,68 @@
import { ribbit, resetDOM } from './setup';
const r = ribbit();
describe('Custom inline tags', () => {
const strikethrough = r.inlineTag({
name: 'strikethrough', delimiter: '~~', htmlTag: 'del', aliases: 'S,STRIKE', precedence: 45,
});
const h = new r.HopDown({ tags: { ...r.defaultTags, 'DEL,S,STRIKE': strikethrough } });
it('md→html', () => expect(h.toHTML('~~struck~~')).toBe('<p><del>struck</del></p>'));
it('html→md', () => expect(h.toMarkdown('<p><del>struck</del></p>')).toContain('~~struck~~'));
it('round-trip', () => expect(h.toMarkdown(h.toHTML('~~struck~~'))).toBe('~~struck~~'));
it('mixed with bold', () => expect(h.toHTML('**bold** and ~~struck~~')).toContain('<del>struck</del>'));
});
describe('Custom block tags', () => {
const spoiler = {
name: 'spoiler',
match: (context: any) => {
if (!/^\|{3,}/.test(context.lines[context.index])) return null;
const content: string[] = [];
let i = context.index + 1;
while (i < context.lines.length && !/^\|{3,}/.test(context.lines[i])) content.push(context.lines[i++]);
return { content: content.join('\n'), raw: '', consumed: i + 1 - context.index };
},
toHTML: (token: any, convert: any) => '<details>' + convert.block(token.content) + '</details>',
selector: 'DETAILS',
toMarkdown: (el: any, convert: any) => '\n\n|||\n' + convert.children(el).trim() + '\n|||\n\n',
};
const h = new r.HopDown({ tags: { 'DETAILS': spoiler, ...r.defaultTags } });
it('renders', () => expect(h.toHTML('|||\nhidden\n|||')).toContain('<details>'));
it('nested md', () => expect(h.toHTML('|||\n**bold**\n|||')).toContain('<strong>bold</strong>'));
});
describe('HopDown({ exclude })', () => {
it('excludes table', () => {
const h = new r.HopDown({ exclude: ['table'] });
expect(h.toHTML('| a |\n|---|\n| 1 |')).not.toContain('<table>');
});
it('excludes code', () => {
const h = new r.HopDown({ exclude: ['code'] });
expect(h.toHTML('`code`')).toBe('<p>`code`</p>');
});
it('other tags still work', () => {
const h = new r.HopDown({ exclude: ['table'] });
expect(h.toHTML('**bold**')).toContain('<strong>bold</strong>');
});
});
describe('Collision detection', () => {
it('delimiter collision throws', () => {
const bad = r.inlineTag({ name: 'bad', delimiter: '*', htmlTag: 'span', precedence: 10 });
expect(() => new r.HopDown({ tags: { ...r.defaultTags, 'SPAN': bad } })).toThrow();
});
it('selector collision throws', () => {
const dup = { name: 'dup', match: () => null, toHTML: () => '', selector: 'STRONG', toMarkdown: () => '' };
expect(() => new r.HopDown({ tags: { ...r.defaultTags, 'STRONG': dup } })).toThrow();
});
it('valid precedence does not throw', () => {
const short = r.inlineTag({ name: 'short', delimiter: '~', htmlTag: 's', precedence: 50 });
const long = r.inlineTag({ name: 'long', delimiter: '~~', htmlTag: 'del', precedence: 40 });
expect(() => new r.HopDown({ tags: { ...r.defaultTags, 'S': short, 'DEL': long } })).not.toThrow();
});
});

254
test/editor.test.ts Normal file
View File

@ -0,0 +1,254 @@
import { ribbit, resetDOM } from './setup';
const r = ribbit();
describe('RibbitEmitter', () => {
beforeEach(() => resetDOM());
it('fires save event', () => {
const editor = new r.Editor({});
editor.run();
let received: any = null;
editor.on('save', (p: any) => { received = p; });
editor.save();
expect(received).toHaveProperty('markdown');
expect(received).toHaveProperty('html');
});
it('off removes handler', () => {
const editor = new r.Editor({});
editor.run();
let count = 0;
const handler = () => { count++; };
editor.on('save', handler);
editor.save();
editor.off('save', handler);
editor.save();
expect(count).toBe(1);
});
it('multiple listeners', () => {
const editor = new r.Editor({});
editor.run();
let count = 0;
editor.on('save', () => { count++; });
editor.on('save', () => { count++; });
editor.save();
expect(count).toBe(2);
});
});
describe('Ribbit viewer', () => {
beforeEach(() => resetDOM('**bold**'));
it('starts with null state', () => {
const viewer = new r.Viewer({});
expect(viewer.getState()).toBeNull();
});
it('run sets view state', () => {
const viewer = new r.Viewer({});
viewer.run();
expect(viewer.getState()).toBe('view');
});
it('renders html', () => {
const viewer = new r.Viewer({});
viewer.run();
expect(viewer.element.innerHTML).toContain('<strong>bold</strong>');
});
it('getMarkdown returns source', () => {
const viewer = new r.Viewer({});
expect(viewer.getMarkdown()).toBe('**bold**');
});
});
describe('Ribbit events', () => {
it('ready fires on run', () => {
resetDOM('hello');
let payload: any = null;
const viewer = new r.Viewer({ on: { ready: (p: any) => { payload = p; } } });
viewer.run();
expect(payload).toHaveProperty('markdown');
expect(payload).toHaveProperty('mode', 'view');
expect(payload.theme.name).toBe('ribbit-default');
});
});
describe('RibbitEditor modes', () => {
beforeEach(() => resetDOM('**bold**'));
it('starts in view', () => {
const editor = new r.Editor({});
editor.run();
expect(editor.getState()).toBe('view');
});
it('switches to wysiwyg', () => {
const editor = new r.Editor({});
editor.run();
editor.wysiwyg();
expect(editor.getState()).toBe('wysiwyg');
expect(editor.element.contentEditable).toBe('true');
});
it('switches to edit', () => {
const editor = new r.Editor({});
editor.run();
editor.wysiwyg();
editor.edit();
expect(editor.getState()).toBe('edit');
});
it('switches back to view', () => {
const editor = new r.Editor({});
editor.run();
editor.wysiwyg();
editor.view();
expect(editor.getState()).toBe('view');
expect(editor.element.contentEditable).toBe('false');
});
it('fires modeChange events', () => {
const modes: string[] = [];
const editor = new r.Editor({
on: { modeChange: ({ current }: any) => { modes.push(current); } },
});
editor.run();
editor.wysiwyg();
editor.edit();
editor.view();
expect(modes).toEqual(['view', 'wysiwyg', 'edit', 'view']);
});
it('sourceMode disabled blocks edit', () => {
resetDOM();
const editor = new r.Editor({
currentTheme: 'no-source',
themes: [{ name: 'no-source', features: { sourceMode: false } }],
});
editor.run();
editor.wysiwyg();
editor.edit();
expect(editor.getState()).toBe('wysiwyg');
});
});
describe('ThemeManager', () => {
beforeEach(() => resetDOM());
it('lists registered themes', () => {
const editor = new r.Editor({ themes: [{ name: 'dark' }] });
editor.run();
expect(editor.themes.list()).toContain('ribbit-default');
expect(editor.themes.list()).toContain('dark');
});
it('set switches theme', () => {
const editor = new r.Editor({ themes: [{ name: 'dark' }] });
editor.run();
editor.themes.set('dark');
expect(editor.themes.current().name).toBe('dark');
});
it('disable hides from list', () => {
const editor = new r.Editor({ themes: [{ name: 'dark' }] });
editor.run();
editor.themes.disable('dark');
expect(editor.themes.list()).not.toContain('dark');
});
it('enable restores to list', () => {
const editor = new r.Editor({ themes: [{ name: 'dark' }] });
editor.run();
editor.themes.disable('dark');
editor.themes.enable('dark');
expect(editor.themes.list()).toContain('dark');
});
it('set disabled throws', () => {
const editor = new r.Editor({ themes: [{ name: 'dark' }] });
editor.run();
editor.themes.disable('dark');
expect(() => editor.themes.set('dark')).toThrow();
});
it('set unknown throws', () => {
const editor = new r.Editor({});
editor.run();
expect(() => editor.themes.set('nonexistent')).toThrow();
});
it('remove active throws', () => {
const editor = new r.Editor({});
editor.run();
expect(() => editor.themes.remove(editor.themes.current().name)).toThrow();
});
it('fires themeChange', () => {
let payload: any = null;
const editor = new r.Editor({
themes: [{ name: 'dark' }],
on: { themeChange: (p: any) => { payload = p; } },
});
editor.run();
editor.themes.set('dark');
expect(payload.current.name).toBe('dark');
expect(payload.previous.name).toBe('ribbit-default');
});
});
describe('RibbitPlugin', () => {
it('has defaults', () => {
resetDOM();
const viewer = new r.Viewer({});
const plugin = new r.Plugin({ name: 'test', wiki: viewer });
expect(plugin.name).toBe('test');
expect(plugin.precedence).toBe(50);
expect(plugin.toMarkdown('<b>x</b>')).toBe('<b>x</b>');
expect(plugin.toHTML('**x**')).toBe('**x**');
});
});
describe('defaultTheme', () => {
it('has correct shape', () => {
expect(r.defaultTheme.name).toBe('ribbit-default');
expect(r.defaultTheme.tags).toBeDefined();
expect(r.defaultTheme.features.sourceMode).toBe(true);
});
});
describe('Utility functions', () => {
it('encodeHtmlEntities', () => {
expect(r.encodeHtmlEntities('<')).toBe('&#60;');
expect(r.encodeHtmlEntities('>')).toBe('&#62;');
expect(r.encodeHtmlEntities('&')).toBe('&#38;');
});
it('decodeHtmlEntities', () => {
expect(r.decodeHtmlEntities('&#60;')).toBe('<');
expect(r.decodeHtmlEntities('&amp;')).toBe('&');
});
it('camelCase', () => {
expect(r.camelCase('hello').join('')).toBe('Hello');
expect(r.camelCase('hello world').join(' ')).toBe('Hello World');
});
});
describe('Editor htmlToMarkdown', () => {
beforeEach(() => resetDOM());
it('converts strong', () => {
const editor = new r.Editor({});
editor.run();
expect(editor.htmlToMarkdown('<strong>bold</strong>')).toBe('**bold**');
});
it('converts em', () => {
const editor = new r.Editor({});
editor.run();
expect(editor.htmlToMarkdown('<em>italic</em>')).toBe('*italic*');
});
});

151
test/hopdown.test.ts Normal file
View File

@ -0,0 +1,151 @@
import { ribbit } from './setup';
const r = ribbit();
const hopdown = new r.HopDown();
const H = (md: string) => hopdown.toHTML(md);
const M = (html: string) => hopdown.toMarkdown(html);
const rt = (md: string) => M(H(md));
describe('Markdown → HTML', () => {
describe('inline formatting', () => {
it('bold', () => expect(H('**bold**')).toBe('<p><strong>bold</strong></p>'));
it('italic', () => expect(H('*italic*')).toBe('<p><em>italic</em></p>'));
it('inline code', () => expect(H('`code`')).toBe('<p><code>code</code></p>'));
it('link', () => expect(H('[t](http://x)')).toBe('<p><a href="http://x">t</a></p>'));
it('bold+italic', () => expect(H('***bi***')).toBe('<p><em><strong>bi</strong></em></p>'));
it('mixed', () => expect(H('a **b** *c* `d`')).toBe('<p>a <strong>b</strong> <em>c</em> <code>d</code></p>'));
it('code before bold', () => expect(H('`a` **b**')).toBe('<p><code>a</code> <strong>b</strong></p>'));
});
describe('headings', () => {
it.each([1,2,3,4,5,6])('h%i', (n) => {
const prefix = '#'.repeat(n);
expect(H(`${prefix} Sub`)).toContain(`<h${n}`);
});
it('heading id', () => expect(H('## Hello World')).toContain("id='HelloWorld'"));
it('heading inline md', () => expect(H('## **Bold** text')).toContain('<strong>Bold</strong>'));
});
describe('horizontal rules', () => {
it('***', () => expect(H('***')).toBe('<hr>'));
it('---', () => expect(H('---')).toBe('<hr>'));
it('___', () => expect(H('___')).toBe('<hr>'));
});
describe('lists', () => {
it('ul *', () => expect(H('* a\n* b')).toBe('<ul><li>a</li><li>b</li></ul>'));
it('ul -', () => expect(H('- a\n- b')).toBe('<ul><li>a</li><li>b</li></ul>'));
it('ol', () => expect(H('1. a\n2. b')).toBe('<ol><li>a</li><li>b</li></ol>'));
it('ul inline', () => expect(H('* **bold** item')).toContain('<strong>bold</strong>'));
});
describe('blockquotes', () => {
it('basic', () => expect(H('> text')).toContain('<blockquote>'));
it('content', () => expect(H('> hello')).toContain('hello'));
it('multi-line', () => expect(H('> a\n> b')).toContain('a'));
});
describe('fenced code', () => {
it('basic', () => expect(H('```\nx = 1\n```')).toContain('<pre><code>'));
it('content', () => expect(H('```\nx = 1\n```')).toContain('x = 1'));
it('language', () => expect(H('```js\nvar x;\n```')).toContain('language-js'));
it('escapes html', () => expect(H('```\n<div>\n```')).toContain('&lt;div&gt;'));
it('no lang when none', () => expect(H('```\nplain\n```')).not.toContain('language-'));
});
describe('tables', () => {
const tbl = '| a | b |\n|---|---|\n| 1 | 2 |';
it('table tag', () => expect(H(tbl)).toContain('<table>'));
it('thead', () => expect(H(tbl)).toContain('<thead>'));
it('th cells', () => expect(H(tbl)).toContain('<th>a</th>'));
it('td cells', () => expect(H(tbl)).toContain('<td>1</td>'));
it('center align', () => expect(H('| C |\n|:--:|\n| x |')).toContain('text-align:center'));
it('right align', () => expect(H('| R |\n|--:|\n| x |')).toContain('text-align:right'));
it('inline md', () => expect(H('| **b** |\n|---|\n| x |')).toContain('<strong>b</strong>'));
});
describe('paragraphs', () => {
it('single', () => expect(H('hello')).toBe('<p>hello</p>'));
it('two', () => expect(H('a\n\nb')).toBe('<p>a</p>\n<p>b</p>'));
it('soft break', () => expect(H('a\nb')).toBe('<p>a\nb</p>'));
});
describe('edge cases', () => {
it('empty', () => expect(H('')).toBe(''));
it('whitespace', () => expect(H(' ')).toBe(''));
it('html entities', () => expect(H('a & b < c')).toContain('&amp;'));
it('html in code', () => expect(H('`<div>`')).toContain('&lt;div&gt;'));
it('para then heading', () => expect(H('text\n\n## H')).toContain('<h2'));
it('list then para', () => expect(H('- a\n\ntext')).toContain('<p>text</p>'));
});
});
describe('HTML → Markdown', () => {
it('strong→**', () => expect(M('<p><strong>b</strong></p>')).toBe('**b**'));
it('em→*', () => expect(M('<p><em>i</em></p>')).toBe('*i*'));
it('code→`', () => expect(M('<p><code>c</code></p>')).toBe('`c`'));
it('a→[]', () => expect(M('<a href="http://x">t</a>')).toBe('[t](http://x)'));
it('h1→#', () => expect(M('<h1>T</h1>')).toBe('# T'));
it('hr→---', () => expect(M('<hr>')).toBe('---'));
it('ul→-', () => expect(M('<ul><li>a</li><li>b</li></ul>')).toBe('- a\n- b'));
it('ol→1.', () => expect(M('<ol><li>a</li><li>b</li></ol>')).toBe('1. a\n2. b'));
it('bq→>', () => expect(M('<blockquote><p>q</p></blockquote>')).toContain('> '));
it('pre→```', () => expect(M('<pre><code>x</code></pre>')).toContain('```'));
it('pre lang', () => expect(M('<pre><code class="language-py">x</code></pre>')).toContain('```py'));
it('table→pipes', () => {
const html = '<table><thead><tr><th>a</th><th>b</th></tr></thead><tbody><tr><td>1</td><td>2</td></tr></tbody></table>';
expect(M(html)).toContain('| a | b |');
});
});
describe('Round-trips', () => {
it.each([
['paragraph', 'Hello world'],
['bold', '**bold**'],
['italic', '*italic*'],
['code', '`code`'],
['link', '[t](http://x)'],
['h1', '# Title'],
['h2', '## Sub'],
['ul', '- a\n- b'],
['ol', '1. a\n2. b'],
])('%s', (_, md) => expect(rt(md)).toBe(md));
it('hr', () => expect(rt('---')).toBe('---'));
it('blockquote', () => expect(rt('> quoted')).toContain('> '));
it('code block', () => expect(rt('```\nx = 1\n```')).toContain('```'));
it('table', () => expect(rt('| a | b |\n|---|---|\n| 1 | 2 |')).toContain('| a | b |'));
});
describe('Nested inline', () => {
it('bold wraps italic', () => expect(H('**a *b* c**')).toBe('<p><strong>a <em>b</em> c</strong></p>'));
it('italic wraps bold', () => expect(H('*a **b** c*')).toBe('<p><em>a <strong>b</strong> c</em></p>'));
it('bold wraps code', () => expect(H('**a `b` c**')).toBe('<p><strong>a <code>b</code> c</strong></p>'));
it('bold wraps link', () => expect(H('**[t](u)**')).toBe('<p><strong><a href="u">t</a></strong></p>'));
it('link with bold', () => expect(H('[**t**](u)')).toBe('<p><a href="u"><strong>t</strong></a></p>'));
it('link with code', () => expect(H('[`t`](u)')).toBe('<p><a href="u"><code>t</code></a></p>'));
});
describe('Nested blocks', () => {
it('bq > heading', () => expect(H('> # Title')).toContain('<h1'));
it('bq > list', () => expect(H('> - a\n> - b')).toContain('<ul>'));
it('bq > bq', () => expect(H('> > nested')).toContain('<blockquote>'));
it('li > bold', () => expect(H('- **bold**')).toContain('<strong>bold</strong>'));
it('heading > code', () => expect(H('## `code`')).toContain('<code>code</code>'));
it('table > bold', () => expect(H('| **b** |\n|---|\n| x |')).toContain('<strong>b</strong>'));
});
describe('Nested lists', () => {
it('ul > ul', () => expect(H('- a\n - b\n - c\n- d')).toBe('<ul><li>a<ul><li>b</li><li>c</li></ul></li><li>d</li></ul>'));
it('ol > ol', () => expect(H('1. a\n 1. b\n 1. c\n2. d')).toBe('<ol><li>a<ol><li>b</li><li>c</li></ol></li><li>d</li></ol>'));
it('ul > ol', () => expect(H('- a\n 1. b\n 2. c\n- d')).toBe('<ul><li>a<ol><li>b</li><li>c</li></ol></li><li>d</li></ul>'));
it('3-level', () => expect(H('- a\n - b\n - c\n- d')).toBe('<ul><li>a<ul><li>b<ul><li>c</li></ul></li></ul></li><li>d</li></ul>'));
it('ul>ul rt', () => expect(rt('- a\n - b\n - c\n- d')).toBe('- a\n - b\n - c\n- d'));
});
describe('Tables with nested markdown', () => {
it('td bold', () => expect(H('| h |\n|---|\n| **b** |')).toContain('<td><strong>b</strong></td>'));
it('td link>bold', () => expect(H('| h |\n|---|\n| [**t**](u) |')).toContain('<a href="u"><strong>t</strong></a>'));
it('td bold rt', () => expect(rt('| h |\n|---|\n| **b** |')).toBe('| h |\n| --- |\n| **b** |'));
it('multi-cell rt', () => expect(rt('| **a** | *b* |\n|---|---|\n| `c` | [d](e) |')).toBe('| **a** | *b* |\n| --- | --- |\n| `c` | [d](e) |'));
});

83
test/macros.test.ts Normal file
View File

@ -0,0 +1,83 @@
import { ribbit } from './setup';
const r = ribbit();
const macros = [
{
name: 'user',
toHTML: () => '<a href="/user">TestUser</a>',
selector: 'A[href="/user"]',
toMarkdown: () => '@user',
},
{
name: 'npc',
toHTML: ({ keywords }: any) => {
const name = keywords.join(' ');
return '<a href="/NPC/' + name.replace(/ /g, '') + '">' + name + '</a>';
},
selector: 'A[href^="/NPC/"]',
toMarkdown: (el: any) => '@npc(' + el.textContent + ')',
},
{
name: 'style',
toHTML: ({ keywords, content }: any) => '<div class="' + keywords.join(' ') + '">' + (content || '') + '</div>',
selector: 'DIV[class]',
toMarkdown: (el: any, convert: any) => '\n\n@style(' + el.className + '\n' + convert.children(el) + '\n)\n\n',
},
{
name: 'toc',
toHTML: ({ params }: any) => '<aside class="toc" data-depth="' + (params.depth || '3') + '"></aside>',
},
];
const h = new r.HopDown({ macros });
const H = (md: string) => h.toHTML(md);
const M = (html: string) => h.toMarkdown(html);
describe('Macros', () => {
describe('self-closing', () => {
it('bare name', () => expect(H('hello @user world')).toBe('<p>hello <a href="/user">TestUser</a> world</p>'));
it('empty parens', () => expect(H('hello @user() world')).toBe('<p>hello <a href="/user">TestUser</a> world</p>'));
it('keywords', () => expect(H('@npc(Goblin King)')).toBe('<p><a href="/NPC/GoblinKing">Goblin King</a></p>'));
it('params', () => expect(H('@toc(depth="2")')).toContain('data-depth="2"'));
});
describe('unknown macros', () => {
it('renders error', () => expect(H('@bogus')).toContain('ribbit-error'));
it('shows name', () => expect(H('@bogus')).toContain('@bogus'));
it('block error', () => expect(H('@bogus(args\ncontent\n)')).toContain('ribbit-error'));
});
it('email not matched', () => expect(H('user@example.com')).toBe('<p>user@example.com</p>'));
describe('block macros', () => {
it('content processed', () => expect(H('@style(box\n**bold**\n)')).toContain('<strong>bold</strong>'));
it('wraps in div', () => expect(H('@style(box\ncontent\n)')).toContain('<div class="box">'));
it('multiple keywords', () => expect(H('@style(box center\ncontent\n)')).toContain('class="box center"'));
});
describe('verbatim', () => {
it('skips markdown', () => expect(H('@style(box verbatim\n**bold**\n)')).toContain('**bold**'));
it('no strong tag', () => expect(H('@style(box verbatim\n**bold**\n)')).not.toContain('<strong>'));
it('escapes html', () => expect(H('@style(box verbatim\n<b>tag</b>\n)')).toContain('&lt;b&gt;'));
it('preserves newlines', () => expect(H('@style(box verbatim\nline1\nline2\n)')).toContain('line1<br>'));
it('strips keyword', () => expect(H('@style(box verbatim\ncontent\n)')).not.toContain('verbatim'));
});
describe('nesting', () => {
it('inline inside bold', () => expect(H('**@npc(Goblin King)**')).toContain('<strong><a href="/NPC/GoblinKing">'));
it('block contains list', () => expect(H('@style(box\n- item 1\n- item 2\n)')).toContain('<ul>'));
it('inline inside block', () => expect(H('@style(box\nhello @user world\n)')).toContain('<a href="/user">TestUser</a>'));
});
describe('fenced code protection', () => {
it('not in code block', () => expect(H('```\n@user\n```')).not.toContain('<a href="/user">'));
it('literal in code block', () => expect(H('```\n@user\n```')).toContain('@user'));
it('not in inline code', () => expect(H('`@user`')).not.toContain('<a href="/user">'));
});
describe('round-trips', () => {
it('npc', () => expect(M(H('@npc(Goblin King)'))).toBe('@npc(Goblin King)'));
it('user', () => expect(M(H('hello @user world'))).toBe('hello @user world'));
});
});

30
test/setup.ts Normal file
View File

@ -0,0 +1,30 @@
import { Window } from 'happy-dom';
import * as fs from 'fs';
import * as path from 'path';
let _window: any;
export function getWindow(): any {
if (!_window) {
_window = new Window({ url: 'http://localhost' });
(global as any).window = _window;
(global as any).document = _window.document;
(global as any).HTMLElement = _window.HTMLElement;
(global as any).Node = _window.Node;
(global as any).NodeFilter = _window.NodeFilter;
const bundle = fs.readFileSync(
path.join(__dirname, '..', 'dist', 'ribbit', 'ribbit.js'), 'utf8'
);
_window.eval(bundle.replace('var ribbit =', 'window.ribbit ='));
}
return _window;
}
export function ribbit(): any {
return getWindow().ribbit;
}
export function resetDOM(content = 'test'): void {
getWindow().document.body.innerHTML = `<article id="ribbit">${content}</article>`;
}

View File

@ -1,507 +0,0 @@
const { JSDOM } = require('jsdom');
const fs = require('fs');
const path = require('path');
// Set up a DOM environment and load the bundle
const dom = new JSDOM('<!DOCTYPE html><html><body></body></html>', {
url: 'http://localhost',
pretendToBeVisual: true,
});
global.window = dom.window;
global.document = dom.window.document;
global.HTMLElement = dom.window.HTMLElement;
global.Node = dom.window.Node;
// Load the compiled bundle — esbuild IIFE assigns to var ribbit,
// but eval in jsdom doesn't attach vars to window, so we patch it.
const bundle = fs.readFileSync(path.join(__dirname, '..', 'dist', 'ribbit', 'ribbit.js'), 'utf8');
dom.window.eval(bundle.replace('var ribbit =', 'window.ribbit ='));
const hopdown = new dom.window.ribbit.HopDown();
const H = hopdown.toHTML.bind(hopdown);
const M = hopdown.toMarkdown.bind(hopdown);
function rt(md) { return M(H(md)); }
// Test harness
let passed = 0, failed = 0, errors = [];
function norm(s) { return (s || '').replace(/\r\n/g, '\n').trim(); }
function eq(name, actual, expected) {
const a = norm(actual), e = norm(expected);
if (a === e) {
passed++;
} else {
failed++;
errors.push(name);
console.log(`${name}`);
console.log(` expected: ${e}`);
console.log(` actual: ${a}`);
}
}
function has(name, actual, sub) {
if (norm(actual).indexOf(norm(sub)) !== -1) {
passed++;
} else {
failed++;
errors.push(name);
console.log(`${name}`);
console.log(` expected to contain: ${sub}`);
console.log(` actual: ${actual}`);
}
}
function not(name, actual, sub) {
if (norm(actual).indexOf(norm(sub)) === -1) {
passed++;
} else {
failed++;
errors.push(name);
console.log(`${name}`);
console.log(` should NOT contain: ${sub}`);
console.log(` actual: ${actual}`);
}
}
function section(n) { /* silent */ }
// ── 1. Inline formatting ────────────────────────────────
section('1. Inline Formatting → HTML');
eq('bold', H('**bold**'), '<p><strong>bold</strong></p>');
eq('italic', H('*italic*'), '<p><em>italic</em></p>');
eq('inline code', H('`code`'), '<p><code>code</code></p>');
eq('link', H('[t](http://x)'), '<p><a href="http://x">t</a></p>');
eq('bold+italic', H('***bi***'), '<p><em><strong>bi</strong></em></p>');
eq('mixed inline', H('a **b** *c* `d`'), '<p>a <strong>b</strong> <em>c</em> <code>d</code></p>');
eq('code before bold', H('`a` **b**'), '<p><code>a</code> <strong>b</strong></p>');
// ── 2. Headings ─────────────────────────────────────────
eq('h1', H('# Title'), "<h1 id='Title'>Title</h1>");
eq('h2', H('## Sub'), "<h2 id='Sub'>Sub</h2>");
eq('h3', H('### Sub3'), "<h3 id='Sub3'>Sub3</h3>");
eq('h4', H('#### Sub4'), "<h4 id='Sub4'>Sub4</h4>");
eq('h5', H('##### Sub5'), "<h5 id='Sub5'>Sub5</h5>");
eq('h6', H('###### Sub6'), "<h6 id='Sub6'>Sub6</h6>");
has('heading id multi-word', H('## Hello World'), "id='HelloWorld'");
has('heading inline md', H('## **Bold** text'), '<strong>Bold</strong>');
// ── 3. Horizontal rules ─────────────────────────────────
eq('*** rule', H('***'), '<hr>');
eq('--- rule', H('---'), '<hr>');
eq('___ rule', H('___'), '<hr>');
// ── 4. Lists ────────────────────────────────────────────
eq('ul *', H('* a\n* b'), '<ul><li>a</li><li>b</li></ul>');
eq('ul -', H('- a\n- b'), '<ul><li>a</li><li>b</li></ul>');
eq('ol', H('1. a\n2. b'),'<ol><li>a</li><li>b</li></ol>');
has('ul inline', H('* **bold** item'), '<strong>bold</strong>');
has('ol inline', H('1. *em* item'), '<em>em</em>');
// ── 5. Blockquotes ──────────────────────────────────────
has('blockquote', H('> text'), '<blockquote>');
has('bq content', H('> hello'), 'hello');
has('multi-line bq', H('> a\n> b'), 'a');
// ── 6. Fenced code blocks ───────────────────────────────
has('code block', H('```\nx = 1\n```'), '<pre><code>');
has('code content', H('```\nx = 1\n```'), 'x = 1');
has('lang class', H('```js\nvar x;\n```'), 'language-js');
has('html escaped', H('```\n<div>\n```'), '&lt;div&gt;');
not('no lang attr when none', H('```\nplain\n```'), 'language-');
// ── 7. Tables ───────────────────────────────────────────
var tbl = '| a | b |\n|---|---|\n| 1 | 2 |';
has('table tag', H(tbl), '<table>');
has('thead', H(tbl), '<thead>');
has('tbody', H(tbl), '<tbody>');
has('th cells', H(tbl), '<th>a</th>');
has('td cells', H(tbl), '<td>1</td>');
var aligned = '| L | C | R |\n|:--|:--:|--:|\n| a | b | c |';
has('left align (default)', H(aligned), '<td>a</td>');
has('center align', H(aligned), 'text-align:center');
has('right align', H(aligned), 'text-align:right');
has('table inline md', H('| **b** | *i* |\n|---|---|\n| x | y |'), '<strong>b</strong>');
// ── 8. Paragraphs ───────────────────────────────────────
eq('single para', H('hello'), '<p>hello</p>');
eq('two paras', H('a\n\nb'), '<p>a</p>\n<p>b</p>');
eq('soft line break', H('a\nb'), '<p>a\nb</p>');
// ── 9. HTML → Markdown ──────────────────────────────────
eq('strong→**', M('<p><strong>b</strong></p>'), '**b**');
eq('em→*', M('<p><em>i</em></p>'), '*i*');
eq('code→`', M('<p><code>c</code></p>'), '`c`');
eq('a→[]', M('<a href="http://x">t</a>'), '[t](http://x)');
eq('p→text', M('<p>hello</p>'), 'hello');
eq('h1→#', M('<h1>T</h1>'), '# T');
eq('h2→##', M('<h2>T</h2>'), '## T');
eq('h3→###', M('<h3>T</h3>'), '### T');
eq('hr→---', M('<hr>'), '---');
eq('ul→-', M('<ul><li>a</li><li>b</li></ul>'), '- a\n- b');
eq('ol→1.', M('<ol><li>a</li><li>b</li></ol>'), '1. a\n2. b');
has('bq→>', M('<blockquote><p>q</p></blockquote>'), '> ');
has('pre→```', M('<pre><code>x</code></pre>'), '```');
has('pre content', M('<pre><code>x = 1</code></pre>'), 'x = 1');
has('pre lang', M('<pre><code class="language-py">x</code></pre>'), '```py');
var tableHtml = '<table><thead><tr><th>a</th><th>b</th></tr></thead><tbody><tr><td>1</td><td>2</td></tr></tbody></table>';
has('table→pipes', M(tableHtml), '| a | b |');
has('table separator', M(tableHtml), '| --- | --- |');
has('table body', M(tableHtml), '| 1 | 2 |');
// ── 10. Round-trip ──────────────────────────────────────
eq('para rt', rt('Hello world'), 'Hello world');
eq('bold rt', rt('**bold**'), '**bold**');
eq('italic rt', rt('*italic*'), '*italic*');
eq('code rt', rt('`code`'), '`code`');
eq('link rt', rt('[t](http://x)'), '[t](http://x)');
eq('h1 rt', rt('# Title'), '# Title');
eq('h2 rt', rt('## Sub'), '## Sub');
eq('hr rt', rt('---'), '---');
eq('ul rt', rt('- a\n- b'), '- a\n- b');
eq('ol rt', rt('1. a\n2. b'), '1. a\n2. b');
has('bq rt', rt('> quoted'), '> ');
has('code block rt', rt('```\nx = 1\n```'), '```');
has('code block rt content', rt('```\nx = 1\n```'), 'x = 1');
has('table rt', rt('| a | b |\n|---|---|\n| 1 | 2 |'), '| a | b |');
// ── 11. Edge cases ──────────────────────────────────────
eq('empty string', H(''), '');
eq('whitespace only', H(' '), '');
has('html entities', H('a & b < c'), '&amp;');
has('html in code', H('`<div>`'), '&lt;div&gt;');
eq('empty html→md', M(''), '');
has('para then heading', H('text\n\n## H'), '<h2');
has('list then para', H('- a\n\ntext'), '<p>text</p>');
has('table no leading pipe', H('a | b\n---|---\n1 | 2'), '<table>');
// ── 12. Complex document ────────────────────────────────
var doc = '# Title\n\nSome **bold** and *italic* text with `code`.\n\n## Section One\n\n- item 1\n- item 2\n\n## Section Two\n\n| Col A | Col B |\n|-------|-------|\n| 1 | 2 |\n\n> A blockquote\n\n```js\nvar x = 1;\n```\n\n[A link](http://example.com)\n\n---';
var html = H(doc);
has('doc: h1', html, "<h1 id='Title'>Title</h1>");
has('doc: bold', html, '<strong>bold</strong>');
has('doc: italic', html, '<em>italic</em>');
has('doc: code', html, '<code>code</code>');
has('doc: h2', html, '<h2');
has('doc: ul', html, '<ul>');
has('doc: table', html, '<table>');
has('doc: blockquote', html, '<blockquote>');
has('doc: pre', html, '<pre>');
has('doc: link', html, '<a href="http://example.com">');
has('doc: hr', html, '<hr>');
var md = M(html);
has('doc rt: heading', md, '# Title');
has('doc rt: bold', md, '**bold**');
has('doc rt: italic', md, '*italic*');
has('doc rt: code', md, '`code`');
has('doc rt: list', md, '- item 1');
has('doc rt: table', md, '| Col A | Col B |');
has('doc rt: bq', md, '> ');
has('doc rt: fenced', md, '```');
has('doc rt: link', md, '[A link](http://example.com)');
has('doc rt: hr', md, '---');
// ── 13. Nested Inline ───────────────────────────────────
eq('bold wraps italic', H('**a *b* c**'), '<p><strong>a <em>b</em> c</strong></p>');
eq('italic wraps bold', H('*a **b** c*'), '<p><em>a <strong>b</strong> c</em></p>');
eq('bold wraps code', H('**a `b` c**'), '<p><strong>a <code>b</code> c</strong></p>');
eq('italic wraps code', H('*a `b` c*'), '<p><em>a <code>b</code> c</em></p>');
eq('bold wraps link', H('**[t](u)**'), '<p><strong><a href="u">t</a></strong></p>');
eq('italic wraps link', H('*[t](u)*'), '<p><em><a href="u">t</a></em></p>');
eq('link with bold text', H('[**t**](u)'), '<p><a href="u"><strong>t</strong></a></p>');
eq('link with italic text', H('[*t*](u)'), '<p><a href="u"><em>t</em></a></p>');
eq('link with code text', H('[`t`](u)'), '<p><a href="u"><code>t</code></a></p>');
eq('bold>italic>code', H('***`x`***'), '<p><em><strong><code>x</code></strong></em></p>');
eq('bold wraps bold-italic', H('**a ***b*** c**'), '<p><strong>a <em><strong>b</strong></em> c</strong></p>');
// ── 14. Nested Blocks ───────────────────────────────────
has('bq > heading', H('> # Title'), '<h1');
has('bq > heading content', H('> # Title'), 'Title');
has('bq > list', H('> - a\n> - b'), '<ul>');
has('bq > list items', H('> - a\n> - b'), '<li>a</li>');
has('bq > inline md', H('> **bold**'), '<strong>bold</strong>');
has('bq > code', H('> `code`'), '<code>code</code>');
has('bq > link', H('> [t](u)'), '<a href="u">');
has('bq > bq', H('> > nested'), '<blockquote>');
has('bq > fenced code', H('> ```\n> x\n> ```'), '<code>');
has('li > bold', H('- **bold**'), '<strong>bold</strong>');
has('li > italic', H('- *italic*'), '<em>italic</em>');
has('li > code', H('- `code`'), '<code>code</code>');
has('li > link', H('- [t](u)'), '<a href="u">');
has('heading > link', H('## [t](u)'), '<a href="u">');
has('heading > code', H('## `code`'), '<code>code</code>');
has('table > bold', H('| **b** |\n|---|\n| x |'), '<strong>b</strong>');
has('table > italic', H('| *i* |\n|---|\n| x |'), '<em>i</em>');
has('table > code', H('| `c` |\n|---|\n| x |'), '<code>c</code>');
has('table > link', H('| [t](u) |\n|---|\n| x |'), '<a href="u">');
// ── 15. Nested Round-Trips ──────────────────────────────
eq('bold>italic rt', rt('**a *b* c**'), '**a *b* c**');
eq('italic>bold rt', rt('*a **b** c*'), '*a **b** c*');
eq('bold>code rt', rt('**a `b` c**'), '**a `b` c**');
eq('bold>link rt', rt('**[t](u)**'), '**[t](u)**');
eq('link>bold rt', rt('[**t**](u)'), '[**t**](u)');
has('bq>heading rt', rt('> # Title'), '> ');
has('bq>heading rt title', rt('> # Title'), '# Title');
has('bq>list rt', rt('> - a\n> - b'), '> ');
has('li>bold rt', rt('- **bold**'), '**bold**');
has('heading>code rt', rt('## `code`'), '`code`');
// ── 16. Nested Lists ────────────────────────────────────
eq('ul > ul', H('- a\n - b\n - c\n- d'), '<ul><li>a<ul><li>b</li><li>c</li></ul></li><li>d</li></ul>');
eq('ol > ol', H('1. a\n 1. b\n 1. c\n2. d'), '<ol><li>a<ol><li>b</li><li>c</li></ol></li><li>d</li></ol>');
eq('ul > ol', H('- a\n 1. b\n 2. c\n- d'), '<ul><li>a<ol><li>b</li><li>c</li></ol></li><li>d</li></ul>');
eq('ol > ul', H('1. a\n - b\n - c\n2. d'), '<ol><li>a<ul><li>b</li><li>c</li></ul></li><li>d</li></ol>');
eq('3-level nesting', H('- a\n - b\n - c\n- d'), '<ul><li>a<ul><li>b<ul><li>c</li></ul></li></ul></li><li>d</li></ul>');
has('nested li > bold', H('- a\n - **bold**'), '<strong>bold</strong>');
has('nested li > link', H('- a\n - [t](u)'), '<a href="u">');
eq('ul>ul → md', M('<ul><li>a<ul><li>b</li><li>c</li></ul></li><li>d</li></ul>'), '- a\n - b\n - c\n- d');
eq('ol>ol → md', M('<ol><li>a<ol><li>b</li><li>c</li></ol></li><li>d</li></ol>'), '1. a\n 1. b\n 2. c\n2. d');
eq('ul>ol → md', M('<ul><li>a<ol><li>b</li><li>c</li></ol></li><li>d</li></ul>'), '- a\n 1. b\n 2. c\n- d');
eq('3-level → md', M('<ul><li>a<ul><li>b<ul><li>c</li></ul></li></ul></li><li>d</li></ul>'), '- a\n - b\n - c\n- d');
eq('ul>ul rt', rt('- a\n - b\n - c\n- d'), '- a\n - b\n - c\n- d');
eq('ol>ol rt', rt('1. a\n 1. b\n 1. c\n2. d'), '1. a\n 1. b\n 2. c\n2. d');
eq('ul>ol rt', rt('- a\n 1. b\n 2. c\n- d'), '- a\n 1. b\n 2. c\n- d');
eq('3-level rt', rt('- a\n - b\n - c\n- d'), '- a\n - b\n - c\n- d');
// ── 17. Tables with nested markdown ─────────────────────
has('td bold', H('| h |\n|---|\n| **b** |'), '<td><strong>b</strong></td>');
has('td italic', H('| h |\n|---|\n| *i* |'), '<td><em>i</em></td>');
has('td code', H('| h |\n|---|\n| `c` |'), '<td><code>c</code></td>');
has('td link', H('| h |\n|---|\n| [t](u) |'), '<td><a href="u">t</a></td>');
has('td bold+italic', H('| h |\n|---|\n| ***bi*** |'), '<td><em><strong>bi</strong></em></td>');
has('td bold>italic', H('| h |\n|---|\n| **a *b* c** |'), '<strong>a <em>b</em> c</strong>');
has('td link>bold', H('| h |\n|---|\n| [**t**](u) |'), '<a href="u"><strong>t</strong></a>');
has('td link>code', H('| h |\n|---|\n| [`c`](u) |'), '<a href="u"><code>c</code></a>');
has('multi-cell bold+italic', H('| **a** | *b* |\n|---|---|\n| `c` | [d](e) |'), '<strong>a</strong>');
has('multi-cell code+link', H('| **a** | *b* |\n|---|---|\n| `c` | [d](e) |'), '<a href="e">d</a>');
eq('td bold → md', M('<table><thead><tr><th>h</th></tr></thead><tbody><tr><td><strong>b</strong></td></tr></tbody></table>'), '| h |\n| --- |\n| **b** |');
eq('td italic → md', M('<table><thead><tr><th>h</th></tr></thead><tbody><tr><td><em>i</em></td></tr></tbody></table>'), '| h |\n| --- |\n| *i* |');
eq('td code → md', M('<table><thead><tr><th>h</th></tr></thead><tbody><tr><td><code>c</code></td></tr></tbody></table>'), '| h |\n| --- |\n| `c` |');
eq('td link → md', M('<table><thead><tr><th>h</th></tr></thead><tbody><tr><td><a href="u">t</a></td></tr></tbody></table>'), '| h |\n| --- |\n| [t](u) |');
eq('td bold rt', rt('| h |\n|---|\n| **b** |'), '| h |\n| --- |\n| **b** |');
eq('td italic rt', rt('| h |\n|---|\n| *i* |'), '| h |\n| --- |\n| *i* |');
eq('td code rt', rt('| h |\n|---|\n| `c` |'), '| h |\n| --- |\n| `c` |');
eq('td link rt', rt('| h |\n|---|\n| [t](u) |'), '| h |\n| --- |\n| [t](u) |');
eq('td bold+italic rt', rt('| h |\n|---|\n| ***bi*** |'), '| h |\n| --- |\n| ***bi*** |');
eq('td link>bold rt', rt('| h |\n|---|\n| [**t**](u) |'), '| h |\n| --- |\n| [**t**](u) |');
eq('multi-cell rt', rt('| **a** | *b* |\n|---|---|\n| `c` | [d](e) |'), '| **a** | *b* |\n| --- | --- |\n| `c` | [d](e) |');
// ── 18. inlineTag() factory ─────────────────────────────
const strikethrough = dom.window.ribbit.inlineTag({
name: 'strikethrough',
delimiter: '~~',
htmlTag: 'del',
aliases: 'S,STRIKE',
precedence: 45,
});
const customInline = new dom.window.ribbit.HopDown({
tags: { ...dom.window.ribbit.defaultTags, 'DEL,S,STRIKE': strikethrough },
});
eq('factory: md→html', customInline.toHTML('~~struck~~'), '<p><del>struck</del></p>');
has('factory: html→md', customInline.toMarkdown('<p><del>struck</del></p>'), '~~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~~'), '<strong>bold</strong>');
eq('factory: non-recursive', dom.window.ribbit.inlineTag({
name: 'test',
delimiter: '%%',
htmlTag: 'mark',
recursive: false,
}).toHTML({ content: '<b>x</b>', raw: '', consumed: 0 }, { inline: s => s, block: s => s, children: n => '', node: n => '' }),
'<mark>&lt;b&gt;x&lt;/b&gt;</mark>');
// ── 19. Custom block tag ────────────────────────────────
const spoiler = {
name: 'spoiler',
match: (context) => {
if (!/^\|{3,}/.test(context.lines[context.index])) return null;
const content = [];
let i = context.index + 1;
while (i < context.lines.length && !/^\|{3,}/.test(context.lines[i])) content.push(context.lines[i++]);
return { content: content.join('\n'), raw: '', consumed: i + 1 - context.index };
},
toHTML: (token, convert) => '<details><summary>Spoiler</summary>' + convert.block(token.content) + '</details>',
selector: 'DETAILS',
toMarkdown: (element, convert) => '\n\n|||\n' + convert.children(element).trim() + '\n|||\n\n',
};
const customBlock = new dom.window.ribbit.HopDown({
tags: { 'DETAILS': spoiler, ...dom.window.ribbit.defaultTags },
});
has('custom block: md→html', customBlock.toHTML('|||\nhidden\n|||'), '<details>');
has('custom block: content', customBlock.toHTML('|||\nhidden\n|||'), 'hidden');
has('custom block: html→md', customBlock.toMarkdown('<details><summary>Spoiler</summary><p>hidden</p></details>'), '|||');
has('custom block: nested md', customBlock.toHTML('|||\n**bold** inside\n|||'), '<strong>bold</strong>');
// ── 20. HopDown({ exclude }) ────────────────────────────
const noTables = new dom.window.ribbit.HopDown({ exclude: ['table'] });
// With table excluded, pipe lines fall through to paragraph but isBlockStart
// 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>');
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>');
const noCode = new dom.window.ribbit.HopDown({ exclude: ['code'] });
eq('exclude: code not processed', noCode.toHTML('`code`'), '<p>`code`</p>');
has('exclude: bold still works', noCode.toHTML('**bold**'), '<strong>bold</strong>');
// ── 21. Collision detection: delimiter ───────────────────
let threw = false;
try {
const bad = dom.window.ribbit.inlineTag({ name: 'bad', delimiter: '*', htmlTag: 'span', precedence: 10 });
new dom.window.ribbit.HopDown({ tags: { ...dom.window.ribbit.defaultTags, 'SPAN': bad } });
} catch (e) {
threw = true;
}
eq('delimiter collision throws', String(threw), 'true');
threw = false;
try {
// Same delimiter, higher precedence than existing — should throw
const bad = dom.window.ribbit.inlineTag({ name: 'bad', delimiter: '**', htmlTag: 'span', precedence: 60 });
new dom.window.ribbit.HopDown({ tags: { ...dom.window.ribbit.defaultTags, 'SPAN': bad } });
} catch (e) {
threw = true;
}
eq('duplicate delimiter collision throws', String(threw), 'true');
// ── 22. Collision detection: selector ───────────────────
threw = false;
try {
const dup = { name: 'dup', match: () => null, toHTML: () => '', selector: 'STRONG', toMarkdown: () => '' };
new dom.window.ribbit.HopDown({ tags: { ...dom.window.ribbit.defaultTags, 'STRONG': dup } });
} catch (e) {
threw = true;
}
eq('selector collision throws', String(threw), 'true');
// ── 23. Precedence ordering ─────────────────────────────
// Longer delimiter with lower precedence should win
const tilde = dom.window.ribbit.inlineTag({ name: 'tilde', delimiter: '~', htmlTag: 's', precedence: 45 });
const doubleTilde = dom.window.ribbit.inlineTag({ name: 'doubleTilde', delimiter: '~~', htmlTag: 'del', precedence: 35 });
const precTest = new dom.window.ribbit.HopDown({
tags: { ...dom.window.ribbit.defaultTags, 'S': tilde, 'DEL': doubleTilde },
});
has('precedence: ~~ matches before ~', precTest.toHTML('~~struck~~'), '<del>struck</del>');
has('precedence: ~ still works', precTest.toHTML('~light~'), '<s>light</s>');
// Valid: longer delimiter has lower precedence
threw = false;
try {
const short = dom.window.ribbit.inlineTag({ name: 'short', delimiter: '~', htmlTag: 's', precedence: 50 });
const long = dom.window.ribbit.inlineTag({ name: 'long', delimiter: '~~', htmlTag: 'del', precedence: 40 });
new dom.window.ribbit.HopDown({ tags: { ...dom.window.ribbit.defaultTags, 'S': short, 'DEL': long } });
} catch (e) {
threw = true;
}
eq('valid precedence does not throw', String(threw), 'false');
// Invalid: longer delimiter has higher precedence
threw = false;
try {
const short = dom.window.ribbit.inlineTag({ name: 'short', delimiter: '~', htmlTag: 's', precedence: 30 });
const long = dom.window.ribbit.inlineTag({ name: 'long', delimiter: '~~', htmlTag: 'del', precedence: 50 });
new dom.window.ribbit.HopDown({ tags: { ...dom.window.ribbit.defaultTags, 'S': short, 'DEL': long } });
} catch (e) {
threw = true;
}
eq('invalid precedence throws', String(threw), 'true');
// ── 24. Macros ──────────────────────────────────────────
const macroConverter = new dom.window.ribbit.HopDown({
macros: [
{
name: 'user',
toHTML: () => '<a href="/user">TestUser</a>',
selector: 'A[href="/user"]',
toMarkdown: () => '@user',
},
{
name: 'npc',
toHTML: ({ keywords }) => {
const name = keywords.join(' ');
const target = name.replace(/ /g, '');
return '<a href="/NPC/' + target + '">' + name + '</a>';
},
selector: 'A[href^="/NPC/"]',
toMarkdown: (el) => '@npc(' + el.textContent + ')',
},
{
name: 'toc',
toHTML: ({ params }) =>
'<aside class="toc" data-depth="' + (params.depth || '3') + '"></aside>',
},
{
name: 'style',
toHTML: ({ keywords, content }) => {
const classes = keywords.join(' ');
return '<div class="' + classes + '">' + (content || '') + '</div>';
},
selector: 'DIV[class]',
toMarkdown: (el, convert) => {
return '\n\n@style(' + el.className + '\n' + convert.children(el) + '\n)\n\n';
},
},
],
});
const MH = macroConverter.toHTML.bind(macroConverter);
const MM = macroConverter.toMarkdown.bind(macroConverter);
function mrt(md) { return MM(MH(md)); }
// Self-closing macros
eq('macro: bare name', MH('hello @user world'), '<p>hello <a href="/user">TestUser</a> world</p>');
eq('macro: empty parens', MH('hello @user() world'), '<p>hello <a href="/user">TestUser</a> world</p>');
eq('macro: with keywords', MH('@npc(Goblin King)'), '<p><a href="/NPC/GoblinKing">Goblin King</a></p>');
has('macro: with params', MH('@toc(depth="2")'), 'data-depth="2"');
// Unknown macro — error
has('macro: unknown renders error', MH('@bogus'), 'ribbit-error');
has('macro: unknown shows name', MH('@bogus'), '@bogus');
// Email addresses not matched
eq('macro: email not matched', MH('user@example.com'), '<p>user@example.com</p>');
// Block macros
has('macro: block content processed', MH('@style(box\n**bold** inside\n)'), '<strong>bold</strong>');
has('macro: block wraps in div', MH('@style(box\ncontent\n)'), '<div class="box">');
has('macro: block multiple keywords', MH('@style(box center\ncontent\n)'), 'class="box center"');
// Verbatim
has('macro: verbatim skips markdown', MH('@style(box verbatim\n**bold**\n)'), '**bold**');
not('macro: verbatim no strong', MH('@style(box verbatim\n**bold**\n)'), '<strong>');
has('macro: verbatim escapes html', MH('@style(box verbatim\n<b>tag</b>\n)'), '&lt;b&gt;');
has('macro: verbatim preserves newlines', MH('@style(box verbatim\nline1\nline2\n)'), 'line1<br>');
not('macro: verbatim keyword stripped', MH('@style(box verbatim\ncontent\n)'), 'verbatim');
// Nesting
has('macro: inline inside bold', MH('**@npc(Goblin King)**'), '<strong><a href="/NPC/GoblinKing">');
has('macro: block contains list', MH('@style(box\n- item 1\n- item 2\n)'), '<ul>');
has('macro: block contains heading', MH('@style(box\n## Title\n)'), '<h2');
has('macro: inline inside block', MH('@style(box\nhello @user world\n)'), '<a href="/user">TestUser</a>');
// Inside other elements
has('macro: in list item', MH('- @npc(Goblin King)'), '<a href="/NPC/GoblinKing">');
has('macro: in heading', MH('## @npc(Goblin King)'), '<a href="/NPC/GoblinKing">');
// Fenced code protection
not('macro: not in code block', MH('```\n@user\n```'), '<a href="/user">');
has('macro: literal in code block', MH('```\n@user\n```'), '@user');
not('macro: not in inline code', MH('`@user`'), '<a href="/user">');
// Edge cases
has('macro: multiple inline', MH('@npc(Alice) and @npc(Bob)'), 'Alice');
has('macro: multiple inline second', MH('@npc(Alice) and @npc(Bob)'), 'Bob');
has('macro: unknown block renders error', MH('@bogus(args\ncontent\n)'), 'ribbit-error');
// Round-trips
eq('macro: npc round-trip', mrt('@npc(Goblin King)'), '@npc(Goblin King)');
eq('macro: user round-trip', mrt('hello @user world'), 'hello @user world');
// ── Results ─────────────────────────────────────────────
const total = passed + failed;
console.log(`\n${passed}/${total} passed (${Math.round(100 * passed / total)}%) — ${failed} failed`);
if (errors.length) {
console.log('\nFailed:');
errors.forEach(e => console.log(`${e}`));
}
process.exit(failed > 0 ? 1 : 0);

View File

@ -10,5 +10,6 @@
"sourceMap": true, "sourceMap": true,
"lib": ["ES2019", "DOM"] "lib": ["ES2019", "DOM"]
}, },
"include": ["src/ts/**/*.ts"] "include": ["src/ts/**/*.ts"],
"exclude": ["test/**/*"]
} }