import { useState, type ReactNode } from "react"; import type { Citation } from "../api/types"; import { highlight, languageLabel } from "../lib/highlight"; /** Small, safe Markdown renderer for generated text. Builds React elements (never raw HTML), and turns * [n] citation markers into buttons that open the cited lines. * Supports: headings, paragraphs, bullet, numbered and task lists (one nested level), blockquotes, * fenced code (highlighted, copyable, collapsible), tables with alignment, images, horizontal rules, * bold, italic, strikethrough, inline code, links and bare URLs. */ type Cite = { citations?: Citation[]; onCite?: (c: Citation) => void }; const INLINE = /(`[^`]+`)|(\*\*[^*]+\*\*|__[^_]+__)|(\*[^*\s][^*]*\*|_[^_\s][^_]*_)|(~~[^~]+~~)|(!\[([^\]]*)\]\(([^)\s]+)(?:\s+"([^"]*)")?\))|(\[([^\]]+)\]\(([^)\s]+)(?:\s+"[^"]*")?\))|(\[\d+\](?:\[\d+\])*)|(https?:\/\/[^\s)<]+[^\s)<.,;:!?'"])/g; /** Links: web, mail and in app paths only (no javascript: and friends). Images: web and app paths. */ const safeHref = (url: string) => (/^(https?:\/\/|mailto:|\/(?!\/)|#)/i.test(url) ? url : null); const safeSrc = (url: string) => (/^(https?:\/\/|\/(?!\/))/i.test(url) ? url : null); function Img({ src, alt, title }: { src: string; alt: string; title?: string }) { const [broken, setBroken] = useState(false); if (broken) return [image: {alt || src}]; return ( {alt} setBroken(true)} /> ); } const COLLAPSE_AT = 24; // lines function CodeBlock({ code, lang }: { code: string; lang: string }) { const [copied, setCopied] = useState(false); const lines = code.split("\n"); const long = lines.length > COLLAPSE_AT; const [open, setOpen] = useState(!long); const shown = open ? code : lines.slice(0, COLLAPSE_AT).join("\n"); return (
{languageLabel(lang)}
        {highlight(shown, lang)}
      
{long && ( )}
); } function inline(text: string, cite: Cite, keyBase: string): ReactNode[] { const out: ReactNode[] = []; const byMarker = new Map((cite.citations ?? []).map((c) => [c.marker, c])); let last = 0; let i = 0; for (const m of text.matchAll(INLINE)) { const idx = m.index ?? 0; if (idx > last) out.push(text.slice(last, idx)); const key = `${keyBase}-${i++}`; const [whole, code, bold, italic, strike, image, alt, src, title, link, label, href, markers, bare] = m; if (code) out.push({code.slice(1, -1)}); else if (bold) out.push({inline(bold.slice(2, -2), cite, key)}); else if (italic) out.push({inline(italic.slice(1, -1), cite, key)}); else if (strike) out.push({inline(strike.slice(2, -2), cite, key)}); else if (image) { const url = safeSrc(src); out.push(url ? {alt} : alt); } else if (link) { const url = safeHref(href); const external = url ? /^https?:/i.test(url) : false; out.push( url ? ( {inline(label, cite, key)} ) : ( {inline(label, cite, key)} ), ); } else if (markers) { for (const n of markers.matchAll(/\[(\d+)\]/g)) { const c = byMarker.get(Number(n[1])); out.push( c ? ( ) : ( {n[1]} ), ); } } else if (bare) { out.push( {bare} , ); } else out.push(whole); last = idx + whole.length; } if (last < text.length) out.push(text.slice(last)); return out; } type ListItem = { text: string; children: ListItem[]; task?: boolean; done?: boolean }; type Align = "left" | "center" | "right" | undefined; type Block = | { kind: "h"; level: number; text: string } | { kind: "p"; text: string } | { kind: "ul" | "ol"; items: ListItem[]; start: number } | { kind: "quote"; text: string } | { kind: "code"; text: string; lang: string } | { kind: "table"; head: string[]; rows: string[][]; align: Align[] } | { kind: "img"; src: string; alt: string; title?: string } | { kind: "hr" }; const LIST = /^(\s*)([-*+]|\d+[.)])\s+(.*)$/; const splitRow = (line: string) => line .trim() .replace(/^\|/, "") .replace(/\|$/, "") .split("|") .map((c) => c.trim()); function parse(src: string): Block[] { const lines = src.replace(/\r\n/g, "\n").split("\n"); const blocks: Block[] = []; let i = 0; while (i < lines.length) { const line = lines[i]; const trimmed = line.trim(); if (!trimmed) { i++; continue; } const fence = trimmed.match(/^```(\w*)/); if (fence) { const body: string[] = []; i++; while (i < lines.length && !lines[i].trim().startsWith("```")) body.push(lines[i++]); i++; blocks.push({ kind: "code", text: body.join("\n"), lang: fence[1] }); continue; } const image = trimmed.match(/^!\[([^\]]*)\]\(([^)\s]+)(?:\s+"([^"]*)")?\)$/); if (image && safeSrc(image[2])) { blocks.push({ kind: "img", alt: image[1], src: image[2], title: image[3] }); i++; continue; } const heading = trimmed.match(/^(#{1,6})\s+(.*)$/); if (heading) { blocks.push({ kind: "h", level: heading[1].length, text: heading[2] }); i++; continue; } if (/^(-{3,}|\*{3,}|_{3,})$/.test(trimmed)) { blocks.push({ kind: "hr" }); i++; continue; } if (trimmed.startsWith("|") && i + 1 < lines.length && /^\s*\|?\s*:?-{2,}/.test(lines[i + 1])) { const head = splitRow(trimmed); const align: Align[] = splitRow(lines[i + 1]).map((c) => c.startsWith(":") && c.endsWith(":") ? "center" : c.endsWith(":") ? "right" : c.startsWith(":") ? "left" : undefined, ); i += 2; const rows: string[][] = []; while (i < lines.length && lines[i].trim().startsWith("|")) rows.push(splitRow(lines[i++])); blocks.push({ kind: "table", head, rows, align }); continue; } if (trimmed.startsWith(">")) { const body: string[] = []; while (i < lines.length && lines[i].trim().startsWith(">")) body.push(lines[i++].trim().replace(/^>\s?/, "")); blocks.push({ kind: "quote", text: body.join("\n") }); continue; } const first = line.match(LIST); if (first) { const ordered = /\d/.test(first[2]); const baseIndent = first[1].length; const items: ListItem[] = []; while (i < lines.length) { const m = lines[i].match(LIST); if (m) { const task = m[3].match(/^\[([ xX])\]\s+(.*)$/); const item: ListItem = task ? { text: task[2], children: [], task: true, done: task[1] !== " " } : { text: m[3], children: [] }; if (m[1].length > baseIndent && items.length) items[items.length - 1].children.push(item); else items.push(item); i++; } else if (lines[i].trim() && /^\s{2,}/.test(lines[i]) && items.length) { // Continuation line of the previous item. const target = items[items.length - 1]; const deepest = target.children.length ? target.children[target.children.length - 1] : target; deepest.text += ` ${lines[i].trim()}`; i++; } else if (!lines[i].trim() && i + 1 < lines.length && LIST.test(lines[i + 1])) { i++; // loose list: blank line between items } else break; } blocks.push({ kind: ordered ? "ol" : "ul", items, start: ordered ? parseInt(first[2], 10) || 1 : 1 }); continue; } const para: string[] = []; while (i < lines.length && lines[i].trim() && !LIST.test(lines[i]) && !/^(#{1,6}\s|>|```|\||!\[)/.test(lines[i].trim())) { para.push(lines[i++].trim()); } if (para.length === 0) { para.push(trimmed); i++; } blocks.push({ kind: "p", text: para.join("\n") }); } return blocks; } function withBreaks(text: string, cite: Cite, key: string): ReactNode[] { return text.split("\n").flatMap((part, j, all) => [ ...inline(part, cite, `${key}-${j}`), ...(j < all.length - 1 ? [
] : []), ]); } function renderItems(items: ListItem[], ordered: boolean, cite: Cite, key: string, start = 1): ReactNode { const children = items.map((it, j) => (
  • {it.task && } {inline(it.text, cite, `${key}-${j}`)} {it.children.length > 0 && renderItems(it.children, ordered, cite, `${key}-${j}-c`)}
  • )); return ordered ? (
      {children}
    ) : ( ); } export function Markdown({ text, citations, onCite, className = "", reportHeadings = false }: { text: string; className?: string; /** A report page: "#" and "##" are page sections (h2), not the chat's small headings. */ reportHeadings?: boolean; } & Cite) { const cite = { citations, onCite }; return (
    {parse(text).map((b, i) => { const key = `b${i}`; switch (b.kind) { case "h": { const Tag = (reportHeadings ? (["h2", "h2", "h3", "h4", "h5", "h5"] as const) : (["h3", "h3", "h4", "h5", "h5", "h5"] as const))[b.level - 1]; return {inline(b.text, cite, key)}; } case "p": return

    {withBreaks(b.text, cite, key)}

    ; case "ul": case "ol": return renderItems(b.items, b.kind === "ol", cite, key, b.start); case "quote": return
    {withBreaks(b.text, cite, key)}
    ; case "code": return ; case "img": return (
    {b.alt} {(b.title || b.alt) &&
    {b.title || b.alt}
    }
    ); case "table": return (
    {b.head.map((h, j) => ( ))} {b.rows.map((r, j) => ( {r.map((c, k) => ( ))} ))}
    {inline(h, cite, `${key}-h${j}`)}
    {inline(c, cite, `${key}-${j}-${k}`)}
    ); case "hr": return
    ; default: return null; } })}
    ); }