Use data- attributes to preserve macro configs

This commit is contained in:
gsb
2026-04-29 05:19:17 +00:00
parent 2b88d2c10b
commit 98719ec8cd
3 changed files with 121 additions and 70 deletions
+2 -6
View File
@@ -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;
}
+85 -44
View File
@@ -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 `<a href="/NPC/${name}">${name}</a>`;
* }}
* { name: 'user', toHTML: () => '<a href="/User/gsb">gsb</a>' }
* { name: 'style', toHTML: ({ keywords, content }) =>
* `<div class="${keywords.join(' ')}">${content}</div>` }
*/
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 `<span class="ribbit-error">Unknown macro: @${escapeHtml(name)}</span>`;
}
/**
* Wrap a macro's rendered HTML with data- attributes for round-tripping.
* Block macros (with content) use <div>, inline macros use <span>.
*/
function wrapMacro(
name: string,
keywords: string[],
params: Record<string, string>,
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}</${tag}>`;
}
/**
* 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<string, Tag>; macroMap: Map<string, MacroDef> } {
): { blockTag: Tag; selectorTag: Tag; macroMap: Map<string, MacroDef> } {
const macroMap = new Map<string, MacroDef>();
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<string, Tag> = {};
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';
});
}