import { createCodePlugin } from "@streamdown/code"; import { createMathPlugin } from "@streamdown/math"; import { Streamdown, type StreamdownProps } from "streamdown"; import "katex/dist/katex.min.css"; import Image from "next/image"; import { useMemo } from "react"; import { processChildrenWithCitations } from "@/components/citations/citation-renderer"; import { type CitationUrlMap, preprocessCitationMarkdown } from "@/lib/citations/citation-parser"; import { cn } from "@/lib/utils"; const code = createCodePlugin({ themes: ["nord", "nord"], }); const math = createMathPlugin({ // Keep currency from being parsed as math; real math is normalized below. singleDollarTextMath: false, }); interface MarkdownViewerProps { content: string; className?: string; maxLength?: number; /** Render citation tokens as interactive badges. */ enableCitations?: boolean; } const EMPTY_URL_MAP: CitationUrlMap = new Map(); /** Strip a single outer markdown fence when the whole payload is fenced. */ function stripOuterMarkdownFence(content: string): string { const trimmed = content.trim(); const match = trimmed.match(/^(`{3,})(?:markdown|md)?\s*\n([\s\S]+?)\n\1\s*$/); return match ? match[2] : content; } /** Normalize common LaTeX delimiters to Streamdown's double-dollar syntax. */ function convertLatexDelimiters(content: string): string { content = content.replace(/\\\[([\s\S]*?)\\\]/g, (_, inner) => `\n$$\n${inner.trim()}\n$$\n`); content = content.replace(/\\\(([\s\S]*?)\\\)/g, (_, inner) => `$$${inner.trim()}$$`); content = content.replace( /\\begin\{equation\}([\s\S]*?)\\end\{equation\}/g, (_, inner) => `\n$$\n${inner.trim()}\n$$\n` ); content = content.replace( /\\begin\{displaymath\}([\s\S]*?)\\end\{displaymath\}/g, (_, inner) => `\n$$\n${inner.trim()}\n$$\n` ); content = content.replace( /\\begin\{math\}([\s\S]*?)\\end\{math\}/g, (_, inner) => `$$${inner.trim()}$$` ); content = content.replace(/`(\${1,2})((?:(?!\1).)+)\1`/g, "$1$2$1"); // Only convert command-style single-dollar math, leaving currency alone. content = content.replace( /(? `$$${inner.trim()}$$` ); return content; } export function MarkdownViewer({ content, className, maxLength, enableCitations = false, }: MarkdownViewerProps) { const isTruncated = maxLength != null && content.length > maxLength; const displayContent = isTruncated ? content.slice(0, maxLength) : content; // Rewrite citation URLs before markdown autolinking can split them. const { processedContent, urlMap } = useMemo(() => { const stripped = stripOuterMarkdownFence(displayContent); if (!enableCitations) { return { processedContent: convertLatexDelimiters(stripped), urlMap: EMPTY_URL_MAP, }; } const { content: rewritten, urlMap: map } = preprocessCitationMarkdown(stripped); return { processedContent: convertLatexDelimiters(rewritten), urlMap: map, }; }, [displayContent, enableCitations]); // Do not wrap anchors or code; citation buttons inside them would be invalid. const wrap = (children: React.ReactNode): React.ReactNode => enableCitations ? processChildrenWithCitations(children, urlMap) : children; const components: StreamdownProps["components"] = { p: ({ children, ...props }) => (

{wrap(children)}

), a: ({ children, ...props }) => ( {children} ), li: ({ children, ...props }) =>
  • {wrap(children)}
  • , ul: ({ ...props }) =>