Use data- attributes to preserve macro configs
This commit is contained in:
parent
2b88d2c10b
commit
98719ec8cd
|
|
@ -52,13 +52,9 @@ export class HopDown {
|
|||
// 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);
|
||||
const { blockTag, selectorTag, macroMap } = buildMacroTags(options.macros);
|
||||
this.macroMap = macroMap;
|
||||
tagMap = {
|
||||
...tagMap,
|
||||
...selectorEntries,
|
||||
};
|
||||
// Insert macro block tag — will be placed after fencedCode below
|
||||
tagMap['[data-macro]'] = selectorTag;
|
||||
tagMap['_macro'] = blockTag;
|
||||
}
|
||||
|
||||
|
|
|
|||
123
src/ts/macros.ts
123
src/ts/macros.ts
|
|
@ -1,9 +1,9 @@
|
|||
/*
|
||||
* 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.
|
||||
* Macros use @name(...) syntax. Ribbit automatically wraps macro output
|
||||
* in an element with data- attributes that preserve the original source.
|
||||
* Round-tripping is handled generically — consumers only write toHTML.
|
||||
*
|
||||
* Syntax:
|
||||
* @user — bare, no args
|
||||
|
|
@ -24,12 +24,12 @@ import { escapeHtml } from './tags';
|
|||
export interface MacroDef {
|
||||
name: string;
|
||||
/**
|
||||
* Render the macro to HTML.
|
||||
* Render the macro's inner HTML. Ribbit wraps the result in an
|
||||
* element with data- attributes for round-tripping.
|
||||
*
|
||||
* { name: 'npc', toHTML: ({ keywords }) => {
|
||||
* const name = keywords.join(' ');
|
||||
* return `<a href="/NPC/${name}">${name}</a>`;
|
||||
* }}
|
||||
* { name: 'user', toHTML: () => '<a href="/User/gsb">gsb</a>' }
|
||||
* { name: 'style', toHTML: ({ keywords, content }) =>
|
||||
* `<div class="${keywords.join(' ')}">${content}</div>` }
|
||||
*/
|
||||
toHTML: (context: {
|
||||
keywords: string[];
|
||||
|
|
@ -37,17 +37,6 @@ export interface MacroDef {
|
|||
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 {
|
||||
|
|
@ -84,10 +73,64 @@ function macroError(name: string): string {
|
|||
return `<span class="ribbit-error">Unknown macro: @${escapeHtml(name)}</span>`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Wrap a macro's rendered HTML with data- attributes for round-tripping.
|
||||
* Block macros (with content) use <div>, inline macros use <span>.
|
||||
*/
|
||||
function wrapMacro(
|
||||
name: string,
|
||||
keywords: string[],
|
||||
params: Record<string, string>,
|
||||
verbatim: boolean,
|
||||
hasContent: boolean,
|
||||
innerHtml: string,
|
||||
): string {
|
||||
const tag = hasContent ? 'div' : 'span';
|
||||
let attrs = ` data-macro="${escapeHtml(name)}"`;
|
||||
if (keywords.length) {
|
||||
attrs += ` data-keywords="${escapeHtml(keywords.join(' '))}"`;
|
||||
}
|
||||
for (const [key, val] of Object.entries(params)) {
|
||||
attrs += ` data-param-${escapeHtml(key)}="${escapeHtml(val)}"`;
|
||||
}
|
||||
if (verbatim) {
|
||||
attrs += ` data-verbatim="true"`;
|
||||
}
|
||||
return `<${tag}${attrs}>${innerHtml}</${tag}>`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Reconstruct macro source from a DOM element's data- attributes.
|
||||
* This is the generic toMarkdown for all macros.
|
||||
*/
|
||||
function macroToMarkdown(element: HTMLElement, convert: Converter): string {
|
||||
const name = element.dataset.macro || '';
|
||||
const keywords = element.dataset.keywords || '';
|
||||
const verbatim = element.dataset.verbatim === 'true';
|
||||
|
||||
const paramParts: string[] = [];
|
||||
for (const [key, val] of Object.entries(element.dataset)) {
|
||||
if (key.startsWith('param') && key.length > 5) {
|
||||
const paramName = key.slice(5).toLowerCase();
|
||||
paramParts.push(`${paramName}="${val}"`);
|
||||
}
|
||||
}
|
||||
|
||||
const allKeywords = verbatim
|
||||
? [keywords, 'verbatim'].filter(Boolean).join(' ')
|
||||
: keywords;
|
||||
const args = [allKeywords, paramParts.join(' ')].filter(Boolean).join(' ');
|
||||
|
||||
const isBlock = element.tagName === 'DIV';
|
||||
if (isBlock) {
|
||||
const content = convert.children(element);
|
||||
return `\n\n@${name}(${args}\n${content}\n)\n\n`;
|
||||
}
|
||||
return args ? `@${name}(${args})` : `@${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];
|
||||
|
|
@ -126,10 +169,6 @@ function parseBlockMacro(lines: string[], index: number): ParsedMacro | null {
|
|||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 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;
|
||||
|
||||
/**
|
||||
|
|
@ -137,7 +176,7 @@ const INLINE_MACRO_GLOBAL = /(?:^|(?<=[\s*_(>|]))@(\w+)(?:\(([^)]*)\))?/g;
|
|||
*/
|
||||
export function buildMacroTags(
|
||||
macros: MacroDef[],
|
||||
): { blockTag: Tag; selectorEntries: Record<string, Tag>; macroMap: Map<string, MacroDef> } {
|
||||
): { blockTag: Tag; selectorTag: Tag; macroMap: Map<string, MacroDef> } {
|
||||
const macroMap = new Map<string, MacroDef>();
|
||||
for (const macro of macros) {
|
||||
macroMap.set(macro.name, macro);
|
||||
|
|
@ -175,37 +214,38 @@ export function buildMacroTags(
|
|||
content = convert.block(content);
|
||||
}
|
||||
}
|
||||
return macro.toHTML({
|
||||
const innerHtml = macro.toHTML({
|
||||
keywords: parsed.keywords,
|
||||
params: parsed.params,
|
||||
content,
|
||||
convert,
|
||||
});
|
||||
return wrapMacro(
|
||||
parsed.name, parsed.keywords, parsed.params,
|
||||
parsed.verbatim, true, innerHtml,
|
||||
);
|
||||
},
|
||||
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}`,
|
||||
/**
|
||||
* Generic selector tag that matches any element with data-macro
|
||||
* and reconstructs the macro source from data- attributes.
|
||||
*/
|
||||
const selectorTag: Tag = {
|
||||
name: 'macro:generic',
|
||||
match: () => null,
|
||||
toHTML: () => '',
|
||||
selector: macro.selector,
|
||||
toMarkdown: (element, convert) => macroCopy.toMarkdown!(element, convert),
|
||||
selector: '[data-macro]',
|
||||
toMarkdown: macroToMarkdown,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
return { blockTag, selectorEntries, macroMap };
|
||||
return { blockTag, selectorTag, 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,
|
||||
|
|
@ -220,12 +260,13 @@ export function processInlineMacros(
|
|||
return '\x00P' + (placeholders.length - 1) + '\x00';
|
||||
}
|
||||
const { keywords, params } = parseArgs(argsStr);
|
||||
const html = macro.toHTML({
|
||||
const innerHtml = macro.toHTML({
|
||||
keywords,
|
||||
params,
|
||||
convert,
|
||||
});
|
||||
placeholders.push(html);
|
||||
const wrapped = wrapMacro(nameStr, keywords, params, false, false, innerHtml);
|
||||
placeholders.push(wrapped);
|
||||
return '\x00P' + (placeholders.length - 1) + '\x00';
|
||||
});
|
||||
}
|
||||
|
|
|
|||
|
|
@ -6,8 +6,6 @@ const macros = [
|
|||
{
|
||||
name: 'user',
|
||||
toHTML: () => '<a href="/user">TestUser</a>',
|
||||
selector: 'A[href="/user"]',
|
||||
toMarkdown: () => '@user',
|
||||
},
|
||||
{
|
||||
name: 'npc',
|
||||
|
|
@ -15,14 +13,10 @@ const macros = [
|
|||
const name = keywords.join(' ');
|
||||
return '<a href="/NPC/' + name.replace(/ /g, '') + '">' + name + '</a>';
|
||||
},
|
||||
selector: 'A[href^="/NPC/"]',
|
||||
toMarkdown: (el: any) => '@npc(' + el.textContent + ')',
|
||||
},
|
||||
{
|
||||
name: 'style',
|
||||
toHTML: ({ keywords, content }: any) => '<div class="' + keywords.join(' ') + '">' + (content || '') + '</div>',
|
||||
selector: 'DIV[class]',
|
||||
toMarkdown: (el: any, convert: any) => '\n\n@style(' + el.className + '\n' + convert.children(el) + '\n)\n\n',
|
||||
},
|
||||
{
|
||||
name: 'toc',
|
||||
|
|
@ -36,10 +30,12 @@ const M = (html: string) => h.toMarkdown(html);
|
|||
|
||||
describe('Macros', () => {
|
||||
describe('self-closing', () => {
|
||||
it('bare name', () => expect(H('hello @user world')).toBe('<p>hello <a href="/user">TestUser</a> world</p>'));
|
||||
it('empty parens', () => expect(H('hello @user() world')).toBe('<p>hello <a href="/user">TestUser</a> world</p>'));
|
||||
it('keywords', () => expect(H('@npc(Goblin King)')).toBe('<p><a href="/NPC/GoblinKing">Goblin King</a></p>'));
|
||||
it('params', () => expect(H('@toc(depth="2")')).toContain('data-depth="2"'));
|
||||
it('bare name renders', () => expect(H('hello @user world')).toContain('<a href="/user">TestUser</a>'));
|
||||
it('bare name wrapped', () => expect(H('hello @user world')).toContain('data-macro="user"'));
|
||||
it('empty parens', () => expect(H('hello @user() world')).toContain('data-macro="user"'));
|
||||
it('keywords', () => expect(H('@npc(Goblin King)')).toContain('Goblin King'));
|
||||
it('keywords in data attr', () => expect(H('@npc(Goblin King)')).toContain('data-keywords="Goblin King"'));
|
||||
it('params', () => expect(H('@toc(depth="2")')).toContain('data-param-depth="2"'));
|
||||
});
|
||||
|
||||
describe('unknown macros', () => {
|
||||
|
|
@ -52,8 +48,8 @@ describe('Macros', () => {
|
|||
|
||||
describe('block macros', () => {
|
||||
it('content processed', () => expect(H('@style(box\n**bold**\n)')).toContain('<strong>bold</strong>'));
|
||||
it('wraps in div', () => expect(H('@style(box\ncontent\n)')).toContain('<div class="box">'));
|
||||
it('multiple keywords', () => expect(H('@style(box center\ncontent\n)')).toContain('class="box center"'));
|
||||
it('wrapped with data-macro', () => expect(H('@style(box\ncontent\n)')).toContain('data-macro="style"'));
|
||||
it('keywords in data attr', () => expect(H('@style(box center\ncontent\n)')).toContain('data-keywords="box center"'));
|
||||
});
|
||||
|
||||
describe('verbatim', () => {
|
||||
|
|
@ -61,23 +57,41 @@ describe('Macros', () => {
|
|||
it('no strong tag', () => expect(H('@style(box verbatim\n**bold**\n)')).not.toContain('<strong>'));
|
||||
it('escapes html', () => expect(H('@style(box verbatim\n<b>tag</b>\n)')).toContain('<b>'));
|
||||
it('preserves newlines', () => expect(H('@style(box verbatim\nline1\nline2\n)')).toContain('line1<br>'));
|
||||
it('strips keyword', () => expect(H('@style(box verbatim\ncontent\n)')).not.toContain('verbatim'));
|
||||
it('data-verbatim set', () => expect(H('@style(box verbatim\ncontent\n)')).toContain('data-verbatim="true"'));
|
||||
it('keyword stripped from data-keywords', () => {
|
||||
const html = H('@style(box verbatim\ncontent\n)');
|
||||
expect(html).toContain('data-keywords="box"');
|
||||
expect(html).not.toMatch(/data-keywords="[^"]*verbatim/);
|
||||
});
|
||||
});
|
||||
|
||||
describe('nesting', () => {
|
||||
it('inline inside bold', () => expect(H('**@npc(Goblin King)**')).toContain('<strong><a href="/NPC/GoblinKing">'));
|
||||
it('inline inside bold', () => expect(H('**@npc(Goblin King)**')).toContain('<strong>'));
|
||||
it('block contains list', () => expect(H('@style(box\n- item 1\n- item 2\n)')).toContain('<ul>'));
|
||||
it('inline inside block', () => expect(H('@style(box\nhello @user world\n)')).toContain('<a href="/user">TestUser</a>'));
|
||||
it('inline inside block', () => expect(H('@style(box\nhello @user world\n)')).toContain('data-macro="user"'));
|
||||
});
|
||||
|
||||
describe('fenced code protection', () => {
|
||||
it('not in code block', () => expect(H('```\n@user\n```')).not.toContain('<a href="/user">'));
|
||||
it('not in code block', () => expect(H('```\n@user\n```')).not.toContain('data-macro'));
|
||||
it('literal in code block', () => expect(H('```\n@user\n```')).toContain('@user'));
|
||||
it('not in inline code', () => expect(H('`@user`')).not.toContain('<a href="/user">'));
|
||||
it('not in inline code', () => expect(H('`@user`')).not.toContain('data-macro'));
|
||||
});
|
||||
|
||||
describe('round-trips', () => {
|
||||
it('npc', () => expect(M(H('@npc(Goblin King)'))).toBe('@npc(Goblin King)'));
|
||||
it('user', () => expect(M(H('hello @user world'))).toBe('hello @user world'));
|
||||
describe('generic round-trip via data- attributes', () => {
|
||||
it('inline macro', () => expect(M(H('hello @user world'))).toBe('hello @user world'));
|
||||
it('inline with keywords', () => expect(M(H('@npc(Goblin King)'))).toBe('@npc(Goblin King)'));
|
||||
it('inline with params', () => expect(M(H('@toc(depth="2")'))).toBe('@toc(depth="2")'));
|
||||
it('block macro', () => {
|
||||
const md = '@style(box\n**bold** content\n)';
|
||||
const result = M(H(md)).trim();
|
||||
expect(result).toContain('@style(box');
|
||||
expect(result).toContain('**bold** content');
|
||||
expect(result).toContain(')');
|
||||
});
|
||||
it('verbatim round-trip preserves keyword', () => {
|
||||
const md = '@style(box verbatim\n<b>literal</b>\n)';
|
||||
const result = M(H(md)).trim();
|
||||
expect(result).toContain('@style(box verbatim');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
|||
Loading…
Reference in New Issue
Block a user