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:
@@ -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)'), '<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');
|
||||
|
||||
// ── Results ─────────────────────────────────────────────
|
||||
const total = passed + failed;
|
||||
console.log(`\n${passed}/${total} passed (${Math.round(100 * passed / total)}%) — ${failed} failed`);
|
||||
|
||||
Reference in New Issue
Block a user