diff --git a/src/ts/hopdown.ts b/src/ts/hopdown.ts
index ee8c521..d087933 100644
--- a/src/ts/hopdown.ts
+++ b/src/ts/hopdown.ts
@@ -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;
}
diff --git a/src/ts/macros.ts b/src/ts/macros.ts
index 1c95dd6..d909efb 100644
--- a/src/ts/macros.ts
+++ b/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 `${name}`;
- * }}
+ * { name: 'user', toHTML: () => 'gsb' }
+ * { name: 'style', toHTML: ({ keywords, content }) =>
+ * `
${content}
` }
*/
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 `Unknown macro: @${escapeHtml(name)}`;
}
+/**
+ * Wrap a macro's rendered HTML with data- attributes for round-tripping.
+ * Block macros (with content) use , inline macros use
.
+ */
+function wrapMacro(
+ name: string,
+ keywords: string[],
+ params: Record,
+ 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; macroMap: Map } {
+): { blockTag: Tag; selectorTag: Tag; macroMap: Map } {
const macroMap = new Map();
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 = {};
- 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),
- };
- }
- }
+ /**
+ * 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: '[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';
});
}
diff --git a/test/macros.test.ts b/test/macros.test.ts
index bfc420f..d1f1224 100644
--- a/test/macros.test.ts
+++ b/test/macros.test.ts
@@ -6,8 +6,6 @@ const macros = [
{
name: 'user',
toHTML: () => 'TestUser',
- selector: 'A[href="/user"]',
- toMarkdown: () => '@user',
},
{
name: 'npc',
@@ -15,14 +13,10 @@ const macros = [
const name = keywords.join(' ');
return '' + name + '';
},
- selector: 'A[href^="/NPC/"]',
- toMarkdown: (el: any) => '@npc(' + el.textContent + ')',
},
{
name: 'style',
toHTML: ({ keywords, content }: any) => '' + (content || '') + '
',
- 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('hello TestUser world
'));
- it('empty parens', () => expect(H('hello @user() world')).toBe('hello TestUser world
'));
- it('keywords', () => expect(H('@npc(Goblin King)')).toBe('Goblin King
'));
- it('params', () => expect(H('@toc(depth="2")')).toContain('data-depth="2"'));
+ it('bare name renders', () => expect(H('hello @user world')).toContain('TestUser'));
+ 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('bold'));
- it('wraps in div', () => expect(H('@style(box\ncontent\n)')).toContain('