New: macros.ts with MacroDef, parseBlockMacro, matchInlineMacro, buildMacroTags, processInlineMacros. Macro syntax: @user — bare, no args @user() — empty parens, same as bare @npc(Goblin King) — self-closing with args @style(box center — block: no closing paren on first line Content here. — content on subsequent lines ) — closing paren on its own line Unknown macro names now render as an error: <span class="ribbit-error">Unknown macro: @bogus</span> The verbatim keyword causes the contents to render as literals and also preserves line breaks.
232 lines
6.9 KiB
TypeScript
232 lines
6.9 KiB
TypeScript
/*
|
|
* 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 <b>content</b>.
|
|
* )
|
|
*/
|
|
|
|
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 `<a href="/NPC/${name}">${name}</a>`;
|
|
* }}
|
|
*/
|
|
toHTML: (context: {
|
|
keywords: string[];
|
|
params: Record<string, string>;
|
|
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<string, string>;
|
|
verbatim: boolean;
|
|
content?: string;
|
|
consumed: number;
|
|
}
|
|
|
|
const PARAM_PATTERN = /(\w+)="([^"]*)"/g;
|
|
|
|
function parseArgs(argsStr: string | undefined): {
|
|
keywords: string[];
|
|
params: Record<string, string>;
|
|
verbatim: boolean;
|
|
} {
|
|
if (!argsStr || !argsStr.trim()) {
|
|
return { keywords: [], params: {}, verbatim: false };
|
|
}
|
|
const params: Record<string, string> = {};
|
|
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 `<span class="ribbit-error">Unknown macro: @${escapeHtml(name)}</span>`;
|
|
}
|
|
|
|
/**
|
|
* 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<string, Tag>; macroMap: Map<string, MacroDef> } {
|
|
const macroMap = new Map<string, MacroDef>();
|
|
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, '<br>\n');
|
|
} else {
|
|
content = convert.block(content);
|
|
}
|
|
}
|
|
return macro.toHTML({
|
|
keywords: parsed.keywords,
|
|
params: parsed.params,
|
|
content,
|
|
convert,
|
|
});
|
|
},
|
|
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),
|
|
};
|
|
}
|
|
}
|
|
|
|
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<string, MacroDef>,
|
|
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';
|
|
});
|
|
}
|