');
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.ribbit.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.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~~'), 'struck');
has('precedence: ~ still works', precTest.toHTML('~light~'), 'light');
// 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: () => 'TestUser',
selector: 'A[href="/user"]',
toMarkdown: () => '@user',
},
{
name: 'npc',
toHTML: ({ keywords }) => {
const name = keywords.join(' ');
const target = name.replace(/ /g, '');
return '' + name + '';
},
selector: 'A[href^="/NPC/"]',
toMarkdown: (el) => '@npc(' + el.textContent + ')',
},
{
name: 'toc',
toHTML: ({ params }) =>
'',
},
{
name: 'style',
toHTML: ({ keywords, content }) => {
const classes = keywords.join(' ');
return '' + (content || '') + '
';
},
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'), 'hello TestUser world
');
eq('macro: empty parens', MH('hello @user() world'), 'hello TestUser world
');
eq('macro: with keywords', MH('@npc(Goblin King)'), 'Goblin King
');
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'), 'user@example.com
');
// Block macros
has('macro: block content processed', MH('@style(box\n**bold** inside\n)'), 'bold');
has('macro: block wraps in div', MH('@style(box\ncontent\n)'), '');
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)'), '
');
has('macro: verbatim escapes html', MH('@style(box verbatim\ntag\n)'), '<b>');
has('macro: verbatim preserves newlines', MH('@style(box verbatim\nline1\nline2\n)'), 'line1
');
not('macro: verbatim keyword stripped', MH('@style(box verbatim\ncontent\n)'), 'verbatim');
// Nesting
has('macro: inline inside bold', MH('**@npc(Goblin King)**'), '');
has('macro: block contains list', MH('@style(box\n- item 1\n- item 2\n)'), '');
has('macro: block contains heading', MH('@style(box\n## Title\n)'), 'TestUser
');
// Inside other elements
has('macro: in list item', MH('- @npc(Goblin King)'), '');
has('macro: in heading', MH('## @npc(Goblin King)'), '');
// Fenced code protection
not('macro: not in code block', MH('```\n@user\n```'), '');
has('macro: literal in code block', MH('```\n@user\n```'), '@user');
not('macro: not in inline code', MH('`@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');
// ── 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), 'world');
has('speculate: unclosed italic',
specPatch('hello *world', 0, 12), 'world');
has('speculate: unclosed code',
specPatch('hello `world', 0, 12), 'world');
has('speculate: unclosed bold+italic',
specPatch('hello ***world', 0, 14), 'world');
// Already closed — no double closing
eq('speculate: closed bold unchanged',
specPatch('hello **world**', 0, 15), 'hello world
');
eq('speculate: closed italic unchanged',
specPatch('hello *world*', 0, 13), 'hello world
');
// Only cursor line patched
has('speculate: multiline patches cursor only',
specPatch('normal\nhello **world', 1, 13), 'world');
not('speculate: other line untouched',
specPatch('normal\nhello **world', 1, 13), 'normal');
// No unclosed delimiter — no change
eq('speculate: no delimiter no-op',
specPatch('hello world', 0, 11), 'hello world
');
// ** wins over * (precedence)
has('speculate: ** wins over *',
specPatch('hello **world', 0, 13), '');
not('speculate: ** not italic',
specPatch('hello **world', 0, 13), 'world');
// Delimiter with no content — speculation appends but nothing to format
eq('speculate: bare delimiter no content',
specPatch('hello **', 0, 8), 'hello **
');
// Even count — all closed
eq('speculate: even count no-op',
specPatch('**a** **b**', 0, 11), 'a b
');
// Block tags need no speculation
eq('speculate: list works as-is',
H('- '), '');
has('speculate: blockquote works as-is',
H('> '), '');
// ── 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);