import { useMemo, useCallback } from 'react' import DOMPurify from 'dompurify' import { marked, type Tokens } from 'marked' import { CodeViewer } from '../chat/CodeViewer' import { MermaidRenderer } from '../chat/MermaidRenderer' type Props = { content: string variant?: 'default' | 'document' className?: string } type CodeBlock = { id: string code: string language: string | undefined } 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 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 enhanceMarkdownHtml(html: string): 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 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[] } { pendingCodeBlocks = [] const html = marked.parse(content) as string const codeBlocks = [...pendingCodeBlocks] pendingCodeBlocks = [] return { html, codeBlocks } } const BASE_PROSE_CLASSES = `markdown-prose prose prose-sm max-w-none 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-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 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)] [&_.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` function getProseClasses(variant: 'default' | 'document', className?: string) { return [BASE_PROSE_CLASSES, variant === 'document' ? DOCUMENT_PROSE_CLASSES : '', className ?? ''] .filter(Boolean) .join(' ') } export function MarkdownRenderer({ content, variant = 'default', className }: Props) { const { html, codeBlocks } = 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: React.MouseEvent