From 86d59877f10c18354b34ab429c28af0b18e3ee26 Mon Sep 17 00:00:00 2001 From: gsb Date: Wed, 29 Apr 2026 03:03:58 +0000 Subject: [PATCH] feat: Add macro support MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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: Unknown macro: @bogus The verbatim keyword causes the contents to render as literals and also preserves line breaks. --- src/ts/hopdown.ts | 51 ++++++++- src/ts/macros.ts | 231 ++++++++++++++++++++++++++++++++++++++++ src/ts/ribbit-editor.ts | 3 + src/ts/ribbit.ts | 12 ++- test/test_hopdown.js | 90 ++++++++++++++++ 5 files changed, 382 insertions(+), 5 deletions(-) create mode 100644 src/ts/macros.ts diff --git a/src/ts/hopdown.ts b/src/ts/hopdown.ts index 00d7524..1fe9b66 100644 --- a/src/ts/hopdown.ts +++ b/src/ts/hopdown.ts @@ -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; 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; + private macroMap: Map; 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()); diff --git a/src/ts/macros.ts b/src/ts/macros.ts new file mode 100644 index 0000000..1c95dd6 --- /dev/null +++ b/src/ts/macros.ts @@ -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 content. + * ) + */ + +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 `${name}`; + * }} + */ + toHTML: (context: { + keywords: string[]; + params: Record; + 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; + verbatim: boolean; + content?: string; + consumed: number; +} + +const PARAM_PATTERN = /(\w+)="([^"]*)"/g; + +function parseArgs(argsStr: string | undefined): { + keywords: string[]; + params: Record; + verbatim: boolean; +} { + if (!argsStr || !argsStr.trim()) { + return { keywords: [], params: {}, verbatim: false }; + } + const params: Record = {}; + 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)}`; +} + +/** + * 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; 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); + } + } + return macro.toHTML({ + keywords: parsed.keywords, + params: parsed.params, + content, + convert, + }); + }, + selector: '[data-macro]', + toMarkdown: () => '', + }; + + const selectorEntries: Record = {}; + 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, + 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'; + }); +} diff --git a/src/ts/ribbit-editor.ts b/src/ts/ribbit-editor.ts index 1b65381..7b38e26 100644 --- a/src/ts/ribbit-editor.ts +++ b/src/ts/ribbit-editor.ts @@ -102,6 +102,8 @@ export class RibbitEditor extends Ribbit { } } +import { type MacroDef } from './macros'; + // Public API — accessed as ribbit.Editor, ribbit.HopDown, etc. export { RibbitEditor as Editor }; export { Ribbit as Viewer }; @@ -111,3 +113,4 @@ export { inlineTag }; export { defaultTags, defaultBlockTags, defaultInlineTags }; export { defaultTheme }; export { camelCase, decodeHtmlEntities, encodeHtmlEntities }; +export type { MacroDef }; diff --git a/src/ts/ribbit.ts b/src/ts/ribbit.ts index f56b23b..57f98b0 100644 --- a/src/ts/ribbit.ts +++ b/src/ts/ribbit.ts @@ -6,6 +6,7 @@ import { HopDown } from './hopdown'; import { defaultTheme } from './default-theme'; import { ThemeManager } from './theme-manager'; import { RibbitEmitter, type RibbitEventMap } from './events'; +import { buildMacroTags, type MacroDef } from './macros'; import type { RibbitTheme } from './types'; export interface RibbitSettings { @@ -15,6 +16,7 @@ export interface RibbitSettings { currentTheme?: string; themes?: RibbitTheme[]; themesPath?: string; + macros?: MacroDef[]; on?: Partial; } @@ -71,12 +73,14 @@ export class Ribbit { converter: HopDown; themesPath: string; private emitter: RibbitEmitter; + private macros: MacroDef[]; constructor(settings: RibbitSettings) { this.api = settings.api || null; this.element = document.getElementById(settings.editorId || 'ribbit')!; this.themesPath = settings.themesPath || './themes'; this.emitter = new RibbitEmitter(); + this.macros = settings.macros || []; this.states = { VIEW: 'view', }; @@ -89,8 +93,8 @@ export class Ribbit { this.themes = new ThemeManager(defaultTheme, this.themesPath, (theme, previous) => { this.theme = theme; this.converter = theme.tags - ? new HopDown({ tags: theme.tags }) - : new HopDown(); + ? new HopDown({ tags: theme.tags, macros: this.macros }) + : new HopDown({ macros: this.macros }); this.cachedHTML = null; this.emitter.emit('themeChange', { current: theme, @@ -110,8 +114,8 @@ export class Ribbit { this.themes.set(activeName); this.theme = this.themes.current(); this.converter = this.theme.tags - ? new HopDown({ tags: this.theme.tags }) - : new HopDown(); + ? new HopDown({ tags: this.theme.tags, macros: this.macros }) + : new HopDown({ macros: this.macros }); (settings.plugins || []).forEach(plugin => { this.enabledPlugins[plugin.name] = new plugin({ diff --git a/test/test_hopdown.js b/test/test_hopdown.js index bb92a8e..cfaf87b 100644 --- a/test/test_hopdown.js +++ b/test/test_hopdown.js @@ -407,6 +407,96 @@ try { } eq('invalid precedence throws', String(threw), 'true'); +// ── 24. Macros ────────────────────────────────────────── +const macroConverter = new dom.window.ribbit.HopDown({ + macros: [ + { + name: 'user', + toHTML: () => 'TestUser', + selector: 'A[href="/user"]', + toMarkdown: () => '@user', + }, + { + name: 'npc', + toHTML: ({ keywords }) => { + const name = keywords.join(' '); + const target = name.replace(/ /g, ''); + return '' + name + ''; + }, + selector: 'A[href^="/NPC/"]', + toMarkdown: (el) => '@npc(' + el.textContent + ')', + }, + { + name: 'toc', + toHTML: ({ params }) => + '', + }, + { + name: 'style', + toHTML: ({ keywords, content }) => { + const classes = keywords.join(' '); + return '
' + (content || '') + '
'; + }, + 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'), '

hello TestUser world

'); +eq('macro: empty parens', MH('hello @user() world'), '

hello TestUser world

'); +eq('macro: with keywords', MH('@npc(Goblin King)'), '

Goblin King

'); +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'), '

user@example.com

'); + +// Block macros +has('macro: block content processed', MH('@style(box\n**bold** inside\n)'), 'bold'); +has('macro: block wraps in div', MH('@style(box\ncontent\n)'), '
'); +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)'), ''); +has('macro: verbatim escapes html', MH('@style(box verbatim\ntag\n)'), '<b>'); +has('macro: verbatim preserves newlines', MH('@style(box verbatim\nline1\nline2\n)'), 'line1
'); +not('macro: verbatim keyword stripped', MH('@style(box verbatim\ncontent\n)'), 'verbatim'); + +// Nesting +has('macro: inline inside bold', MH('**@npc(Goblin King)**'), ''); +has('macro: block contains list', MH('@style(box\n- item 1\n- item 2\n)'), '