import { type CSSProperties, type ReactNode } from 'react'; export interface MarkdownCitation { /** Citation id token matched in the source text — e.g. `doc:42#§3`. */ id: string; /** Display label shown inside the chip. */ label: string; /** Optional click handler — typically opens the source document. */ onClick?: () => void; } export interface MarkdownProps { /** Markdown source string. */ source: string; /** * Optional citation registry. Inline `[cite:]` tokens are * replaced with ``-styled inline chips that look up * the id here. Missing ids render as `[?]` to keep the failure mode * obvious. */ citations?: MarkdownCitation[]; className?: string; style?: CSSProperties; } /** * `` — pure-JS, dependency-free subset Markdown renderer with * inline citation support. * * Supports: # / ## / ### headings, **bold**, *italic*, `inline code`, * ```fenced code```, - / 1. lists, [link](url), `[cite:]`. * * Tradeoffs: no GFM tables, no HTML pass-through (intentional for * safety), no nested lists. Hosts that need richer rendering should * compose a markdown pipeline (remark/rehype) and feed the result here * via a custom renderer. * * Wave 9.E.1. */ export function Markdown({ source, citations, className, style, }: MarkdownProps) { const blocks = parseBlocks(source); const map = new Map( (citations ?? []).map((c) => [c.id, c]), ); return (
{blocks.map((b, i) => renderBlock(b, i, map))}
); } type Block = | { kind: 'heading'; level: 1 | 2 | 3; text: string } | { kind: 'paragraph'; text: string } | { kind: 'code'; lang: string; text: string } | { kind: 'list'; ordered: boolean; items: string[] }; function parseBlocks(source: string): Block[] { const lines = source.replace(/\r\n/g, '\n').split('\n'); const out: Block[] = []; let i = 0; while (i < lines.length) { const line = lines[i]; if (line === undefined) { i += 1; continue; } // Fenced code block. if (line.startsWith('```')) { const lang = line.slice(3).trim(); const buf: string[] = []; i += 1; while (i < lines.length && !(lines[i] ?? '').startsWith('```')) { buf.push(lines[i] ?? ''); i += 1; } out.push({ kind: 'code', lang, text: buf.join('\n') }); i += 1; // skip closing fence continue; } // Heading. const h = /^(#{1,3})\s+(.+)$/.exec(line); if (h) { const level = h[1]?.length as 1 | 2 | 3; out.push({ kind: 'heading', level, text: h[2] ?? '' }); i += 1; continue; } // List. if (/^[-*]\s+/.test(line) || /^\d+\.\s+/.test(line)) { const ordered = /^\d+\.\s+/.test(line); const items: string[] = []; while ( i < lines.length && ((ordered && /^\d+\.\s+/.test(lines[i] ?? '')) || (!ordered && /^[-*]\s+/.test(lines[i] ?? ''))) ) { const m = ordered ? /^\d+\.\s+(.*)$/.exec(lines[i] ?? '') : /^[-*]\s+(.*)$/.exec(lines[i] ?? ''); items.push(m?.[1] ?? ''); i += 1; } out.push({ kind: 'list', ordered, items }); continue; } // Blank line. if (line.trim() === '') { i += 1; continue; } // Paragraph — consume until blank line or block start. const buf: string[] = []; while ( i < lines.length && (lines[i] ?? '').trim() !== '' && !(lines[i] ?? '').startsWith('```') && !/^(#{1,3})\s+/.test(lines[i] ?? '') && !/^[-*]\s+/.test(lines[i] ?? '') && !/^\d+\.\s+/.test(lines[i] ?? '') ) { buf.push(lines[i] ?? ''); i += 1; } out.push({ kind: 'paragraph', text: buf.join('\n') }); } return out; } function renderBlock(b: Block, key: number, cites: Map): ReactNode { switch (b.kind) { case 'heading': { const Tag = (`h${b.level}` as 'h1' | 'h2' | 'h3') as 'h1'; const sizes = { 1: 22, 2: 18, 3: 16 } as const; return ( {renderInline(b.text, cites)} ); } case 'paragraph': return (

{renderInline(b.text, cites)}

); case 'code': return (
          {b.text}
        
); case 'list': { const Tag = b.ordered ? 'ol' : 'ul'; return ( {b.items.map((it, j) => (
  • {renderInline(it, cites)}
  • ))}
    ); } default: return null; } } /** * Inline-token renderer. Tokens recognised: * `code` inline code * **bold** bold * *italic* italic * [text](url) link (opens in same tab, target=_self) * [cite:] citation chip (resolved from registry) * * Implementation is a single pass with regex alternation — order * matters (inline code first to protect from formatting consumption). */ function renderInline(text: string, cites: Map): ReactNode[] { const out: ReactNode[] = []; // Tokenizer — single regex picks the FIRST matching pattern. const tokenRe = /(`[^`]+`)|(\*\*[^*]+\*\*)|(\*[^*]+\*)|(\[cite:[^\]]+\])|(\[[^\]]+\]\([^)]+\))/g; let last = 0; let m: RegExpExecArray | null; let key = 0; while ((m = tokenRe.exec(text))) { if (m.index > last) { out.push(text.slice(last, m.index)); } const full = m[0]; if (full.startsWith('`')) { out.push( {full.slice(1, -1)} , ); } else if (full.startsWith('**')) { out.push( {full.slice(2, -2)}, ); } else if (full.startsWith('[cite:')) { const id = full.slice('[cite:'.length, -1); const c = cites.get(id); out.push( , ); } else if (full.startsWith('[')) { const lm = /^\[([^\]]+)\]\(([^)]+)\)$/.exec(full); if (lm) { out.push( {lm[1]} , ); } else { out.push(full); } } else if (full.startsWith('*')) { out.push({full.slice(1, -1)}); } last = m.index + full.length; } if (last < text.length) { out.push(text.slice(last)); } return out; } function CitationChip({ id, label, onClick, missing, }: { id: string; label?: string; onClick?: () => void; missing: boolean; }) { const text = missing ? '[?]' : label ?? id; return ( ); }