diff --git a/src/ts/hopdown.ts b/src/ts/hopdown.ts index ee8c521..d087933 100644 --- a/src/ts/hopdown.ts +++ b/src/ts/hopdown.ts @@ -52,13 +52,9 @@ export class HopDown { // Build macro tags if macros are provided this.macroMap = new Map(); if (options.macros && options.macros.length > 0) { - const { blockTag, selectorEntries, macroMap } = buildMacroTags(options.macros); + const { blockTag, selectorTag, macroMap } = buildMacroTags(options.macros); this.macroMap = macroMap; - tagMap = { - ...tagMap, - ...selectorEntries, - }; - // Insert macro block tag — will be placed after fencedCode below + tagMap['[data-macro]'] = selectorTag; tagMap['_macro'] = blockTag; } diff --git a/src/ts/macros.ts b/src/ts/macros.ts index 1c95dd6..d909efb 100644 --- a/src/ts/macros.ts +++ b/src/ts/macros.ts @@ -1,9 +1,9 @@ /* * macros.ts — macro parsing and Tag generation for ribbit. * - * Macros use @name(...) syntax. Everything lives inside the parens: - * args on the first line, content on subsequent lines. The closing ) - * on its own line ends a block macro. + * Macros use @name(...) syntax. Ribbit automatically wraps macro output + * in an element with data- attributes that preserve the original source. + * Round-tripping is handled generically — consumers only write toHTML. * * Syntax: * @user — bare, no args @@ -24,12 +24,12 @@ import { escapeHtml } from './tags'; export interface MacroDef { name: string; /** - * Render the macro to HTML. + * Render the macro's inner HTML. Ribbit wraps the result in an + * element with data- attributes for round-tripping. * - * { name: 'npc', toHTML: ({ keywords }) => { - * const name = keywords.join(' '); - * return `${name}`; - * }} + * { name: 'user', toHTML: () => 'gsb' } + * { name: 'style', toHTML: ({ keywords, content }) => + * `
${content}
` } */ toHTML: (context: { keywords: string[]; @@ -37,17 +37,6 @@ export interface MacroDef { content?: string; convert: Converter; }) => string; - /** - * CSS selector for the HTML this macro produces. - * Required for HTML→markdown round-tripping. - */ - selector?: string; - /** - * Convert the macro's HTML back to macro syntax. - * - * toMarkdown: (el) => `@npc(${el.textContent})` - */ - toMarkdown?: (element: HTMLElement, convert: Converter) => string; } interface ParsedMacro { @@ -84,10 +73,64 @@ function macroError(name: string): string { return `Unknown macro: @${escapeHtml(name)}`; } +/** + * Wrap a macro's rendered HTML with data- attributes for round-tripping. + * Block macros (with content) use
, inline macros use . + */ +function wrapMacro( + name: string, + keywords: string[], + params: Record, + verbatim: boolean, + hasContent: boolean, + innerHtml: string, +): string { + const tag = hasContent ? 'div' : 'span'; + let attrs = ` data-macro="${escapeHtml(name)}"`; + if (keywords.length) { + attrs += ` data-keywords="${escapeHtml(keywords.join(' '))}"`; + } + for (const [key, val] of Object.entries(params)) { + attrs += ` data-param-${escapeHtml(key)}="${escapeHtml(val)}"`; + } + if (verbatim) { + attrs += ` data-verbatim="true"`; + } + return `<${tag}${attrs}>${innerHtml}`; +} + +/** + * Reconstruct macro source from a DOM element's data- attributes. + * This is the generic toMarkdown for all macros. + */ +function macroToMarkdown(element: HTMLElement, convert: Converter): string { + const name = element.dataset.macro || ''; + const keywords = element.dataset.keywords || ''; + const verbatim = element.dataset.verbatim === 'true'; + + const paramParts: string[] = []; + for (const [key, val] of Object.entries(element.dataset)) { + if (key.startsWith('param') && key.length > 5) { + const paramName = key.slice(5).toLowerCase(); + paramParts.push(`${paramName}="${val}"`); + } + } + + const allKeywords = verbatim + ? [keywords, 'verbatim'].filter(Boolean).join(' ') + : keywords; + const args = [allKeywords, paramParts.join(' ')].filter(Boolean).join(' '); + + const isBlock = element.tagName === 'DIV'; + if (isBlock) { + const content = convert.children(element); + return `\n\n@${name}(${args}\n${content}\n)\n\n`; + } + return args ? `@${name}(${args})` : `@${name}`; +} + /** * Try to parse a block macro starting at the given line index. - * Matches: @name(args at end of line (no closing paren), - * with content until a line containing only ) */ function parseBlockMacro(lines: string[], index: number): ParsedMacro | null { const line = lines[index]; @@ -126,10 +169,6 @@ function parseBlockMacro(lines: string[], index: number): ParsedMacro | null { }; } -/** - * Inline macro pattern. Matches @name, @name(), or @name(args). - * The @ must be preceded by whitespace, start of string, or markdown delimiters. - */ const INLINE_MACRO_GLOBAL = /(?:^|(?<=[\s*_(>|]))@(\w+)(?:\(([^)]*)\))?/g; /** @@ -137,7 +176,7 @@ const INLINE_MACRO_GLOBAL = /(?:^|(?<=[\s*_(>|]))@(\w+)(?:\(([^)]*)\))?/g; */ export function buildMacroTags( macros: MacroDef[], -): { blockTag: Tag; selectorEntries: Record; macroMap: Map } { +): { blockTag: Tag; selectorTag: Tag; macroMap: Map } { const macroMap = new Map(); for (const macro of macros) { macroMap.set(macro.name, macro); @@ -175,37 +214,38 @@ export function buildMacroTags( content = convert.block(content); } } - return macro.toHTML({ + const innerHtml = macro.toHTML({ keywords: parsed.keywords, params: parsed.params, content, convert, }); + return wrapMacro( + parsed.name, parsed.keywords, parsed.params, + parsed.verbatim, true, innerHtml, + ); }, selector: '[data-macro]', toMarkdown: () => '', }; - const selectorEntries: Record = {}; - for (const macro of macros) { - if (macro.selector && macro.toMarkdown) { - const macroCopy = macro; - selectorEntries[macro.selector] = { - name: `macro:${macro.name}`, - match: () => null, - toHTML: () => '', - selector: macro.selector, - toMarkdown: (element, convert) => macroCopy.toMarkdown!(element, convert), - }; - } - } + /** + * Generic selector tag that matches any element with data-macro + * and reconstructs the macro source from data- attributes. + */ + const selectorTag: Tag = { + name: 'macro:generic', + match: () => null, + toHTML: () => '', + selector: '[data-macro]', + toMarkdown: macroToMarkdown, + }; - return { blockTag, selectorEntries, macroMap }; + return { blockTag, selectorTag, macroMap }; } /** * Process inline macros in a text string, replacing them with rendered HTML. - * Called during inline processing pass 1 (placeholder extraction). */ export function processInlineMacros( text: string, @@ -220,12 +260,13 @@ export function processInlineMacros( return '\x00P' + (placeholders.length - 1) + '\x00'; } const { keywords, params } = parseArgs(argsStr); - const html = macro.toHTML({ + const innerHtml = macro.toHTML({ keywords, params, convert, }); - placeholders.push(html); + const wrapped = wrapMacro(nameStr, keywords, params, false, false, innerHtml); + placeholders.push(wrapped); return '\x00P' + (placeholders.length - 1) + '\x00'; }); } diff --git a/test/macros.test.ts b/test/macros.test.ts index bfc420f..d1f1224 100644 --- a/test/macros.test.ts +++ b/test/macros.test.ts @@ -6,8 +6,6 @@ const macros = [ { name: 'user', toHTML: () => 'TestUser', - selector: 'A[href="/user"]', - toMarkdown: () => '@user', }, { name: 'npc', @@ -15,14 +13,10 @@ const macros = [ const name = keywords.join(' '); return '' + name + ''; }, - selector: 'A[href^="/NPC/"]', - toMarkdown: (el: any) => '@npc(' + el.textContent + ')', }, { name: 'style', toHTML: ({ keywords, content }: any) => '
' + (content || '') + '
', - selector: 'DIV[class]', - toMarkdown: (el: any, convert: any) => '\n\n@style(' + el.className + '\n' + convert.children(el) + '\n)\n\n', }, { name: 'toc', @@ -36,10 +30,12 @@ const M = (html: string) => h.toMarkdown(html); describe('Macros', () => { describe('self-closing', () => { - it('bare name', () => expect(H('hello @user world')).toBe('

hello TestUser world

')); - it('empty parens', () => expect(H('hello @user() world')).toBe('

hello TestUser world

')); - it('keywords', () => expect(H('@npc(Goblin King)')).toBe('

Goblin King

')); - it('params', () => expect(H('@toc(depth="2")')).toContain('data-depth="2"')); + it('bare name renders', () => expect(H('hello @user world')).toContain('TestUser')); + it('bare name wrapped', () => expect(H('hello @user world')).toContain('data-macro="user"')); + it('empty parens', () => expect(H('hello @user() world')).toContain('data-macro="user"')); + it('keywords', () => expect(H('@npc(Goblin King)')).toContain('Goblin King')); + it('keywords in data attr', () => expect(H('@npc(Goblin King)')).toContain('data-keywords="Goblin King"')); + it('params', () => expect(H('@toc(depth="2")')).toContain('data-param-depth="2"')); }); describe('unknown macros', () => { @@ -52,8 +48,8 @@ describe('Macros', () => { describe('block macros', () => { it('content processed', () => expect(H('@style(box\n**bold**\n)')).toContain('bold')); - it('wraps in div', () => expect(H('@style(box\ncontent\n)')).toContain('
')); - it('multiple keywords', () => expect(H('@style(box center\ncontent\n)')).toContain('class="box center"')); + it('wrapped with data-macro', () => expect(H('@style(box\ncontent\n)')).toContain('data-macro="style"')); + it('keywords in data attr', () => expect(H('@style(box center\ncontent\n)')).toContain('data-keywords="box center"')); }); describe('verbatim', () => { @@ -61,23 +57,41 @@ describe('Macros', () => { it('no strong tag', () => expect(H('@style(box verbatim\n**bold**\n)')).not.toContain('')); it('escapes html', () => expect(H('@style(box verbatim\ntag\n)')).toContain('<b>')); it('preserves newlines', () => expect(H('@style(box verbatim\nline1\nline2\n)')).toContain('line1
')); - it('strips keyword', () => expect(H('@style(box verbatim\ncontent\n)')).not.toContain('verbatim')); + it('data-verbatim set', () => expect(H('@style(box verbatim\ncontent\n)')).toContain('data-verbatim="true"')); + it('keyword stripped from data-keywords', () => { + const html = H('@style(box verbatim\ncontent\n)'); + expect(html).toContain('data-keywords="box"'); + expect(html).not.toMatch(/data-keywords="[^"]*verbatim/); + }); }); describe('nesting', () => { - it('inline inside bold', () => expect(H('**@npc(Goblin King)**')).toContain('')); + it('inline inside bold', () => expect(H('**@npc(Goblin King)**')).toContain('')); it('block contains list', () => expect(H('@style(box\n- item 1\n- item 2\n)')).toContain('