"use client"; import React, { useState, useMemo } from 'react'; import ReactMarkdown from 'react-markdown'; import remarkGfm from 'remark-gfm'; import remarkMath from 'remark-math'; import rehypeKatex from 'rehype-katex'; import rehypeRaw from 'rehype-raw'; import rehypeSlug from 'rehype-slug'; import rehypeAutolinkHeadings from 'rehype-autolink-headings'; import { Prism as SyntaxHighlighter } from 'react-syntax-highlighter'; import { vscDarkPlus, coy } from 'react-syntax-highlighter/dist/cjs/styles/prism'; import 'katex/dist/katex.min.css'; import { useTheme } from 'next-themes'; // --- TYPE DEFINITIONS --- interface MarkdownRendererProps { content: string; className?: string; } type MarkdownComponentProps = { node?: any; children?: React.ReactNode; className?: string; [key: string]: any; }; // --- PRE-PROCESSOR --- function preprocessMarkdown(content: string): string { if (!content) { return ""; } let processed = content.replace(/\[cite:\s*(\d+(?:,\s*\d+)*)\]/g, '[$1]'); return processed; } // --- HELPER: Copy to Clipboard --- const copyToClipboard = (text: string, onCopy: () => void) => { if (navigator.clipboard) { navigator.clipboard.writeText(text).then(onCopy).catch(err => { console.error('Async clipboard copy failed:', err); }); } else { const textArea = document.createElement('textarea'); textArea.value = text; textArea.style.position = 'fixed'; textArea.style.opacity = '0'; document.body.appendChild(textArea); textArea.focus(); textArea.select(); try { document.execCommand('copy'); onCopy(); } catch (err) { console.error('Fallback execCommand copy failed:', err); } document.body.removeChild(textArea); } }; // --- CUSTOM RENDERER COMPONENTS --- const CodeBlock: React.FC = React.memo(({ node, inline, className, children }) => { const { theme } = useTheme(); const [isCopied, setIsCopied] = useState(false); const match = /language-(\w+)/.exec(className || ''); const lang = match ? match[1] : 'text'; const codeString = String(children).replace(/\n$/, ''); const handleCopy = () => { copyToClipboard(codeString, () => { setIsCopied(true); setTimeout(() => setIsCopied(false), 2000); }); }; if (inline) { return ( {children} ); } return ( {lang} {isCopied ? ( <> Copied! > ) : ( <> Copy > )} 1} wrapLines={true} customStyle={{ margin: 0, padding: '1rem', background: 'transparent', fontSize: '0.9rem', }} codeTagProps={{ style: { fontFamily: '"JetBrains Mono", "Fira Code", monospace', lineHeight: 1.6, }, }} lineNumberStyle={{ minWidth: '2.5em', paddingRight: '1em', textAlign: 'right', userSelect: 'none', color: theme === 'dark' ? '#6b7280' : '#9ca3af', }} > {codeString} ); }); CodeBlock.displayName = 'CodeBlock'; const CustomImage: React.FC> = React.memo(({ src, alt, title, ...props }) => { const [isZoomed, setIsZoomed] = useState(false); return ( setIsZoomed(true)} loading="lazy" {...props} /> {title && {title}} {isZoomed && ( setIsZoomed(false)} > )} ); }); CustomImage.displayName = 'CustomImage'; // --- MAIN RENDERER COMPONENT --- const MarkdownRenderer: React.FC = ({ content, className = '' }) => { const processedContent = useMemo(() => preprocessMarkdown(content), [content]); const components = useMemo(() => ({ pre: ({ node, ...props }: MarkdownComponentProps) => { const childrenArray = React.Children.toArray(props.children); const child = childrenArray[0]; if (React.isValidElement(child) && (child.props as any)?.className?.includes('language-')) { return ; } return ; }, code: ({ node, inline, className, children, ...props }: MarkdownComponentProps) => { return {children}; }, div: ({ className, children, ...props }: MarkdownComponentProps) => { if (className?.includes('math-display')) { return {children}; } return {children}; }, img: CustomImage, h1: ({node, ...props}: MarkdownComponentProps) => , h2: ({node, ...props}: MarkdownComponentProps) => , h3: ({node, ...props}: MarkdownComponentProps) => , h4: ({node, ...props}: MarkdownComponentProps) => , p: ({node, ...props}: MarkdownComponentProps) => , a: ({node, ...props}: MarkdownComponentProps) => , blockquote: ({node, ...props}: MarkdownComponentProps) => , ul: ({node, ...props}: MarkdownComponentProps) => , ol: ({node, ...props}: MarkdownComponentProps) => , li: ({node, ...props}: MarkdownComponentProps) => , table: ({node, ...props}: MarkdownComponentProps) => ( ), thead: ({node, ...props}: MarkdownComponentProps) => , th: ({node, ...props}: MarkdownComponentProps) => ( ), td: ({node, ...props}: MarkdownComponentProps) => ( ), hr: ({node, ...props}: MarkdownComponentProps) => , }), []); const remarkPlugins = [remarkGfm, remarkMath]; const rehypePlugins: any[] = [ rehypeRaw, rehypeSlug, [rehypeAutolinkHeadings, { behavior: 'wrap' }], [rehypeKatex, { throwOnError: false, errorColor: '#EF4444', strict: false, output: 'html', macros: { "\\vec": "\\overrightarrow{#1}", "\\hat": "\\hat{#1}", "\\perp": "\\perp", "\\ihat": "\\hat{\\imath}", "\\jhat": "\\hat{\\jmath}", "\\khat": "\\hat{k}", "\\R": "\\mathbb{R}", "\\N": "\\mathbb{N}", "\\Z": "\\mathbb{Z}", "\\Q": "\\mathbb{Q}", "\\C": "\\mathbb{C}" } }], ]; return ( <> {processedContent} > ); }; export default React.memo(MarkdownRenderer);
{children}
{title}