= {};
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)}`;
}
/**
* 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}${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.
*/
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,
};
}
const INLINE_MACRO_GLOBAL = /(?:^|(?<=[\s*_(>|]))@(\w+)(?:\(([^)]*)\))?/g;
/**
* Build Tags from an array of macro definitions.
*/
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(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);
}
}
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 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, selectorTag, macroMap };
}
/**
* Process inline macros in a text string, replacing them with rendered HTML.
*/
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 innerHtml = macro.toHTML({
keywords,
params,
convert,
});
const wrapped = wrapMacro(nameStr, keywords, params, false, false, innerHtml);
placeholders.push(wrapped);
return '\x00P' + (placeholders.length - 1) + '\x00';
});
}