/** * Footnote rewrite — pandoc-style `[^key]` inline refs + `[^key]: …` defs. * * Auto-detects footnote defs. If none exist, returns input unchanged. * Otherwise: strips defs, numbers each key by order of first inline * occurrence, replaces inline refs with unicode parenthesized * superscripts (⁽¹⁾), and appends a `## Footnotes` section with an * ordered list. * * Idempotent: a second pass is a no-op because there are no defs left. * Skips content inside fenced code blocks. */ const FENCE_RE = /^```/; const SUPERSCRIPT_DIGITS: Record = { "0": "⁰", "1": "¹", "2": "²", "3": "³", "4": "⁴", "5": "⁵", "6": "⁶", "7": "⁷", "8": "⁸", "9": "⁹", }; function toSuperscript(n: number): string { return String(n).split("").map((d) => SUPERSCRIPT_DIGITS[d] ?? d).join(""); } function eachContentLine(body: string, fn: (line: string) => string): string { const out: string[] = []; let inFence = false; for (const line of body.split("\n")) { if (FENCE_RE.test(line)) { inFence = !inFence; out.push(line); continue; } out.push(inFence ? line : fn(line)); } return out.join("\n"); } export function processFootnotes(md: string): string { const defRegex = /^\[\^([^\]]+)\]:\s*(.+)$/; // Fast path: no def lines anywhere → no-op. let hasDef = false; { let fence = false; for (const line of md.split("\n")) { if (FENCE_RE.test(line)) { fence = !fence; continue; } if (!fence && defRegex.test(line)) { hasDef = true; break; } } } if (!hasDef) return md; // Collect defs and strip def lines from body. const defs = new Map(); const stripped: string[] = []; { let fence = false; for (const line of md.split("\n")) { if (FENCE_RE.test(line)) { fence = !fence; stripped.push(line); continue; } if (!fence) { const m = line.match(defRegex); if (m) { defs.set(m[1], m[2]); continue; } } stripped.push(line); } } const bodyNoDefs = stripped.join("\n"); // Number by first inline occurrence. const keyToNumber = new Map(); const ordered: string[] = []; let next = 1; { let fence = false; for (const line of bodyNoDefs.split("\n")) { if (FENCE_RE.test(line)) { fence = !fence; continue; } if (fence) continue; for (const m of line.matchAll(/\[\^([^\]]+)\]/g)) { const k = m[1]; if (!keyToNumber.has(k)) { keyToNumber.set(k, next++); ordered.push(k); } } } } // Defined but unreferenced — append at the end. for (const k of defs.keys()) { if (!keyToNumber.has(k)) { keyToNumber.set(k, next++); ordered.push(k); } } // Rewrite inline refs to ⁽ⁿ⁾. const rewritten = eachContentLine(bodyNoDefs, (line) => line.replace(/\[\^([^\]]+)\]/g, (_m, k: string) => { const n = keyToNumber.get(k); return n !== undefined ? `⁽${toSuperscript(n)}⁾` : `[^${k}]`; }) ); const section = [ "", "## Footnotes", "", ...ordered.map((k) => { const n = keyToNumber.get(k)!; const def = defs.get(k) ?? "(missing definition)"; return `${n}. ${def}`; }), ].join("\n"); return rewritten.trimEnd() + "\n\n" + section + "\n"; }