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
+50 -1
View File
@@ -12,12 +12,14 @@
import type { Converter, MatchContext, Tag } from './types';
import { defaultBlockTags, defaultInlineTags, defaultTags, escapeHtml, parseListBlock } from './tags';
import { buildMacroTags, processInlineMacros, type MacroDef } from './macros';
export type TagMap = Record<string, Tag>;
export interface HopDownOptions {
tags?: TagMap;
exclude?: string[];
macros?: MacroDef[];
}
/**
@@ -31,6 +33,7 @@ export class HopDown {
private blockTags: Tag[];
private inlineTags: Tag[];
private tags: Map<string, Tag>;
private macroMap: Map<string, MacroDef>;
constructor(options: HopDownOptions = {}) {
let tagMap: TagMap;
@@ -46,14 +49,39 @@ export class HopDown {
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 defaultBlockNames = new Set(Object.values(defaultBlockTags).map(t => t.name));
const defaultInlineNames = new Set(Object.values(defaultInlineTags).map(t => t.name));
this.blockTags = allTags.filter(tag =>
defaultBlockNames.has(tag.name) ||
defaultBlockNames.has(tag.name) || tag.name === 'macro' ||
(!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 =>
defaultInlineNames.has(tag.name) || (tag as any).pattern
);
@@ -181,6 +209,11 @@ export class HopDown {
const placeholders: string[] = [];
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
for (const tag of sorted) {
const recursive = (tag as any).recursive ?? true;
@@ -253,6 +286,22 @@ export class HopDown {
}
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);
if (tag) {
return tag.toMarkdown(element, this.makeConverter());