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('', { 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 (attaches globals to window) const bundle = fs.readFileSync(path.join(__dirname, '..', 'dist', 'ribbit', 'ribbit.js'), 'utf8'); dom.window.eval(bundle); const hopdown = new dom.window.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**'), '

bold

'); eq('italic', H('*italic*'), '

italic

'); eq('inline code', H('`code`'), '

code

'); eq('link', H('[t](http://x)'), '

t

'); eq('bold+italic', H('***bi***'), '

bi

'); eq('mixed inline', H('a **b** *c* `d`'), '

a b c d

'); eq('code before bold', H('`a` **b**'), '

a b

'); // ── 2. Headings ───────────────────────────────────────── eq('h1', H('# Title'), "

Title

"); eq('h2', H('## Sub'), "

Sub

"); eq('h3', H('### Sub3'), "

Sub3

"); eq('h4', H('#### Sub4'), "

Sub4

"); eq('h5', H('##### Sub5'), "
Sub5
"); eq('h6', H('###### Sub6'), "
Sub6
"); has('heading id multi-word', H('## Hello World'), "id='HelloWorld'"); has('heading inline md', H('## **Bold** text'), 'Bold'); // ── 3. Horizontal rules ───────────────────────────────── eq('*** rule', H('***'), '
'); eq('--- rule', H('---'), '
'); eq('___ rule', H('___'), '
'); // ── 4. Lists ──────────────────────────────────────────── eq('ul *', H('* a\n* b'), ''); eq('ul -', H('- a\n- b'), ''); eq('ol', H('1. a\n2. b'),'
  1. a
  2. b
'); has('ul inline', H('* **bold** item'), 'bold'); has('ol inline', H('1. *em* item'), 'em'); // ── 5. Blockquotes ────────────────────────────────────── has('blockquote', H('> text'), '
'); 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```'), '
');
has('code content',     H('```\nx = 1\n```'),  'x = 1');
has('lang class',       H('```js\nvar x;\n```'), 'language-js');
has('html escaped',     H('```\n
\n```'), '<div>'); 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), ''); has('thead', H(tbl), ''); has('tbody', H(tbl), ''); has('th cells', H(tbl), ''); has('td cells', H(tbl), ''); var aligned = '| L | C | R |\n|:--|:--:|--:|\n| a | b | c |'; has('left align (default)', H(aligned), ''); 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 |'), 'b'); // ── 8. Paragraphs ─────────────────────────────────────── eq('single para', H('hello'), '

hello

'); eq('two paras', H('a\n\nb'), '

a

\n

b

'); eq('soft line break', H('a\nb'), '

a\nb

'); // ── 9. HTML → Markdown ────────────────────────────────── eq('strong→**', M('

b

'), '**b**'); eq('em→*', M('

i

'), '*i*'); eq('code→`', M('

c

'), '`c`'); eq('a→[]', M('t'), '[t](http://x)'); eq('p→text', M('

hello

'), 'hello'); eq('h1→#', M('

T

'), '# T'); eq('h2→##', M('

T

'), '## T'); eq('h3→###', M('

T

'), '### T'); eq('hr→---', M('
'), '---'); eq('ul→-', M('
  • a
  • b
'), '- a\n- b'); eq('ol→1.', M('
  1. a
  2. b
'), '1. a\n2. b'); has('bq→>', M('

q

'), '> '); has('pre→```', M('
x
'), '```'); has('pre content', M('
x = 1
'), 'x = 1'); has('pre lang', M('
x
'), '```py'); var tableHtml = '
a1a
ab
12
'; 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'), '&'); has('html in code', H('`
`'), '<div>'); eq('empty html→md', M(''), ''); has('para then heading', H('text\n\n## H'), 'text

'); has('table no leading pipe', H('a | b\n---|---\n1 | 2'), ''); // ── 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, "

Title

"); has('doc: bold', html, 'bold'); has('doc: italic', html, 'italic'); has('doc: code', html, 'code'); has('doc: h2', html, ''); has('doc: table', html, '
'); has('doc: blockquote', html, '
'); has('doc: pre', html, '
');
has('doc: link',      html, '');
has('doc: hr',        html, '
'); 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**'), '

a b c

'); eq('italic wraps bold', H('*a **b** c*'), '

a b c

'); eq('bold wraps code', H('**a `b` c**'), '

a b c

'); eq('italic wraps code', H('*a `b` c*'), '

a b c

'); eq('bold wraps link', H('**[t](u)**'), '

t

'); eq('italic wraps link', H('*[t](u)*'), '

t

'); eq('link with bold text', H('[**t**](u)'), '

t

'); eq('link with italic text', H('[*t*](u)'), '

t

'); eq('link with code text', H('[`t`](u)'), '

t

'); eq('bold>italic>code', H('***`x`***'), '

x

'); eq('bold wraps bold-italic', H('**a ***b*** c**'), '

a b c

'); // ── 14. Nested Blocks ─────────────────────────────────── has('bq > heading', H('> # Title'), ' heading content', H('> # Title'), 'Title'); has('bq > list', H('> - a\n> - b'), '
'); has('td italic', H('| h |\n|---|\n| *i* |'), ''); has('td code', H('| h |\n|---|\n| `c` |'), ''); has('td link', H('| h |\n|---|\n| [t](u) |'), ''); has('td bold+italic', H('| h |\n|---|\n| ***bi*** |'), ''); has('td bold>italic', H('| h |\n|---|\n| **a *b* c** |'), 'a b c'); has('td link>bold', H('| h |\n|---|\n| [**t**](u) |'), 't'); has('td link>code', H('| h |\n|---|\n| [`c`](u) |'), 'c'); has('multi-cell bold+italic', H('| **a** | *b* |\n|---|---|\n| `c` | [d](e) |'), 'a'); has('multi-cell code+link', H('| **a** | *b* |\n|---|---|\n| `c` | [d](e) |'), 'd'); eq('td bold → md', M('
bictbi
h
b
'), '| h |\n| --- |\n| **b** |'); eq('td italic → md', M('
h
i
'), '| h |\n| --- |\n| *i* |'); eq('td code → md', M('
h
c
'), '| h |\n| --- |\n| `c` |'); eq('td link → md', M('
h
t
'), '| 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.inlineTag({ name: 'strikethrough', delimiter: '~~', htmlTag: 'del', aliases: 'S,STRIKE', precedence: 45, }); const customInline = new dom.window.HopDown({ tags: { ...dom.window.defaultTags, 'DEL,S,STRIKE': strikethrough }, }); eq('factory: md→html', customInline.toHTML('~~struck~~'), '

struck

'); has('factory: html→md', customInline.toMarkdown('

struck

'), '~~struck~~'); eq('factory: round-trip', customInline.toMarkdown(customInline.toHTML('~~struck~~')), '~~struck~~'); has('factory: mixed with bold', customInline.toHTML('**bold** and ~~struck~~'), 'struck'); has('factory: mixed with bold', customInline.toHTML('**bold** and ~~struck~~'), 'bold'); eq('factory: non-recursive', dom.window.inlineTag({ name: 'test', delimiter: '%%', htmlTag: 'mark', recursive: false, }).toHTML({ content: 'x', raw: '', consumed: 0 }, { inline: s => s, block: s => s, children: n => '', node: n => '' }), '<b>x</b>'); // ── 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) => '
Spoiler' + convert.block(token.content) + '
', selector: 'DETAILS', toMarkdown: (element, convert) => '\n\n|||\n' + convert.children(element).trim() + '\n|||\n\n', }; const customBlock = new dom.window.HopDown({ tags: { 'DETAILS': spoiler, ...dom.window.defaultTags }, }); has('custom block: md→html', customBlock.toHTML('|||\nhidden\n|||'), '
'); has('custom block: content', customBlock.toHTML('|||\nhidden\n|||'), 'hidden'); has('custom block: html→md', customBlock.toMarkdown('
Spoiler

hidden

'), '|||'); has('custom block: nested md', customBlock.toHTML('|||\n**bold** inside\n|||'), 'bold'); // ── 20. HopDown({ exclude }) ──────────────────────────── const noTables = new dom.window.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 |'), '

'); not('exclude: no table tag', noTables.toHTML('| a | b |\n|---|---|\n| 1 | 2 |'), ''); has('exclude: bold still works', noTables.toHTML('**bold**'), 'bold'); const noCode = new dom.window.HopDown({ exclude: ['code'] }); eq('exclude: code not processed', noCode.toHTML('`code`'), '

`code`

'); has('exclude: bold still works', noCode.toHTML('**bold**'), 'bold'); // ── 21. Collision detection: delimiter ─────────────────── let threw = false; try { const bad = dom.window.inlineTag({ name: 'bad', delimiter: '*', htmlTag: 'span', precedence: 10 }); new dom.window.HopDown({ tags: { ...dom.window.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.inlineTag({ name: 'bad', delimiter: '**', htmlTag: 'span', precedence: 60 }); new dom.window.HopDown({ tags: { ...dom.window.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.HopDown({ tags: { ...dom.window.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.inlineTag({ name: 'tilde', delimiter: '~', htmlTag: 's', precedence: 45 }); const doubleTilde = dom.window.inlineTag({ name: 'doubleTilde', delimiter: '~~', htmlTag: 'del', precedence: 35 }); const precTest = new dom.window.HopDown({ tags: { ...dom.window.defaultTags, 'S': tilde, 'DEL': doubleTilde }, }); has('precedence: ~~ matches before ~', precTest.toHTML('~~struck~~'), 'struck'); has('precedence: ~ still works', precTest.toHTML('~light~'), 'light'); // Valid: longer delimiter has lower precedence threw = false; try { const short = dom.window.inlineTag({ name: 'short', delimiter: '~', htmlTag: 's', precedence: 50 }); const long = dom.window.inlineTag({ name: 'long', delimiter: '~~', htmlTag: 'del', precedence: 40 }); new dom.window.HopDown({ tags: { ...dom.window.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.inlineTag({ name: 'short', delimiter: '~', htmlTag: 's', precedence: 30 }); const long = dom.window.inlineTag({ name: 'long', delimiter: '~~', htmlTag: 'del', precedence: 50 }); new dom.window.HopDown({ tags: { ...dom.window.defaultTags, 'S': short, 'DEL': long } }); } catch (e) { threw = true; } eq('invalid precedence throws', String(threw), 'true'); // ── 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);