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());
|
||||
|
||||
@@ -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 };
|
||||
|
||||
+8
-4
@@ -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({
|
||||
|
||||
Reference in New Issue
Block a user