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 type { Converter, MatchContext, Tag } from './types';
|
||||||
import { defaultBlockTags, defaultInlineTags, defaultTags, escapeHtml, parseListBlock } from './tags';
|
import { defaultBlockTags, defaultInlineTags, defaultTags, escapeHtml, parseListBlock } from './tags';
|
||||||
|
import { buildMacroTags, processInlineMacros, type MacroDef } from './macros';
|
||||||
|
|
||||||
export type TagMap = Record<string, Tag>;
|
export type TagMap = Record<string, Tag>;
|
||||||
|
|
||||||
export interface HopDownOptions {
|
export interface HopDownOptions {
|
||||||
tags?: TagMap;
|
tags?: TagMap;
|
||||||
exclude?: string[];
|
exclude?: string[];
|
||||||
|
macros?: MacroDef[];
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
@ -31,6 +33,7 @@ export class HopDown {
|
||||||
private blockTags: Tag[];
|
private blockTags: Tag[];
|
||||||
private inlineTags: Tag[];
|
private inlineTags: Tag[];
|
||||||
private tags: Map<string, Tag>;
|
private tags: Map<string, Tag>;
|
||||||
|
private macroMap: Map<string, MacroDef>;
|
||||||
|
|
||||||
constructor(options: HopDownOptions = {}) {
|
constructor(options: HopDownOptions = {}) {
|
||||||
let tagMap: TagMap;
|
let tagMap: TagMap;
|
||||||
|
|
@ -46,14 +49,39 @@ export class HopDown {
|
||||||
tagMap = defaultTags;
|
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 allTags = Object.values(tagMap);
|
||||||
const defaultBlockNames = new Set(Object.values(defaultBlockTags).map(t => t.name));
|
const defaultBlockNames = new Set(Object.values(defaultBlockTags).map(t => t.name));
|
||||||
const defaultInlineNames = new Set(Object.values(defaultInlineTags).map(t => t.name));
|
const defaultInlineNames = new Set(Object.values(defaultInlineTags).map(t => t.name));
|
||||||
|
|
||||||
this.blockTags = allTags.filter(tag =>
|
this.blockTags = allTags.filter(tag =>
|
||||||
defaultBlockNames.has(tag.name) ||
|
defaultBlockNames.has(tag.name) || tag.name === 'macro' ||
|
||||||
(!defaultInlineNames.has(tag.name) && !(tag as any).pattern)
|
(!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 =>
|
this.inlineTags = allTags.filter(tag =>
|
||||||
defaultInlineNames.has(tag.name) || (tag as any).pattern
|
defaultInlineNames.has(tag.name) || (tag as any).pattern
|
||||||
);
|
);
|
||||||
|
|
@ -181,6 +209,11 @@ export class HopDown {
|
||||||
const placeholders: string[] = [];
|
const placeholders: string[] = [];
|
||||||
let text = source;
|
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
|
// Pass 1: extract links and non-recursive tags into placeholders before escaping
|
||||||
for (const tag of sorted) {
|
for (const tag of sorted) {
|
||||||
const recursive = (tag as any).recursive ?? true;
|
const recursive = (tag as any).recursive ?? true;
|
||||||
|
|
@ -253,6 +286,22 @@ export class HopDown {
|
||||||
}
|
}
|
||||||
const element = node as HTMLElement;
|
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);
|
const tag = this.tags.get(element.nodeName);
|
||||||
if (tag) {
|
if (tag) {
|
||||||
return tag.toMarkdown(element, this.makeConverter());
|
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.
|
// Public API — accessed as ribbit.Editor, ribbit.HopDown, etc.
|
||||||
export { RibbitEditor as Editor };
|
export { RibbitEditor as Editor };
|
||||||
export { Ribbit as Viewer };
|
export { Ribbit as Viewer };
|
||||||
|
|
@ -111,3 +113,4 @@ export { inlineTag };
|
||||||
export { defaultTags, defaultBlockTags, defaultInlineTags };
|
export { defaultTags, defaultBlockTags, defaultInlineTags };
|
||||||
export { defaultTheme };
|
export { defaultTheme };
|
||||||
export { camelCase, decodeHtmlEntities, encodeHtmlEntities };
|
export { camelCase, decodeHtmlEntities, encodeHtmlEntities };
|
||||||
|
export type { MacroDef };
|
||||||
|
|
|
||||||
|
|
@ -6,6 +6,7 @@ import { HopDown } from './hopdown';
|
||||||
import { defaultTheme } from './default-theme';
|
import { defaultTheme } from './default-theme';
|
||||||
import { ThemeManager } from './theme-manager';
|
import { ThemeManager } from './theme-manager';
|
||||||
import { RibbitEmitter, type RibbitEventMap } from './events';
|
import { RibbitEmitter, type RibbitEventMap } from './events';
|
||||||
|
import { buildMacroTags, type MacroDef } from './macros';
|
||||||
import type { RibbitTheme } from './types';
|
import type { RibbitTheme } from './types';
|
||||||
|
|
||||||
export interface RibbitSettings {
|
export interface RibbitSettings {
|
||||||
|
|
@ -15,6 +16,7 @@ export interface RibbitSettings {
|
||||||
currentTheme?: string;
|
currentTheme?: string;
|
||||||
themes?: RibbitTheme[];
|
themes?: RibbitTheme[];
|
||||||
themesPath?: string;
|
themesPath?: string;
|
||||||
|
macros?: MacroDef[];
|
||||||
on?: Partial<RibbitEventMap>;
|
on?: Partial<RibbitEventMap>;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -71,12 +73,14 @@ export class Ribbit {
|
||||||
converter: HopDown;
|
converter: HopDown;
|
||||||
themesPath: string;
|
themesPath: string;
|
||||||
private emitter: RibbitEmitter;
|
private emitter: RibbitEmitter;
|
||||||
|
private macros: MacroDef[];
|
||||||
|
|
||||||
constructor(settings: RibbitSettings) {
|
constructor(settings: RibbitSettings) {
|
||||||
this.api = settings.api || null;
|
this.api = settings.api || null;
|
||||||
this.element = document.getElementById(settings.editorId || 'ribbit')!;
|
this.element = document.getElementById(settings.editorId || 'ribbit')!;
|
||||||
this.themesPath = settings.themesPath || './themes';
|
this.themesPath = settings.themesPath || './themes';
|
||||||
this.emitter = new RibbitEmitter();
|
this.emitter = new RibbitEmitter();
|
||||||
|
this.macros = settings.macros || [];
|
||||||
this.states = {
|
this.states = {
|
||||||
VIEW: 'view',
|
VIEW: 'view',
|
||||||
};
|
};
|
||||||
|
|
@ -89,8 +93,8 @@ export class Ribbit {
|
||||||
this.themes = new ThemeManager(defaultTheme, this.themesPath, (theme, previous) => {
|
this.themes = new ThemeManager(defaultTheme, this.themesPath, (theme, previous) => {
|
||||||
this.theme = theme;
|
this.theme = theme;
|
||||||
this.converter = theme.tags
|
this.converter = theme.tags
|
||||||
? new HopDown({ tags: theme.tags })
|
? new HopDown({ tags: theme.tags, macros: this.macros })
|
||||||
: new HopDown();
|
: new HopDown({ macros: this.macros });
|
||||||
this.cachedHTML = null;
|
this.cachedHTML = null;
|
||||||
this.emitter.emit('themeChange', {
|
this.emitter.emit('themeChange', {
|
||||||
current: theme,
|
current: theme,
|
||||||
|
|
@ -110,8 +114,8 @@ export class Ribbit {
|
||||||
this.themes.set(activeName);
|
this.themes.set(activeName);
|
||||||
this.theme = this.themes.current();
|
this.theme = this.themes.current();
|
||||||
this.converter = this.theme.tags
|
this.converter = this.theme.tags
|
||||||
? new HopDown({ tags: this.theme.tags })
|
? new HopDown({ tags: this.theme.tags, macros: this.macros })
|
||||||
: new HopDown();
|
: new HopDown({ macros: this.macros });
|
||||||
|
|
||||||
(settings.plugins || []).forEach(plugin => {
|
(settings.plugins || []).forEach(plugin => {
|
||||||
this.enabledPlugins[plugin.name] = new plugin({
|
this.enabledPlugins[plugin.name] = new plugin({
|
||||||
|
|
|
||||||
|
|
@ -407,6 +407,96 @@ try {
|
||||||
}
|
}
|
||||||
eq('invalid precedence throws', String(threw), 'true');
|
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 ─────────────────────────────────────────────
|
// ── Results ─────────────────────────────────────────────
|
||||||
const total = passed + failed;
|
const total = passed + failed;
|
||||||
console.log(`\n${passed}/${total} passed (${Math.round(100 * passed / total)}%) — ${failed} failed`);
|
console.log(`\n${passed}/${total} passed (${Math.round(100 * passed / total)}%) — ${failed} failed`);
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue
Block a user