import { useMemo } from "react"; /** * Lightweight markdown renderer for LLM output. * Handles: code blocks, inline code, bold, italic, headers, links, lists, horizontal rules. * NOT a full CommonMark parser — optimized for typical assistant message patterns. */ export function Markdown({ content, highlightTerms }: { content: string; highlightTerms?: string[] }) { const blocks = useMemo(() => parseBlocks(content), [content]); return (
{blocks.map((block, i) => ( ))}
); } /* ------------------------------------------------------------------ */ /* Types */ /* ------------------------------------------------------------------ */ type BlockNode = | { type: "code"; lang: string; content: string } | { type: "heading"; level: number; content: string } | { type: "hr" } | { type: "list"; ordered: boolean; items: string[] } | { type: "paragraph"; content: string }; /* ------------------------------------------------------------------ */ /* Block parser */ /* ------------------------------------------------------------------ */ function parseBlocks(text: string): BlockNode[] { const lines = text.split("\n"); const blocks: BlockNode[] = []; let i = 0; while (i < lines.length) { const line = lines[i]; // Fenced code block const fenceMatch = line.match(/^```(\w*)/); if (fenceMatch) { const lang = fenceMatch[1] || ""; const codeLines: string[] = []; i++; while (i < lines.length && !lines[i].startsWith("```")) { codeLines.push(lines[i]); i++; } i++; // skip closing ``` blocks.push({ type: "code", lang, content: codeLines.join("\n") }); continue; } // Heading const headingMatch = line.match(/^(#{1,4})\s+(.+)/); if (headingMatch) { blocks.push({ type: "heading", level: headingMatch[1].length, content: headingMatch[2] }); i++; continue; } // Horizontal rule if (/^[-*_]{3,}\s*$/.test(line)) { blocks.push({ type: "hr" }); i++; continue; } // Unordered list if (/^[-*+]\s/.test(line)) { const items: string[] = []; while (i < lines.length && /^[-*+]\s/.test(lines[i])) { items.push(lines[i].replace(/^[-*+]\s/, "")); i++; } blocks.push({ type: "list", ordered: false, items }); continue; } // Ordered list if (/^\d+[.)]\s/.test(line)) { const items: string[] = []; while (i < lines.length && /^\d+[.)]\s/.test(lines[i])) { items.push(lines[i].replace(/^\d+[.)]\s/, "")); i++; } blocks.push({ type: "list", ordered: true, items }); continue; } // Empty line if (line.trim() === "") { i++; continue; } // Paragraph — collect consecutive non-empty, non-special lines const paraLines: string[] = []; while ( i < lines.length && lines[i].trim() !== "" && !lines[i].match(/^```/) && !lines[i].match(/^#{1,4}\s/) && !lines[i].match(/^[-*+]\s/) && !lines[i].match(/^\d+[.)]\s/) && !lines[i].match(/^[-*_]{3,}\s*$/) ) { paraLines.push(lines[i]); i++; } if (paraLines.length > 0) { blocks.push({ type: "paragraph", content: paraLines.join("\n") }); } } return blocks; } /* ------------------------------------------------------------------ */ /* Block renderer */ /* ------------------------------------------------------------------ */ function Block({ block, highlightTerms }: { block: BlockNode; highlightTerms?: string[] }) { switch (block.type) { case "code": return (
          {block.content}
        
); case "heading": { const Tag = `h${Math.min(block.level, 4)}` as "h1" | "h2" | "h3" | "h4"; const sizes: Record = { h1: "text-base font-bold", h2: "text-sm font-bold", h3: "text-sm font-semibold", h4: "text-sm font-medium", }; return ; } case "hr": return
; case "list": { const Tag = block.ordered ? "ol" : "ul"; return ( {block.items.map((item, i) => (
  • ))}
    ); } case "paragraph": return

    ; } } /* ------------------------------------------------------------------ */ /* Inline parser + renderer */ /* ------------------------------------------------------------------ */ type InlineNode = | { type: "text"; content: string } | { type: "code"; content: string } | { type: "bold"; content: string } | { type: "italic"; content: string } | { type: "link"; text: string; href: string } | { type: "br" }; function parseInline(text: string): InlineNode[] { const nodes: InlineNode[] = []; // Pattern priority: code > link > bold > italic > bare URL > line break const pattern = /(`[^`]+`)|(\[([^\]]+)\]\(([^)]+)\))|(\*\*([^*]+)\*\*)|(\*([^*]+)\*)|(\bhttps?:\/\/[^\s<>)\]]+)|(\n)/g; let lastIndex = 0; let match: RegExpExecArray | null; while ((match = pattern.exec(text)) !== null) { if (match.index > lastIndex) { nodes.push({ type: "text", content: text.slice(lastIndex, match.index) }); } if (match[1]) { // Inline code nodes.push({ type: "code", content: match[1].slice(1, -1) }); } else if (match[2]) { // [text](url) link nodes.push({ type: "link", text: match[3], href: match[4] }); } else if (match[5]) { // **bold** nodes.push({ type: "bold", content: match[6] }); } else if (match[7]) { // *italic* nodes.push({ type: "italic", content: match[8] }); } else if (match[9]) { // Bare URL nodes.push({ type: "link", text: match[9], href: match[9] }); } else if (match[10]) { // Line break within paragraph nodes.push({ type: "br" }); } lastIndex = match.index + match[0].length; } if (lastIndex < text.length) { nodes.push({ type: "text", content: text.slice(lastIndex) }); } return nodes; } function InlineContent({ text, highlightTerms }: { text: string; highlightTerms?: string[] }) { const nodes = useMemo(() => parseInline(text), [text]); return ( <> {nodes.map((node, i) => { switch (node.type) { case "text": return ; case "code": return ( {node.content} ); case "bold": return ; case "italic": return ; case "link": return ( {node.text} ); case "br": return
    ; } })} ); } /** Highlight search terms within a plain text string. */ function HighlightedText({ text, terms }: { text: string; terms?: string[] }) { if (!terms || terms.length === 0) return <>{text}; // Build a regex that matches any of the search terms (case-insensitive) const escaped = terms.map((t) => t.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")); const regex = new RegExp(`(${escaped.join("|")})`, "gi"); const parts = text.split(regex); return ( <> {parts.map((part, i) => regex.test(part) ? ( {part} ) : ( {part} ) )} ); }