feat: Add macro support

New: macros.ts with MacroDef, parseBlockMacro, matchInlineMacro,
buildMacroTags, processInlineMacros.

Macro syntax:
  @user                     — bare, no args
  @user()                   — empty parens, same as bare
  @npc(Goblin King)         — self-closing with args
  @style(box center         — block: no closing paren on first line
  Content here.             — content on subsequent lines
  )                         — closing paren on its own line

Unknown macro names now render as an error:
  <span class="ribbit-error">Unknown macro: @bogus</span>

The verbatim keyword causes the contents to render as literals and also
preserves line breaks.
This commit is contained in:
gsb
2026-04-29 03:03:58 +00:00
parent df49ce7545
commit 86d59877f1
5 changed files with 382 additions and 5 deletions
+90
View File
@@ -407,6 +407,96 @@ try {
}
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`);