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:
+50
-1
@@ -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());
|
||||
|
||||
Reference in New Issue
Block a user