/* * 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. * * Syntax: * @user — bare, no args * @user() — empty parens, same as bare * @npc(Goblin King) — self-closing with keywords * @toc(depth="3") — self-closing with params * @style(box center — block: newline after args = content * **Bold** content here. * ) * @style(box verbatim — verbatim block * Literal content. * ) */ import type { Tag, SourceToken, Converter, MatchContext } from './types'; import { escapeHtml } from './tags'; export interface MacroDef { name: string; /** * Render the macro to HTML. * * { name: 'npc', toHTML: ({ keywords }) => { * const name = keywords.join(' '); * return `${name}`; * }} */ toHTML: (context: { keywords: string[]; params: Record; 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 { name: string; keywords: string[]; params: Record; verbatim: boolean; content?: string; consumed: number; } const PARAM_PATTERN = /(\w+)="([^"]*)"/g; function parseArgs(argsStr: string | undefined): { keywords: string[]; params: Record; verbatim: boolean; } { if (!argsStr || !argsStr.trim()) { return { keywords: [], params: {}, verbatim: false }; } const params: Record = {}; const withoutParams = argsStr.replace(new RegExp(PARAM_PATTERN.source, 'g'), (_, key, val) => { params[key] = val; return ''; }); const allKeywords = withoutParams.trim().split(/\s+/).filter(Boolean); const verbatim = allKeywords.includes('verbatim'); const keywords = allKeywords.filter(k => k !== 'verbatim'); return { keywords, params, verbatim }; } function macroError(name: string): string { return `Unknown macro: @${escapeHtml(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]; const m = line.match(/^@(\w+)\(([^)]*)\s*$/); if (!m) { return null; } const name = m[1]; const { keywords, params, verbatim } = parseArgs(m[2]); const contentLines: string[] = []; let i = index + 1; let depth = 1; while (i < lines.length && depth > 0) { if (/^\)\s*$/.test(lines[i])) { depth--; if (depth === 0) { break; } } if (/^@\w+\([^)]*\s*$/.test(lines[i])) { depth++; } contentLines.push(lines[i]); i++; } if (depth !== 0) { return null; } return { name, keywords, params, verbatim, content: contentLines.join('\n'), consumed: i + 1 - index, }; } /** * 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; /** * Build Tags from an array of macro definitions. */ export function buildMacroTags( macros: MacroDef[], ): { blockTag: Tag; selectorEntries: Record; macroMap: Map } { const macroMap = new Map(); for (const macro of macros) { macroMap.set(macro.name, macro); } const blockTag: Tag = { /* * @name(args * content * ) */ name: 'macro', match: (context) => { const parsed = parseBlockMacro(context.lines, context.index); if (!parsed) { return null; } return { content: parsed.content || '', raw: JSON.stringify(parsed), consumed: parsed.consumed, }; }, toHTML: (token, convert) => { const parsed: ParsedMacro = JSON.parse(token.raw); const macro = macroMap.get(parsed.name); if (!macro) { return macroError(parsed.name); } let content = parsed.content; if (content !== undefined) { if (parsed.verbatim) { content = escapeHtml(content.trim()).replace(/\n/g, '
\n'); } else { content = convert.block(content); } } return macro.toHTML({ keywords: parsed.keywords, params: parsed.params, content, convert, }); }, 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), }; } } return { blockTag, selectorEntries, 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, macroMap: Map, convert: Converter, placeholders: string[], ): string { return text.replace(INLINE_MACRO_GLOBAL, (match, nameStr: string, argsStr: string | undefined) => { const macro = macroMap.get(nameStr); if (!macro) { placeholders.push(macroError(nameStr)); return '\x00P' + (placeholders.length - 1) + '\x00'; } const { keywords, params } = parseArgs(argsStr); const html = macro.toHTML({ keywords, params, convert, }); placeholders.push(html); return '\x00P' + (placeholders.length - 1) + '\x00'; }); }