/* * macros.ts — macro parsing and Tag generation for ribbit. * * 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 * @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, Converter, ToolbarButton } from './types'; import { escapeHtml } from './tags'; /* ── Constants ─────────────────────────────────────────────────── */ const VERBATIM_KEYWORD = 'verbatim'; const VERBATIM_DATA_VALUE = 'true'; const DATASET_PARAM_PREFIX = 'param'; const DATASET_PARAM_PREFIX_LENGTH = 5; const PLACEHOLDER_SENTINEL = '\x00P'; const PLACEHOLDER_TERMINATOR = '\x00'; /* Named regex for key="value" pairs inside macro argument strings */ const PARAM_PATTERN = /(?\w+)="(?[^"]*)"/g; /* Matches the opening line of a block macro: @name(args with no closing paren */ const BLOCK_MACRO_OPEN = /^@(?\w+)\((?[^)]*)\s*$/; /* Matches a line that closes a block macro body */ const BLOCK_CLOSE_LINE = /^\)\s*$/; /* Matches a nested block macro opening inside a body */ const NESTED_BLOCK_OPEN = /^@\w+\([^)]*\s*$/; /** * Matches inline macros: `@name` or `@name(args)`. * The lookbehind ensures macros only start after whitespace or * markdown punctuation, preventing false matches mid-word. * * Named groups: * inlineName — the macro name after @ * inlineArgs — optional parenthesized arguments */ const INLINE_MACRO_GLOBAL = /(?:^|(?<=[\s*_(>|]))@(?\w+)(?:\((?[^)]*)\))?/g; /* ── Public interfaces ─────────────────────────────────────────── */ /** * Definition for a macro that can be registered with ribbit. * * Each macro provides a name and a `toHTML` renderer. Ribbit handles * wrapping, round-tripping, and toolbar integration automatically. * * @example * ```ts * const userMacro: MacroDef = { * name: 'user', * toHTML: () => 'gsb', * }; * ``` * * @example * ```ts * const styleMacro: MacroDef = { * name: 'style', * toHTML: ({ keywords, content }) => * `
${content}
`, * }; * ``` */ export interface MacroDef { name: string; /** * Render the macro's inner HTML. Ribbit wraps the result in an * element with data- attributes for round-tripping. * * { name: 'user', toHTML: () => 'gsb' } * { name: 'style', toHTML: ({ keywords, content }) => * `
${content}
` } */ toHTML: (context: { keywords: string[]; params: Record; content?: string; convert: Converter; }) => string; /** * Toolbar button. Set to false to hide from the macros dropdown. * Default: auto-generated from the macro name. */ button?: ToolbarButton | false; } /** Internal representation of a fully parsed macro invocation. */ interface ParsedMacro { name: string; keywords: string[]; params: Record; verbatim: boolean; content?: string; /** Number of source lines consumed by this macro (for block advancement). */ consumed: number; } /* ── Module-level helpers ──────────────────────────────────────── */ /** * Parse the argument string from a macro invocation into keywords, * key="value" params, and a verbatim flag. * * @example * ```ts * parseArgs('box center depth="3"') * // { keywords: ['box', 'center'], params: { depth: '3' }, verbatim: false } * ``` */ function parseArgs(argumentString: string | undefined): { keywords: string[]; params: Record; verbatim: boolean; } { if (!argumentString || !argumentString.trim()) { return { keywords: [], params: {}, verbatim: false, }; } const params: Record = {}; /* Strip key="value" pairs, collecting them into params */ const withoutParams = argumentString.replace( new RegExp(PARAM_PATTERN.source, 'g'), (_match, paramKey, paramValue) => { params[paramKey] = paramValue; return ''; }, ); const allKeywords = withoutParams.trim().split(/\s+/).filter(Boolean); const verbatim = allKeywords.includes(VERBATIM_KEYWORD); const keywords = allKeywords.filter(keyword => keyword !== VERBATIM_KEYWORD); return { keywords, params, verbatim, }; } 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 [paramKey, paramValue] of Object.entries(params)) { attrs += ` data-param-${escapeHtml(paramKey)}="${escapeHtml(paramValue)}"`; } if (verbatim) { attrs += ` data-verbatim="${VERBATIM_DATA_VALUE}"`; } return `<${tag}${attrs}>${innerHtml}`; } /** * Reconstruct macro source from a DOM element's data- attributes. * This is the generic toMarkdown for all macros — it reads the * data- attributes that wrapMacro wrote and rebuilds the @name(...) * syntax so the document can round-trip without per-macro logic. */ function macroToMarkdown(element: HTMLElement, convert: Converter): string { const name = element.dataset.macro || ''; const keywords = element.dataset.keywords || ''; const verbatim = element.dataset.verbatim === VERBATIM_DATA_VALUE; const paramParts: string[] = []; for (const [datasetKey, datasetValue] of Object.entries(element.dataset)) { if (datasetKey.startsWith(DATASET_PARAM_PREFIX) && datasetKey.length > DATASET_PARAM_PREFIX_LENGTH) { const paramName = datasetKey.slice(DATASET_PARAM_PREFIX_LENGTH).toLowerCase(); paramParts.push(`${paramName}="${datasetValue}"`); } } const allKeywords = verbatim ? [keywords, VERBATIM_KEYWORD].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. * Returns null if the line doesn't start a block macro or the * closing paren is never found (unclosed macro). */ function parseBlockMacro(lines: string[], lineIndex: number): ParsedMacro | null { const line = lines[lineIndex]; const openMatch = BLOCK_MACRO_OPEN.exec(line); if (!openMatch || !openMatch.groups) { return null; } const name = openMatch.groups.macroName; const { keywords, params, verbatim } = parseArgs(openMatch.groups.macroArgs); const contentLines: string[] = []; let scanIndex = lineIndex + 1; let nestingDepth = 1; while (scanIndex < lines.length && nestingDepth > 0) { if (BLOCK_CLOSE_LINE.test(lines[scanIndex])) { nestingDepth--; if (nestingDepth === 0) { break; } } if (NESTED_BLOCK_OPEN.test(lines[scanIndex])) { nestingDepth++; } contentLines.push(lines[scanIndex]); scanIndex++; } /* Unclosed macro — treat as plain text */ if (nestingDepth !== 0) { return null; } return { name, keywords, params, verbatim, content: contentLines.join('\n'), consumed: scanIndex + 1 - lineIndex, }; } /* ── Public API ────────────────────────────────────────────────── */ /** * Build Tags from an array of macro definitions. * * Returns a block-level Tag for parsing `@name(args\ncontent\n)` syntax, * a selector Tag for HTML→markdown round-tripping, and a lookup map * for inline macro processing. * * @example * ```ts * const { blockTag, selectorTag, macroMap } = buildMacroTags([ * { name: 'user', toHTML: () => 'gsb' }, * ]); * ``` */ export function buildMacroTags( macros: MacroDef[], ): { blockTag: Tag; selectorTag: Tag; macroMap: Map } { const macroMap = new Map(); for (const macro of macros) { macroMap.set(macro.name, macro); } const blockTag: Tag = { 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); } } 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: () => '', }; /** * Generic selector tag — matches any element with data-macro * and reconstructs the macro source from data- attributes. * Separate from blockTag so the selector-based HTML→markdown * path can find macro elements independently. */ const selectorTag: Tag = { name: 'macro:generic', match: () => null, toHTML: () => '', selector: '[data-macro]', toMarkdown: macroToMarkdown, }; return { blockTag, selectorTag, macroMap, }; } /** * Process inline macros in a text string, replacing them with rendered HTML. * * Inline macros are replaced with placeholder tokens so that subsequent * inline parsing (bold, italic, etc.) doesn't mangle the HTML output. * The caller restores placeholders after all inline processing is done. * * @example * ```ts * const placeholders: string[] = []; * const result = processInlineMacros( * 'Hello @user!', * macroMap, * convert, * placeholders, * ); * ``` */ export function processInlineMacros( text: string, macroMap: Map, convert: Converter, placeholders: string[], ): string { return text.replace( INLINE_MACRO_GLOBAL, (match, ...args) => { /* Named groups are the last non-offset argument from replace() */ const groups = args[args.length - 1] as { inlineName: string; inlineArgs?: string }; const macroName = groups.inlineName; const macro = macroMap.get(macroName); if (!macro) { placeholders.push(macroError(macroName)); return PLACEHOLDER_SENTINEL + (placeholders.length - 1) + PLACEHOLDER_TERMINATOR; } const { keywords, params } = parseArgs(groups.inlineArgs); const innerHtml = macro.toHTML({ keywords, params, convert, }); const wrapped = wrapMacro(macroName, keywords, params, false, false, innerHtml); placeholders.push(wrapped); return PLACEHOLDER_SENTINEL + (placeholders.length - 1) + PLACEHOLDER_TERMINATOR; }, ); }