export function citationExtension() { return { name: 'citation', level: 'inline' as const, start(src: string) { // Trigger on any [number] or [number#suffix] // We check for a digit immediately after [ to avoid matching arbitrary links return src.search(/\[\d/); }, tokenizer(src: string) { // Avoid matching footnotes if (/^\[\^/.test(src)) return; // Match ONE OR MORE adjacent [1], [1,2], or [1#foo] blocks // Example matched: "[1][2,3][4#bar]" // We allow: digits, commas, spaces, and # followed by non-control chars (excluding ] and ,) const rule = /^(\[(?:\d+(?:#[^,\]\s]+)?(?:,\s*\d+(?:#[^,\]\s]+)?)*)\])+/; const match = rule.exec(src); if (!match) return; const raw = match[0]; // Extract ALL bracket groups inside the big match const groupRegex = /\[([^\]]+)\]/g; const ids: number[] = []; const citationIdentifiers: string[] = []; let m: RegExpExecArray | null; while ((m = groupRegex.exec(raw))) { // m[1] is the content inside brackets, e.g. "1, 2#foo" const parts = m[1].split(',').map((p) => p.trim()); parts.forEach((part) => { // Check if it starts with digit const match = /^(\d+)(?:#(.+))?$/.exec(part); if (match) { const index = parseInt(match[1], 10); if (!isNaN(index)) { ids.push(index); // Store the full identifier ("1#foo" or "1") citationIdentifiers.push(part); } } }); } if (ids.length === 0) return; return { type: 'citation', raw, ids, // merged list of integers for legacy title lookup citationIdentifiers // merged list of full identifiers for granular targeting }; }, renderer(token: any) { // fallback text return token.raw; } }; } export default function () { return { extensions: [citationExtension()] }; }