import { useMemo, useCallback } from 'react' import type { MouseEvent as ReactMouseEvent } from 'react' import DOMPurify from 'dompurify' import katex from 'katex' import 'katex/dist/katex.min.css' import { marked, type Tokens } from 'marked' import { CodeViewer } from '../chat/CodeViewer' import { MermaidRenderer } from '../chat/MermaidRenderer' import { copyTextToClipboard } from '../chat/clipboard' type Props = { content: string variant?: 'default' | 'document' | 'compact' className?: string onLinkClick?: (href: string, event: ReactMouseEvent) => boolean | void } type CodeBlock = { id: string code: string language: string | undefined } type MathBlock = { id: string tex: string displayMode: boolean } const MERMAID_LANGUAGE = 'mermaid' const PLAINTEXT_LANGUAGES = new Set(['', 'text', 'plaintext', 'plain']) const MERMAID_DIAGRAM_START = /^(graph|flowchart|sequenceDiagram|classDiagram|stateDiagram(?:-v2)?|erDiagram|journey|gantt|pie|gitGraph|mindmap|timeline|requirementDiagram|quadrantChart|xychart-beta|sankey-beta|block-beta|packet-beta|architecture|kanban)\b/i const CODE_FENCE_START = /^ {0,3}(`{3,}|~{3,})/ function normalizeCodeLanguage(language: string | undefined): string | undefined { const normalized = language?.trim().split(/\s+/)[0]?.toLowerCase() return normalized || undefined } function looksLikeMermaid(code: string): boolean { const firstMeaningfulLine = code .split('\n') .map((line) => line.trim()) .find(Boolean) return firstMeaningfulLine ? MERMAID_DIAGRAM_START.test(firstMeaningfulLine) : false } function shouldRenderAsMermaid(block: CodeBlock): boolean { const normalizedLanguage = normalizeCodeLanguage(block.language) if (normalizedLanguage === MERMAID_LANGUAGE) { return true } if (!PLAINTEXT_LANGUAGES.has(normalizedLanguage ?? '')) { return false } return looksLikeMermaid(block.code) } const renderer = new marked.Renderer() let pendingCodeBlocks: CodeBlock[] = [] renderer.code = function ({ text, lang }: Tokens.Code) { const id = `cb-${pendingCodeBlocks.length}` pendingCodeBlocks.push({ id, code: text, language: normalizeCodeLanguage(lang || undefined), }) return `
` } marked.setOptions({ breaks: true, gfm: true, }) marked.use({ renderer }) function findUnescapedDelimiter(text: string, delimiter: string, fromIndex: number): number { let index = text.indexOf(delimiter, fromIndex) while (index !== -1) { let backslashCount = 0 for (let i = index - 1; i >= 0 && text[i] === '\\'; i--) { backslashCount += 1 } if (backslashCount % 2 === 0) return index index = text.indexOf(delimiter, index + delimiter.length) } return -1 } function consumeMath( content: string, mathBlocks: MathBlock[], start: number, open: string, close: string, displayMode: boolean, ): { replacement: string; end: number } | null { const contentStart = start + open.length const end = findUnescapedDelimiter(content, close, contentStart) if (end === -1) return null const tex = content.slice(contentStart, end) if (!tex.trim()) return null if (!displayMode && /[\n\r]/.test(tex)) return null if (open === '$' && (/\s/.test(content[contentStart] ?? '') || /\s/.test(content[end - 1] ?? ''))) { return null } const id = `math-${mathBlocks.length}` mathBlocks.push({ id, tex, displayMode }) const tag = displayMode ? 'div' : 'span' const spacing = displayMode ? '\n\n' : '' return { replacement: `${spacing}<${tag} data-math-id="${id}">${spacing}`, end: end + close.length, } } function extractMathFromSegment(segment: string, mathBlocks: MathBlock[]): string { let output = '' let index = 0 while (index < segment.length) { if (segment[index] === '`') { const match = /^`+/.exec(segment.slice(index)) const ticks = match?.[0] ?? '`' const end = segment.indexOf(ticks, index + ticks.length) if (end !== -1) { output += segment.slice(index, end + ticks.length) index = end + ticks.length continue } } const displayDollar = segment.startsWith('$$', index) ? consumeMath(segment, mathBlocks, index, '$$', '$$', true) : null if (displayDollar) { output += displayDollar.replacement index = displayDollar.end continue } const displayBracket = segment.startsWith('\\[', index) ? consumeMath(segment, mathBlocks, index, '\\[', '\\]', true) : null if (displayBracket) { output += displayBracket.replacement index = displayBracket.end continue } const inlineParen = segment.startsWith('\\(', index) ? consumeMath(segment, mathBlocks, index, '\\(', '\\)', false) : null if (inlineParen) { output += inlineParen.replacement index = inlineParen.end continue } const inlineDollar = segment[index] === '$' && segment[index + 1] !== '$' ? consumeMath(segment, mathBlocks, index, '$', '$', false) : null if (inlineDollar) { output += inlineDollar.replacement index = inlineDollar.end continue } output += segment[index] index += 1 } return output } function extractMath(content: string): { markdown: string; mathBlocks: MathBlock[] } { const mathBlocks: MathBlock[] = [] const lines = content.match(/[^\n]*\n|[^\n]+/g) ?? [''] let output = '' let pendingMarkdown = '' let inFence: string | null = null const flushPendingMarkdown = () => { if (!pendingMarkdown) return output += extractMathFromSegment(pendingMarkdown, mathBlocks) pendingMarkdown = '' } for (const line of lines) { const fenceMatch = CODE_FENCE_START.exec(line) if (fenceMatch) { const marker = fenceMatch[1]!.charAt(0) if (!inFence) { flushPendingMarkdown() inFence = marker } else if (inFence === marker) { inFence = null } output += line continue } if (inFence) { output += line } else { pendingMarkdown += line } } flushPendingMarkdown() return { markdown: output, mathBlocks } } function renderMath(block: MathBlock): string { try { return katex.renderToString(block.tex, { displayMode: block.displayMode, throwOnError: false, strict: false, trust: false, }) } catch { return DOMPurify.sanitize(block.tex) } } function enhanceMarkdownHtml(html: string, mathBlocks: MathBlock[]): string { const cleanHtml = DOMPurify.sanitize(html, { ADD_TAGS: ['use'], ADD_ATTR: ['xlink:href'], }) if (typeof document === 'undefined') { return cleanHtml } const container = document.createElement('div') container.innerHTML = cleanHtml const mathById = new Map(mathBlocks.map((block) => [block.id, block])) container.querySelectorAll('[data-math-id]').forEach((placeholder) => { const block = mathById.get(placeholder.dataset.mathId ?? '') if (!block) return const rendered = document.createElement(block.displayMode ? 'div' : 'span') rendered.className = block.displayMode ? 'md-math-display' : 'md-math-inline' rendered.innerHTML = renderMath(block) placeholder.replaceWith(rendered) }) container.querySelectorAll('table').forEach((table) => { if (table.parentElement?.classList.contains('md-table-wrap')) return const wrapper = document.createElement('div') wrapper.className = 'md-table-wrap' table.parentNode?.insertBefore(wrapper, table) wrapper.appendChild(table) }) container.querySelectorAll('a[href]').forEach((link) => { link.setAttribute('target', '_blank') link.setAttribute('rel', 'noreferrer noopener') }) return container.innerHTML } function parseMarkdown(content: string): { html: string; codeBlocks: CodeBlock[]; mathBlocks: MathBlock[] } { pendingCodeBlocks = [] const { markdown, mathBlocks } = extractMath(content) const html = marked.parse(markdown) as string const codeBlocks = [...pendingCodeBlocks] pendingCodeBlocks = [] return { html, codeBlocks, mathBlocks } } const BASE_PROSE_CLASSES = `markdown-prose prose prose-sm min-w-0 max-w-none break-words [overflow-wrap:anywhere] text-[var(--color-text-primary)] prose-headings:text-[var(--color-text-primary)] prose-headings:font-semibold prose-p:my-2 prose-p:leading-relaxed prose-p:break-words prose-p:[overflow-wrap:anywhere] prose-code:text-[13px] prose-code:text-[var(--color-code-fg)] prose-code:font-[var(--font-mono)] prose-code:bg-[var(--color-code-bg)] prose-code:border prose-code:border-[var(--color-border)] prose-code:px-1.5 prose-code:py-0.5 prose-code:rounded-md prose-code:before:hidden prose-code:after:hidden prose-pre:!bg-transparent prose-pre:!p-0 prose-pre:!shadow-none prose-a:text-[var(--color-text-accent)] prose-a:no-underline prose-a:[overflow-wrap:anywhere] hover:prose-a:underline prose-strong:text-[var(--color-text-primary)] prose-ul:my-2 prose-ol:my-2 prose-li:my-0.5 prose-table:my-0 prose-table:w-full prose-table:table-auto prose-table:text-sm prose-th:bg-[var(--color-surface-info)] prose-th:px-3 prose-th:py-2 prose-th:text-left prose-th:whitespace-normal prose-th:break-words prose-th:align-top prose-th:border-b prose-th:border-[var(--color-border)] prose-td:px-3 prose-td:py-2 prose-td:border-b prose-td:border-[var(--color-border)] prose-td:whitespace-normal prose-td:break-words prose-td:align-top prose-td:bg-[var(--color-surface)] [&_.katex]:[white-space:nowrap] [&_.katex]:[overflow-wrap:normal] [&_.katex]:[word-break:normal] [&_.md-math-inline]:inline-flex [&_.md-math-inline]:max-w-full [&_.md-math-inline]:overflow-x-auto [&_.md-math-inline]:[vertical-align:-0.08em] [&_.md-math-inline_.katex]:text-[1.02em] [&_.md-math-display]:my-5 [&_.md-math-display]:flex [&_.md-math-display]:max-w-full [&_.md-math-display]:justify-center [&_.md-math-display]:overflow-x-auto [&_.md-math-display]:px-1 [&_.md-math-display]:py-2 [&_.md-math-display]:[scrollbar-width:thin] [&_.md-math-display_.katex-display]:m-0 [&_.md-math-display_.katex]:text-[1.14em] [&_.md-math-display_.katex-html]:min-w-max [&_.md-table-wrap]:my-5 [&_.md-table-wrap]:overflow-x-auto [&_.md-table-wrap]:rounded-xl [&_.md-table-wrap]:border [&_.md-table-wrap]:border-[var(--color-border)] [&_.md-table-wrap]:bg-[var(--color-surface-container-lowest)]` const DOCUMENT_PROSE_CLASSES = ` prose-p:text-[15px] prose-p:leading-7 prose-headings:scroll-mt-6 prose-headings:tracking-[-0.01em] prose-h1:mb-4 prose-h1:text-2xl prose-h1:font-semibold prose-h1:leading-tight prose-h2:mt-8 prose-h2:mb-3 prose-h2:border-b prose-h2:border-[var(--color-border)] prose-h2:pb-2 prose-h2:text-xl prose-h2:font-semibold prose-h3:mt-6 prose-h3:mb-2 prose-h3:text-base prose-h3:font-semibold prose-h4:mt-5 prose-h4:mb-2 prose-h4:text-sm prose-h4:font-semibold prose-blockquote:my-4 prose-blockquote:rounded-r-lg prose-blockquote:border-l-4 prose-blockquote:border-[var(--color-outline-variant)] prose-blockquote:bg-[var(--color-surface-container-low)] prose-blockquote:px-4 prose-blockquote:py-2 prose-blockquote:italic prose-hr:my-6 prose-hr:border-[var(--color-border)] prose-img:rounded-lg prose-img:border prose-img:border-[var(--color-border)] prose-kbd:rounded prose-kbd:border prose-kbd:border-[var(--color-border)] prose-kbd:bg-[var(--color-surface-container-lowest)] prose-kbd:px-1.5 prose-kbd:py-0.5 prose-kbd:font-[var(--font-mono)] prose-kbd:text-[12px] prose-kbd:font-normal prose-kbd:text-[var(--color-text-secondary)] prose-kbd:shadow-none prose-ul:pl-5 prose-ul:[&>li]:marker:text-[var(--color-text-tertiary)] prose-ol:pl-5 prose-ol:[&>li]:marker:text-[var(--color-text-tertiary)] prose-li:my-1.5 prose-table:my-0 [&_.md-math-display]:my-6 [&_.md-math-display_.katex]:text-[1.18em]` const COMPACT_PROSE_CLASSES = ` prose-p:my-1 prose-p:text-xs prose-p:leading-5 prose-p:text-[var(--color-text-secondary)] prose-headings:mt-2 prose-headings:mb-1 prose-headings:leading-snug prose-h1:text-base prose-h2:text-sm prose-h3:text-xs prose-h4:text-xs prose-blockquote:my-2 prose-blockquote:border-l-2 prose-blockquote:border-[var(--color-outline-variant)] prose-blockquote:pl-3 prose-blockquote:text-[var(--color-text-secondary)] prose-code:text-[12px] prose-ul:my-1 prose-ol:my-1 prose-ul:pl-4 prose-ol:pl-4 prose-li:my-0.5 prose-li:text-xs prose-li:leading-5 prose-li:text-[var(--color-text-secondary)] prose-table:text-xs [&_.md-math-display]:my-2 [&_.md-math-display]:py-1 [&_.md-math-display_.katex]:text-[1.04em] [&_.md-table-wrap]:my-2` function getProseClasses(variant: 'default' | 'document' | 'compact', className?: string) { return [ BASE_PROSE_CLASSES, variant === 'document' ? DOCUMENT_PROSE_CLASSES : '', variant === 'compact' ? COMPACT_PROSE_CLASSES : '', className ?? '', ] .filter(Boolean) .join(' ') } export function MarkdownRenderer({ content, variant = 'default', className, onLinkClick }: Props) { const { html, codeBlocks, mathBlocks } = useMemo(() => parseMarkdown(content), [content]) const proseClasses = useMemo( () => getProseClasses(variant, className), [variant, className], ) const parts = useMemo(() => { if (codeBlocks.length === 0) { return [{ type: 'html' as const, content: html }] } const result: Array<{ type: 'html'; content: string } | { type: 'code'; block: CodeBlock }> = [] let remaining = html for (const block of codeBlocks) { const marker = `
` const idx = remaining.indexOf(marker) if (idx === -1) continue const before = remaining.slice(0, idx) if (before) { result.push({ type: 'html', content: before }) } result.push({ type: 'code', block }) remaining = remaining.slice(idx + marker.length) } if (remaining) { result.push({ type: 'html', content: remaining }) } return result }, [html, codeBlocks]) const handleClick = useCallback(async (event: ReactMouseEvent) => { const target = event.target as HTMLElement | null const button = target?.closest('[data-copy-code]') if (!button) { const link = target?.closest('a[href]') if (!link || !onLinkClick) return const handled = onLinkClick(link.getAttribute('href') ?? '', event) if (handled) { event.preventDefault() event.stopPropagation() } return } const text = button.getAttribute('data-copy-code') if (!text) return const copied = await copyTextToClipboard(text) if (!copied) return const original = button.textContent button.textContent = 'Copied' window.setTimeout(() => { button.textContent = original }, 1500) }, [onLinkClick]) if (codeBlocks.length === 0) { const cleanHtml = enhanceMarkdownHtml(html, mathBlocks) return (
) } return (
{parts.map((part, i) => part.type === 'html' ? (
) : shouldRenderAsMermaid(part.block) ? ( ) : (
) )}
) }