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:
parent
df49ce7545
commit
86d59877f1
|
|
@ -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());
|
||||
|
|
|
|||
231
src/ts/macros.ts
Normal file
231
src/ts/macros.ts
Normal 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 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';
|
||||
});
|
||||
}
|
||||
|
|
@ -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 };
|
||||
|
|
|
|||
|
|
@ -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<RibbitEventMap>;
|
||||
}
|
||||
|
||||
|
|
@ -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({
|
||||
|
|
|
|||
|
|
@ -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: () => '<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)'), '<b>');
|
||||
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 ─────────────────────────────────────────────
|
||||
const total = passed + failed;
|
||||
console.log(`\n${passed}/${total} passed (${Math.round(100 * passed / total)}%) — ${failed} failed`);
|
||||
|
|
|
|||
Loading…
Reference in New Issue
Block a user