feat: Add macro support

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.
This commit is contained in:
gsb 2026-04-29 03:03:58 +00:00
parent df49ce7545
commit 86d59877f1
5 changed files with 382 additions and 5 deletions

View File

@ -12,12 +12,14 @@
import type { Converter, MatchContext, Tag } from './types'; import type { Converter, MatchContext, Tag } from './types';
import { defaultBlockTags, defaultInlineTags, defaultTags, escapeHtml, parseListBlock } from './tags'; import { defaultBlockTags, defaultInlineTags, defaultTags, escapeHtml, parseListBlock } from './tags';
import { buildMacroTags, processInlineMacros, type MacroDef } from './macros';
export type TagMap = Record<string, Tag>; export type TagMap = Record<string, Tag>;
export interface HopDownOptions { export interface HopDownOptions {
tags?: TagMap; tags?: TagMap;
exclude?: string[]; exclude?: string[];
macros?: MacroDef[];
} }
/** /**
@ -31,6 +33,7 @@ export class HopDown {
private blockTags: Tag[]; private blockTags: Tag[];
private inlineTags: Tag[]; private inlineTags: Tag[];
private tags: Map<string, Tag>; private tags: Map<string, Tag>;
private macroMap: Map<string, MacroDef>;
constructor(options: HopDownOptions = {}) { constructor(options: HopDownOptions = {}) {
let tagMap: TagMap; let tagMap: TagMap;
@ -46,14 +49,39 @@ export class HopDown {
tagMap = defaultTags; tagMap = defaultTags;
} }
// 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);
this.macroMap = macroMap;
tagMap = {
...tagMap,
...selectorEntries,
};
// Insert macro block tag — will be placed after fencedCode below
tagMap['_macro'] = blockTag;
}
const allTags = Object.values(tagMap); const allTags = Object.values(tagMap);
const defaultBlockNames = new Set(Object.values(defaultBlockTags).map(t => t.name)); const defaultBlockNames = new Set(Object.values(defaultBlockTags).map(t => t.name));
const defaultInlineNames = new Set(Object.values(defaultInlineTags).map(t => t.name)); const defaultInlineNames = new Set(Object.values(defaultInlineTags).map(t => t.name));
this.blockTags = allTags.filter(tag => this.blockTags = allTags.filter(tag =>
defaultBlockNames.has(tag.name) || defaultBlockNames.has(tag.name) || tag.name === 'macro' ||
(!defaultInlineNames.has(tag.name) && !(tag as any).pattern) (!defaultInlineNames.has(tag.name) && !(tag as any).pattern)
); );
// Ensure macro block tag runs after fencedCode but before everything else
this.blockTags.sort((a, b) => {
const order = (t: Tag) => {
if (t.name === 'fencedCode') return 0;
if (t.name === 'macro') return 1;
if (t.name === 'paragraph') return 99;
return 50;
};
return order(a) - order(b);
});
this.inlineTags = allTags.filter(tag => this.inlineTags = allTags.filter(tag =>
defaultInlineNames.has(tag.name) || (tag as any).pattern defaultInlineNames.has(tag.name) || (tag as any).pattern
); );
@ -181,6 +209,11 @@ export class HopDown {
const placeholders: string[] = []; const placeholders: string[] = [];
let text = source; let text = source;
// Extract inline macros before other processing
if (this.macroMap.size > 0) {
text = processInlineMacros(text, this.macroMap, this.makeConverter(), placeholders);
}
// Pass 1: extract links and non-recursive tags into placeholders before escaping // Pass 1: extract links and non-recursive tags into placeholders before escaping
for (const tag of sorted) { for (const tag of sorted) {
const recursive = (tag as any).recursive ?? true; const recursive = (tag as any).recursive ?? true;
@ -253,6 +286,22 @@ export class HopDown {
} }
const element = node as HTMLElement; const element = node as HTMLElement;
// Check CSS selectors first (macro selectors are more specific)
for (const [selector, selectorTag] of this.tags.entries()) {
if (selector.includes('[') || selector.includes('.') || selector.includes('#')) {
// Lowercase only the tag name portion for case-insensitive matching
const normalized = selector.replace(/^[A-Z]+/, s => s.toLowerCase());
try {
if (element.matches(normalized)) {
return selectorTag.toMarkdown(element, this.makeConverter());
}
} catch {
// invalid selector, skip
}
}
}
// Then check by element name
const tag = this.tags.get(element.nodeName); const tag = this.tags.get(element.nodeName);
if (tag) { if (tag) {
return tag.toMarkdown(element, this.makeConverter()); return tag.toMarkdown(element, this.makeConverter());

231
src/ts/macros.ts Normal file
View File

@ -0,0 +1,231 @@
/*
* 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 HTMLmarkdown 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';
});
}

View File

@ -102,6 +102,8 @@ export class RibbitEditor extends Ribbit {
} }
} }
import { type MacroDef } from './macros';
// Public API — accessed as ribbit.Editor, ribbit.HopDown, etc. // Public API — accessed as ribbit.Editor, ribbit.HopDown, etc.
export { RibbitEditor as Editor }; export { RibbitEditor as Editor };
export { Ribbit as Viewer }; export { Ribbit as Viewer };
@ -111,3 +113,4 @@ export { inlineTag };
export { defaultTags, defaultBlockTags, defaultInlineTags }; export { defaultTags, defaultBlockTags, defaultInlineTags };
export { defaultTheme }; export { defaultTheme };
export { camelCase, decodeHtmlEntities, encodeHtmlEntities }; export { camelCase, decodeHtmlEntities, encodeHtmlEntities };
export type { MacroDef };

View File

@ -6,6 +6,7 @@ import { HopDown } from './hopdown';
import { defaultTheme } from './default-theme'; import { defaultTheme } from './default-theme';
import { ThemeManager } from './theme-manager'; import { ThemeManager } from './theme-manager';
import { RibbitEmitter, type RibbitEventMap } from './events'; import { RibbitEmitter, type RibbitEventMap } from './events';
import { buildMacroTags, type MacroDef } from './macros';
import type { RibbitTheme } from './types'; import type { RibbitTheme } from './types';
export interface RibbitSettings { export interface RibbitSettings {
@ -15,6 +16,7 @@ export interface RibbitSettings {
currentTheme?: string; currentTheme?: string;
themes?: RibbitTheme[]; themes?: RibbitTheme[];
themesPath?: string; themesPath?: string;
macros?: MacroDef[];
on?: Partial<RibbitEventMap>; on?: Partial<RibbitEventMap>;
} }
@ -71,12 +73,14 @@ export class Ribbit {
converter: HopDown; converter: HopDown;
themesPath: string; themesPath: string;
private emitter: RibbitEmitter; private emitter: RibbitEmitter;
private macros: MacroDef[];
constructor(settings: RibbitSettings) { constructor(settings: RibbitSettings) {
this.api = settings.api || null; this.api = settings.api || null;
this.element = document.getElementById(settings.editorId || 'ribbit')!; this.element = document.getElementById(settings.editorId || 'ribbit')!;
this.themesPath = settings.themesPath || './themes'; this.themesPath = settings.themesPath || './themes';
this.emitter = new RibbitEmitter(); this.emitter = new RibbitEmitter();
this.macros = settings.macros || [];
this.states = { this.states = {
VIEW: 'view', VIEW: 'view',
}; };
@ -89,8 +93,8 @@ export class Ribbit {
this.themes = new ThemeManager(defaultTheme, this.themesPath, (theme, previous) => { this.themes = new ThemeManager(defaultTheme, this.themesPath, (theme, previous) => {
this.theme = theme; this.theme = theme;
this.converter = theme.tags this.converter = theme.tags
? new HopDown({ tags: theme.tags }) ? new HopDown({ tags: theme.tags, macros: this.macros })
: new HopDown(); : new HopDown({ macros: this.macros });
this.cachedHTML = null; this.cachedHTML = null;
this.emitter.emit('themeChange', { this.emitter.emit('themeChange', {
current: theme, current: theme,
@ -110,8 +114,8 @@ export class Ribbit {
this.themes.set(activeName); this.themes.set(activeName);
this.theme = this.themes.current(); this.theme = this.themes.current();
this.converter = this.theme.tags this.converter = this.theme.tags
? new HopDown({ tags: this.theme.tags }) ? new HopDown({ tags: this.theme.tags, macros: this.macros })
: new HopDown(); : new HopDown({ macros: this.macros });
(settings.plugins || []).forEach(plugin => { (settings.plugins || []).forEach(plugin => {
this.enabledPlugins[plugin.name] = new plugin({ this.enabledPlugins[plugin.name] = new plugin({

View File

@ -407,6 +407,96 @@ try {
} }
eq('invalid precedence throws', String(threw), 'true'); eq('invalid precedence throws', String(threw), 'true');
// ── 24. Macros ──────────────────────────────────────────
const macroConverter = new dom.window.ribbit.HopDown({
macros: [
{
name: 'user',
toHTML: () => '<a href="/user">TestUser</a>',
selector: 'A[href="/user"]',
toMarkdown: () => '@user',
},
{
name: 'npc',
toHTML: ({ keywords }) => {
const name = keywords.join(' ');
const target = name.replace(/ /g, '');
return '<a href="/NPC/' + target + '">' + name + '</a>';
},
selector: 'A[href^="/NPC/"]',
toMarkdown: (el) => '@npc(' + el.textContent + ')',
},
{
name: 'toc',
toHTML: ({ params }) =>
'<aside class="toc" data-depth="' + (params.depth || '3') + '"></aside>',
},
{
name: 'style',
toHTML: ({ keywords, content }) => {
const classes = keywords.join(' ');
return '<div class="' + classes + '">' + (content || '') + '</div>';
},
selector: 'DIV[class]',
toMarkdown: (el, convert) => {
return '\n\n@style(' + el.className + '\n' + convert.children(el) + '\n)\n\n';
},
},
],
});
const MH = macroConverter.toHTML.bind(macroConverter);
const MM = macroConverter.toMarkdown.bind(macroConverter);
function mrt(md) { return MM(MH(md)); }
// Self-closing macros
eq('macro: bare name', MH('hello @user world'), '<p>hello <a href="/user">TestUser</a> world</p>');
eq('macro: empty parens', MH('hello @user() world'), '<p>hello <a href="/user">TestUser</a> world</p>');
eq('macro: with keywords', MH('@npc(Goblin King)'), '<p><a href="/NPC/GoblinKing">Goblin King</a></p>');
has('macro: with params', MH('@toc(depth="2")'), 'data-depth="2"');
// Unknown macro — error
has('macro: unknown renders error', MH('@bogus'), 'ribbit-error');
has('macro: unknown shows name', MH('@bogus'), '@bogus');
// Email addresses not matched
eq('macro: email not matched', MH('user@example.com'), '<p>user@example.com</p>');
// Block macros
has('macro: block content processed', MH('@style(box\n**bold** inside\n)'), '<strong>bold</strong>');
has('macro: block wraps in div', MH('@style(box\ncontent\n)'), '<div class="box">');
has('macro: block multiple keywords', MH('@style(box center\ncontent\n)'), 'class="box center"');
// Verbatim
has('macro: verbatim skips markdown', MH('@style(box verbatim\n**bold**\n)'), '**bold**');
not('macro: verbatim no strong', MH('@style(box verbatim\n**bold**\n)'), '<strong>');
has('macro: verbatim escapes html', MH('@style(box verbatim\n<b>tag</b>\n)'), '&lt;b&gt;');
has('macro: verbatim preserves newlines', MH('@style(box verbatim\nline1\nline2\n)'), 'line1<br>');
not('macro: verbatim keyword stripped', MH('@style(box verbatim\ncontent\n)'), 'verbatim');
// Nesting
has('macro: inline inside bold', MH('**@npc(Goblin King)**'), '<strong><a href="/NPC/GoblinKing">');
has('macro: block contains list', MH('@style(box\n- item 1\n- item 2\n)'), '<ul>');
has('macro: block contains heading', MH('@style(box\n## Title\n)'), '<h2');
has('macro: inline inside block', MH('@style(box\nhello @user world\n)'), '<a href="/user">TestUser</a>');
// Inside other elements
has('macro: in list item', MH('- @npc(Goblin King)'), '<a href="/NPC/GoblinKing">');
has('macro: in heading', MH('## @npc(Goblin King)'), '<a href="/NPC/GoblinKing">');
// Fenced code protection
not('macro: not in code block', MH('```\n@user\n```'), '<a href="/user">');
has('macro: literal in code block', MH('```\n@user\n```'), '@user');
not('macro: not in inline code', MH('`@user`'), '<a href="/user">');
// Edge cases
has('macro: multiple inline', MH('@npc(Alice) and @npc(Bob)'), 'Alice');
has('macro: multiple inline second', MH('@npc(Alice) and @npc(Bob)'), 'Bob');
has('macro: unknown block renders error', MH('@bogus(args\ncontent\n)'), 'ribbit-error');
// Round-trips
eq('macro: npc round-trip', mrt('@npc(Goblin King)'), '@npc(Goblin King)');
eq('macro: user round-trip', mrt('hello @user world'), 'hello @user world');
// ── Results ───────────────────────────────────────────── // ── Results ─────────────────────────────────────────────
const total = passed + failed; const total = passed + failed;
console.log(`\n${passed}/${total} passed (${Math.round(100 * passed / total)}%) — ${failed} failed`); console.log(`\n${passed}/${total} passed (${Math.round(100 * passed / total)}%) — ${failed} failed`);