2026-04-28 09:59:30 -07:00
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 ;
2026-04-28 18:39:16 -07:00
// Load the compiled bundle — esbuild IIFE assigns to var ribbit,
// but eval in jsdom doesn't attach vars to window, so we patch it.
2026-04-28 18:17:32 -07:00
const bundle = fs . readFileSync ( path . join ( _ _dirname , '..' , 'dist' , 'ribbit' , 'ribbit.js' ) , 'utf8' ) ;
2026-04-28 18:39:16 -07:00
dom . window . eval ( bundle . replace ( 'var ribbit =' , 'window.ribbit =' ) ) ;
2026-04-28 09:59:30 -07:00
2026-04-28 18:39:16 -07:00
const hopdown = new dom . window . ribbit . HopDown ( ) ;
2026-04-28 09:59:30 -07:00
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 } ` ) ;
}
}
2026-04-28 20:18:19 -07:00
function section ( n ) { console . log ( ' ' + n ) ; }
2026-04-28 09:59:30 -07:00
// ── 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```' ) , '<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 ) , '<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' ) , '&' ) ;
has ( 'html in code' , H ( '`<div>`' ) , '<div>' ) ;
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 ─────────────────────────────
2026-04-28 18:39:16 -07:00
const strikethrough = dom . window . ribbit . inlineTag ( {
2026-04-28 09:59:30 -07:00
name : 'strikethrough' ,
delimiter : '~~' ,
htmlTag : 'del' ,
aliases : 'S,STRIKE' ,
precedence : 45 ,
} ) ;
2026-04-28 18:39:16 -07:00
const customInline = new dom . window . ribbit . HopDown ( {
tags : { ... dom . window . ribbit . defaultTags , 'DEL,S,STRIKE' : strikethrough } ,
2026-04-28 09:59:30 -07:00
} ) ;
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>' ) ;
2026-04-28 18:39:16 -07:00
eq ( 'factory: non-recursive' , dom . window . ribbit . inlineTag ( {
2026-04-28 09:59:30 -07:00
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><b>x</b></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' ,
} ;
2026-04-28 18:39:16 -07:00
const customBlock = new dom . window . ribbit . HopDown ( {
tags : { 'DETAILS' : spoiler , ... dom . window . ribbit . defaultTags } ,
2026-04-28 09:59:30 -07:00
} ) ;
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 }) ────────────────────────────
2026-04-28 18:39:16 -07:00
const noTables = new dom . window . ribbit . HopDown ( { exclude : [ 'table' ] } ) ;
2026-04-28 09:59:30 -07:00
// 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>' ) ;
2026-04-28 18:39:16 -07:00
const noCode = new dom . window . ribbit . HopDown ( { exclude : [ 'code' ] } ) ;
2026-04-28 09:59:30 -07:00
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 {
2026-04-28 18:39:16 -07:00
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 } } ) ;
2026-04-28 09:59:30 -07:00
} catch ( e ) {
threw = true ;
}
eq ( 'delimiter collision throws' , String ( threw ) , 'true' ) ;
threw = false ;
try {
// Same delimiter, higher precedence than existing — should throw
2026-04-28 18:39:16 -07:00
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 } } ) ;
2026-04-28 09:59:30 -07:00
} 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 : ( ) => '' } ;
2026-04-28 18:39:16 -07:00
new dom . window . ribbit . HopDown ( { tags : { ... dom . window . ribbit . defaultTags , 'STRONG' : dup } } ) ;
2026-04-28 09:59:30 -07:00
} catch ( e ) {
threw = true ;
}
eq ( 'selector collision throws' , String ( threw ) , 'true' ) ;
// ── 23. Precedence ordering ─────────────────────────────
// Longer delimiter with lower precedence should win
2026-04-28 18:39:16 -07:00
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 } ,
2026-04-28 09:59:30 -07:00
} ) ;
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 {
2026-04-28 18:39:16 -07:00
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 } } ) ;
2026-04-28 09:59:30 -07:00
} catch ( e ) {
threw = true ;
}
eq ( 'valid precedence does not throw' , String ( threw ) , 'false' ) ;
// Invalid: longer delimiter has higher precedence
threw = false ;
try {
2026-04-28 18:39:16 -07:00
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 } } ) ;
2026-04-28 09:59:30 -07:00
} catch ( e ) {
threw = true ;
}
eq ( 'invalid precedence throws' , String ( threw ) , 'true' ) ;
2026-04-28 20:03:58 -07:00
// ── 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)' ) , '<b>' ) ;
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' ) ;
2026-04-28 20:18:19 -07:00
// ── 25. Preview CSS (via .ribbit-editing pseudo-elements) ───
// Preview styling is handled by CSS ::before/::after on .ribbit-editing,
// not by JS. We verify the converter output is clean HTML without syntax spans.
not ( 'preview: no syntax spans in toHTML' ,
H ( '**bold**' ) , 'ribbit-syntax' ) ;
not ( 'preview: no syntax spans in heading' ,
H ( '## Title' ) , 'ribbit-syntax' ) ;
// ── 26. openPattern — unclosed delimiter detection ──────
var inlineTags = hopdown . getInlineTags ( ) ;
function findTag ( name ) {
return inlineTags . find ( function ( t ) { return t . name === name ; } ) ;
}
var boldTag = findTag ( 'bold' ) ;
var italicTag = findTag ( 'italic' ) ;
var codeTag = findTag ( 'code' ) ;
var boldItalicTag = findTag ( 'boldItalic' ) ;
eq ( 'openPattern: bold has pattern' , String ( ! ! boldTag . openPattern ) , 'true' ) ;
eq ( 'openPattern: italic has pattern' , String ( ! ! italicTag . openPattern ) , 'true' ) ;
eq ( 'openPattern: code has pattern' , String ( ! ! codeTag . openPattern ) , 'true' ) ;
// Unclosed bold matches
eq ( 'openPattern: unclosed ** odd count' ,
String ( ( ( 'hello **world' ) . match ( /\*\*/g ) || [ ] ) . length % 2 === 1 ) , 'true' ) ;
// Closed bold — even count
eq ( 'openPattern: closed ** even count' ,
String ( ( ( 'hello **world**' ) . match ( /\*\*/g ) || [ ] ) . length % 2 === 1 ) , 'false' ) ;
// Unclosed italic
eq ( 'openPattern: unclosed * odd count' ,
String ( ( ( 'hello *world' ) . match ( /\*/g ) || [ ] ) . length % 2 === 1 ) , 'true' ) ;
// Unclosed code
eq ( 'openPattern: unclosed ` odd count' ,
String ( ( ( 'hello `world' ) . match ( /`/g ) || [ ] ) . length % 2 === 1 ) , 'true' ) ;
// ── 27. Speculative patching ────────────────────────────
function specPatch ( md , cursorLine , cursorOffset ) {
var lines = md . split ( '\n' ) ;
var sorted = inlineTags . slice ( ) . sort ( function ( a , b ) {
return ( ( a ) . precedence || 50 ) - ( ( b ) . precedence || 50 ) ;
} ) ;
for ( var i = 0 ; i < sorted . length ; i ++ ) {
var tag = sorted [ i ] ;
if ( tag . openPattern && tag . delimiter ) {
var before = lines [ cursorLine ] . slice ( 0 , cursorOffset ) ;
var escaped = tag . delimiter . replace ( /[.*+?^${}()|[\]\\]/g , '\\$&' ) ;
var re = new RegExp ( escaped , 'g' ) ;
var count = ( before . match ( re ) || [ ] ) . length ;
if ( count % 2 === 1 ) {
lines [ cursorLine ] = lines [ cursorLine ] + tag . delimiter ;
break ;
}
}
}
return hopdown . toHTML ( lines . join ( '\n' ) ) ;
}
has ( 'speculate: unclosed bold' ,
specPatch ( 'hello **world' , 0 , 13 ) , '<strong>world</strong>' ) ;
has ( 'speculate: unclosed italic' ,
specPatch ( 'hello *world' , 0 , 12 ) , '<em>world</em>' ) ;
has ( 'speculate: unclosed code' ,
specPatch ( 'hello `world' , 0 , 12 ) , '<code>world</code>' ) ;
has ( 'speculate: unclosed bold+italic' ,
specPatch ( 'hello ***world' , 0 , 14 ) , '<em><strong>world</strong></em>' ) ;
// Already closed — no double closing
eq ( 'speculate: closed bold unchanged' ,
specPatch ( 'hello **world**' , 0 , 15 ) , '<p>hello <strong>world</strong></p>' ) ;
eq ( 'speculate: closed italic unchanged' ,
specPatch ( 'hello *world*' , 0 , 13 ) , '<p>hello <em>world</em></p>' ) ;
// Only cursor line patched
has ( 'speculate: multiline patches cursor only' ,
specPatch ( 'normal\nhello **world' , 1 , 13 ) , '<strong>world</strong>' ) ;
not ( 'speculate: other line untouched' ,
specPatch ( 'normal\nhello **world' , 1 , 13 ) , '<strong>normal</strong>' ) ;
// No unclosed delimiter — no change
eq ( 'speculate: no delimiter no-op' ,
specPatch ( 'hello world' , 0 , 11 ) , '<p>hello world</p>' ) ;
// ** wins over * (precedence)
has ( 'speculate: ** wins over *' ,
specPatch ( 'hello **world' , 0 , 13 ) , '<strong>' ) ;
not ( 'speculate: ** not italic' ,
specPatch ( 'hello **world' , 0 , 13 ) , '<em>world</em>' ) ;
// Delimiter with no content — speculation appends but nothing to format
eq ( 'speculate: bare delimiter no content' ,
specPatch ( 'hello **' , 0 , 8 ) , '<p>hello <em>*</em>*</p>' ) ;
// Even count — all closed
eq ( 'speculate: even count no-op' ,
specPatch ( '**a** **b**' , 0 , 11 ) , '<p><strong>a</strong> <strong>b</strong></p>' ) ;
// Block tags need no speculation
eq ( 'speculate: list works as-is' ,
H ( '- ' ) , '<ul><li></li></ul>' ) ;
has ( 'speculate: blockquote works as-is' ,
H ( '> ' ) , '<blockquote>' ) ;
2026-04-28 09:59:30 -07:00
// ── 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 ) ;