import ReactMarkdown, { Components } from 'react-markdown' import remarkGfm from 'remark-gfm' import remarkEmoji from 'remark-emoji' import remarkMath from 'remark-math' import remarkBreaks from 'remark-breaks' import rehypeKatex from 'rehype-katex' import { Prism as SyntaxHighlighter } from 'react-syntax-highlighter' import * as prismStyles from 'react-syntax-highlighter/dist/cjs/styles/prism' import { memo, useState, useMemo, useCallback } from 'react' import { getReadableLanguageName } from '@/lib/utils' import { cn } from '@/lib/utils' import { useCodeblock } from '@/hooks/useCodeblock' import 'katex/dist/katex.min.css' import { IconCopy, IconCopyCheck } from '@tabler/icons-react' import rehypeRaw from 'rehype-raw' import { useTranslation } from '@/i18n/react-i18next-compat' interface MarkdownProps { content: string className?: string components?: Components enableRawHtml?: boolean isUser?: boolean isWrapping?: boolean } // Cache for normalized LaTeX content const latexCache = new Map() /** * Optimized preprocessor: normalize LaTeX fragments into $ / $$. * Uses caching to avoid reprocessing the same content. */ const normalizeLatex = (input: string): string => { // Check cache first if (latexCache.has(input)) { return latexCache.get(input)! } const segments = input.split(/(```[\s\S]*?```|`[^`]*`|<[^>]+>)/g) const result = segments .map((segment) => { if (!segment) return '' // Skip code blocks, inline code, html tags if (/^```[\s\S]*```$/.test(segment)) return segment if (/^`[^`]*`$/.test(segment)) return segment if (/^<[^>]+>$/.test(segment)) return segment let s = segment // --- Display math: \[...\] surrounded by newlines s = s.replace( /(^|\n)\\\[\s*\n([\s\S]*?)\n\s*\\\](?=\n|$)/g, (_, pre, inner) => `${pre}$$\n${inner.trim()}\n$$` ) // --- Inline math: space \( ... \) s = s.replace( /(^|[^$\\])\\\((.+?)\\\)(?=[^$\\]|$)/g, (_, pre, inner) => `${pre}$${inner.trim()}$` ) return s }) .join('') // Cache the result (with size limit to prevent memory leaks) if (latexCache.size > 100) { const firstKey = latexCache.keys().next().value || '' latexCache.delete(firstKey) } latexCache.set(input, result) return result } // Memoized code component to prevent unnecessary re-renders const CodeComponent = memo( ({ className, children, isUser, codeBlockStyle, showLineNumbers, isWrapping, onCopy, copiedId, ...props // eslint-disable-next-line @typescript-eslint/no-explicit-any }: any) => { const { t } = useTranslation() const match = /language-(\w+)/.exec(className || '') const language = match ? match[1] : '' const isInline = !match || !language const code = String(children).replace(/\n$/, '') // Generate a stable ID based on content hash instead of position const codeId = useMemo(() => { let hash = 0 for (let i = 0; i < code.length; i++) { const char = code.charCodeAt(i) hash = (hash << 5) - hash + char hash = hash & hash // Convert to 32-bit integer } return `code-${Math.abs(hash)}-${language}` }, [code, language]) const handleCopyClick = useCallback( (e: React.MouseEvent) => { e.stopPropagation() onCopy(code, codeId) }, [code, codeId, onCopy] ) if (isInline || isUser) { return {children} } return (
{getReadableLanguageName(language)}
index === 0 ? part : part.charAt(0).toUpperCase() + part.slice(1) ) .join('') as keyof typeof prismStyles ] || prismStyles.oneLight } language={language} showLineNumbers={showLineNumbers} wrapLines={true} lineProps={ isWrapping ? { style: { wordBreak: 'break-all', whiteSpace: 'pre-wrap' }, } : {} } customStyle={{ margin: 0, padding: '8px', borderRadius: '0 0 4px 4px', overflow: 'auto', border: 'none', }} PreTag="div" CodeTag={'code'} {...props} > {code}
) } ) CodeComponent.displayName = 'CodeComponent' function RenderMarkdownComponent({ content, enableRawHtml, className, isUser, components, isWrapping, }: MarkdownProps) { const { codeBlockStyle, showLineNumbers } = useCodeblock() // State for tracking which code block has been copied const [copiedId, setCopiedId] = useState(null) // Memoized copy handler const handleCopy = useCallback((code: string, id: string) => { navigator.clipboard.writeText(code) setCopiedId(id) // Reset copied state after 2 seconds setTimeout(() => { setCopiedId(null) }, 2000) }, []) // Memoize the normalized content to avoid reprocessing on every render const normalizedContent = useMemo(() => normalizeLatex(content), [content]) // Stable remarkPlugins reference const remarkPlugins = useMemo(() => { const basePlugins = [remarkGfm, remarkMath, remarkEmoji] if (isUser) { basePlugins.push(remarkBreaks) } return basePlugins }, [isUser]) // Stable rehypePlugins reference const rehypePlugins = useMemo(() => { return enableRawHtml ? [rehypeKatex, rehypeRaw] : [rehypeKatex] }, [enableRawHtml]) // Memoized components with stable references const markdownComponents: Components = useMemo( () => ({ code: (props) => ( ), // Add other optimized components if needed ...components, }), [ isUser, codeBlockStyle, showLineNumbers, isWrapping, handleCopy, copiedId, components, ] ) // Render the markdown content return (
{normalizedContent}
) } export const RenderMarkdown = memo( RenderMarkdownComponent, (prevProps, nextProps) => prevProps.content === nextProps.content )