"use client" import katex from "katex" import React, { useCallback } from "react" import ReactMarkdown, { Components } from "react-markdown" import remarkGfm from "remark-gfm" import "katex/dist/katex.min.css" import { TableRowCard } from "../cards/table-row-card" import { Accordion } from "./accordion" import Prism from "prismjs" import rehypeRaw from "rehype-raw" import "prismjs/themes/prism-tomorrow.css" import "prismjs/components/prism-javascript" import "prismjs/components/prism-typescript" import "prismjs/components/prism-jsx" import "prismjs/components/prism-tsx" import "prismjs/components/prism-json" import "prismjs/components/prism-bash" import "prismjs/components/prism-css" import "prismjs/components/prism-markdown" import "prismjs/components/prism-yaml" import "prismjs/components/prism-python" import "prismjs/components/prism-rust" import "prismjs/components/prism-solidity" import { AppLink } from "../app-link" import { Icons } from "../icons" const SCROLL_OFFSET = 150 const scrollToElementWithOffset = (target: HTMLElement) => { const rect = target.getBoundingClientRect() const targetPosition = rect.top + window.pageYOffset - SCROLL_OFFSET // Set margin before scrolling target.style.scrollMarginTop = `${SCROLL_OFFSET}px` requestAnimationFrame(() => { window.scrollTo({ top: targetPosition, behavior: "smooth", }) }) } // Helper function to convert double dollar math blocks to a custom markdown syntax const preprocessMathBlocks = (content: string): string => { // Replace $$...$$ with a custom markdown syntax that won't interfere with footnotes return content.replace(/\$\$([\s\S]*?)\$\$/g, (match, mathContent) => { const encodedMath = Buffer.from(mathContent.trim()).toString("base64") return `\n\n\n\n` }) } // Custom component for math blocks const MathBlockComponent = ({ "data-math": dataMath, ...props }: any) => { const mathContent = React.useMemo(() => { try { return Buffer.from(dataMath, "base64").toString("utf8") } catch { return "" } }, [dataMath]) return } interface CustomComponents extends Components { "table-row-card": React.ComponentType<{ node: any children: string }> accordion: React.ComponentType<{ node: any children: string }> "footnote-ref": React.ComponentType<{ identifier: string label: string }> "footnote-definition": React.ComponentType<{ identifier: string label: string children: React.ReactNode }> footnotes: React.ComponentType<{ children: React.ReactNode }> } const generateSectionId = (text: string) => { return text .toLowerCase() .replace(/[']/g, "") .replace(/[^a-z0-9]+/g, "-") .replace(/^-+|-+$/g, "") } export const createMarkdownElement = ( tag: keyof JSX.IntrinsicElements, props: any ) => React.createElement(tag, { ...props, ref: (node: HTMLElement | null) => { if (node && node.textContent) { node.setAttribute( "data-section-id", generateSectionId(node.textContent) ) } }, }) const Table = (props: any) => { return (
{props.children}
) } const TableRow = (props: any) => { return {props.children} } const TableHead = (props: any) => { if (!props.children || props.children.length === 0) { return null } const isEmpty = React.Children.toArray(props.children).every((child: any) => { if (!child.props || !child.props.children) return true if (child.props.children) { const thChildren = React.Children.toArray(child.props.children) if (thChildren.length === 0) return true return thChildren.every((thChild: any) => { if (!thChild) return true if (typeof thChild === "string") return thChild.trim() === "" if (!thChild.props || !thChild.props.children) return true if (typeof thChild.props.children === "string") return thChild.props.children.trim() === "" return false }) } return true }) if (isEmpty) { return null } return {props.children} } const TableBody = (props: any) => { if (!props.children || props.children.length === 0) { return null } return {props.children} } const remarkCustomNewlines = () => { return (tree: any) => { const visit = (node: any) => { if (node.type === "text" && typeof node.value === "string") { if (node.value.includes("/n")) { const parts = node.value.split("/n") const newChildren: any[] = [] parts.forEach((part: string, index: number) => { newChildren.push({ type: "text", value: part }) if (index < parts.length - 1) { newChildren.push({ type: "break" }) } }) return newChildren } } if (node.children) { const newChildren: any[] = [] for (const child of node.children) { const result = visit(child) if (Array.isArray(result)) { newChildren.push(...result) } else if (result) { newChildren.push(result) } else { newChildren.push(child) } } node.children = newChildren } return node } return visit(tree) } } // Custom math components const KaTeXBlock = ({ math }: { math: string }) => { const mathRef = React.useRef(null) React.useEffect(() => { if (mathRef.current) { try { const processedMath = preprocessMathContent(math.trim()) katex.render(processedMath, mathRef.current, { displayMode: true, throwOnError: false, }) } catch (error) { console.error("KaTeX block render error:", error) if (mathRef.current) { mathRef.current.textContent = math } } } }, [math]) return
} const KaTeXInline = ({ math }: { math: string }) => { const mathRef = React.useRef(null) React.useEffect(() => { if (mathRef.current) { try { // Pre-process the math content to replace "mod" with "\pmod" const processedMath = preprocessMathContent(math.trim()) katex.render(processedMath, mathRef.current, { displayMode: false, throwOnError: false, }) } catch (error) { console.error("KaTeX inline render error:", error) if (mathRef.current) { mathRef.current.textContent = math } } } }, [math]) return } // Preprocess math content to improve rendering const preprocessMathContent = (mathContent: string): string => { // Replace pattern "x ; mod ; q" or "x \mod q" with "\pmod{q}" const modRegex = /([^\\])(;?\s*mod\s*;?\s*)([^{])/g const processedMath = mathContent.replace( modRegex, (match, before, mod, after) => { return `${before}\\pmod{${after}}` } ) // Also handle cases like "\mod q" or "\mod{q}" const modCommandRegex = /\\mod\s+([^{])/g return processedMath.replace(modCommandRegex, (match, after) => { return `\\pmod{${after}}` }) } // Define a simple regex to extract math content from our markdown const extractMathBlocks = (content: string) => { const mathBlocks: { start: number end: number content: string isBlock: boolean }[] = [] const blockMathRegex = /\$\$([\s\S]*?)\$\$/g let match: RegExpExecArray | null while ((match = blockMathRegex.exec(content)) !== null) { mathBlocks.push({ start: match.index, end: match.index + match[0].length, content: match[1], isBlock: true, }) } const inlineMathRegex = /\$(.*?)\$/g let inlineMatch: RegExpExecArray | null while ((inlineMatch = inlineMathRegex.exec(content)) !== null) { // Make sure this isn't already part of a block math section const isInsideBlockMath = mathBlocks.some( (block) => inlineMatch!.index > block.start && inlineMatch!.index < block.end ) if (!isInsideBlockMath) { mathBlocks.push({ start: inlineMatch.index, end: inlineMatch.index + inlineMatch[0].length, content: inlineMatch[1], isBlock: false, }) } } mathBlocks.sort((a, b) => a.start - b.start) return mathBlocks } // Helper to check if text contains unescaped math delimiters const containsMath = (text: string): boolean => { // Skip markdown links if (text.match(/\[.*?\]\(.*?\)/)) { return false } if (!text.includes("$")) return false // Check for currency pattern first const currencyPattern = /\$\s*\d+(?:,\d{3})*(?:\.\d{2})?(?!\^|\{|\}|\d)/g if (text.match(currencyPattern) && !text.match(/\$.*[\^_{}].*\$/)) { return false } const blockMathRegex = /\$\$([\s\S]*?)\$\$/g const inlineMathRegex = /(? { const parts: React.ReactNode[] = [] let lastIndex = 0 try { // First handle block math ($$...$$) const blockMathRegex = /\$\$([\s\S]*?)\$\$/g let match: RegExpExecArray | null while ((match = blockMathRegex.exec(text))) { if (match.index > lastIndex) { parts.push(text.slice(lastIndex, match.index)) } const mathContent = match[1].trim() parts.push( ) lastIndex = match.index + match[0].length } // Then handle remaining text for inline math ($...$) const remainingText = text.slice(lastIndex) if (remainingText) { const inlineParts: React.ReactNode[] = [] let inlineLastIndex = 0 const inlineMathRegex = /(? inlineLastIndex) { inlineParts.push( remainingText.slice(inlineLastIndex, inlineMatch.index) ) } const mathContent = inlineMatch[1].trim() inlineParts.push( ) inlineLastIndex = inlineMatch.index + inlineMatch[0].length } if (inlineLastIndex < remainingText.length) { inlineParts.push(remainingText.slice(inlineLastIndex)) } parts.push(...inlineParts) } return <>{parts} } catch (error) { console.error("Error processing text with math:", error) return <>{text} } } const rehypeProcessBrTags = () => { return (tree: any) => { const visit = (node: any) => { if ( node.type === "element" && (node.tagName === "td" || node.tagName === "th") ) { // Look for text nodes that contain \n or
and convert them if (node.children) { for (let i = 0; i < node.children.length; i++) { const child = node.children[i] if (child.type === "text" && child.value) { if (child.value.includes("\n")) { const parts = child.value.split("\n") const newChildren: any[] = [] parts.forEach((part: string, index: number) => { newChildren.push({ type: "text", value: part }) // Add
element except after the last part if (index < parts.length - 1) { newChildren.push({ type: "element", tagName: "br", properties: {}, children: [], }) } }) node.children.splice(i, 1, ...newChildren) // Adjust i based on the new length of children we've inserted i += newChildren.length - 1 } } } } } if (node.children) { node.children.forEach(visit) } return node } return visit(tree) } } const rehypeStyleAside = () => { return (tree: any) => { const visit = (node: any) => { if (node.type === "element" && node.tagName === "aside") { if (!node.properties) { node.properties = {} } if (!node.properties.className) { node.properties.className = [] } const classes = Array.isArray(node.properties.className) ? node.properties.className : [node.properties.className] node.properties.className = [ ...classes, "my-6", "p-4", "rounded-lg", "bg-tuatara-50", "dark:bg-tuatara-900", "border-l-4", "border-anakiwa-500", "dark:border-anakiwa-400", "[&>p]:text-tuatara-700", "dark:[&>p]:text-tuatara-200", "[&>p]:my-2", "[&>ul]:text-tuatara-700", "dark:[&>ul]:text-tuatara-200", "[&>ul]:my-2", "[&>strong]:text-tuatara-900", "dark:[&>strong]:text-tuatara-100", ] } if (node.children) { node.children.forEach(visit) } return node } return visit(tree) } } const CodeBlock = ({ className, children, }: { className?: string children: string }) => { const language = className ? className.replace(/language-/, "") : "text" const codeRef = React.useRef(null) React.useEffect(() => { if (codeRef.current) { Prism.highlightElement(codeRef.current) } }, [children]) return (
      
        {children}
      
    
) } const HeadingLink = ({ level, children, }: { level: number children: React.ReactNode }) => { const [copied, setCopied] = React.useState(false) const [showLink, setShowLink] = React.useState(false) const id = React.useMemo(() => { if (typeof children === "string") { return generateSectionId(children) } const text = React.Children.toArray(children) .map((child) => { if (typeof child === "string") return child if ( React.isValidElement(child) && typeof child.props?.children === "string" ) { return child.props.children } return "" }) .join("") return generateSectionId(text) }, [children]) const copyToClipboard = React.useCallback(() => { const url = new URL(window.location.href) url.hash = id navigator.clipboard.writeText(url.toString()) setCopied(true) setTimeout(() => setCopied(false), 2000) }, [id]) const headingClasses = React.useMemo(() => { switch (level) { case 1: return "text-4xl md:text-5xl font-bold" case 2: return "text-4xl" case 3: return "text-3xl" case 4: return "text-xl" case 5: return "text-lg font-bold" default: return "text-md font-bold" } }, [level]) const Component = React.createElement( `h${level}` as keyof JSX.IntrinsicElements, { id, className: `group relative flex items-center gap-2 text-primary ${headingClasses}`, onMouseEnter: () => setShowLink(true), onMouseLeave: () => setShowLink(false), }, <> {children} ) return Component } const REACT_MARKDOWN_CONFIG = (darkMode: boolean): CustomComponents => ({ a: ({ href, children, ...props }) => { if (href?.startsWith("#")) { return ( { e.preventDefault() const targetId = href.slice(1) const target = document.getElementById(targetId) if (target) { scrollToElementWithOffset(target) } }} > {children} ) } return ( {children} ) }, h1: ({ children }) => {children}, h2: ({ children }) => {children}, h3: ({ children }) => {children}, h4: ({ children }) => {children}, h5: ({ children }) => {children}, h6: ({ children }) => {children}, code: ({ node, inline, className, children, ...props }) => { const match = /language-(\w+)/.exec(className || "") return !inline ? ( {String(children).replace(/\n$/, "")} ) : ( {children} ) }, p: ({ node, children }: { node: any; children: React.ReactNode }) => { const childArray = React.Children.toArray(children) const isOnlyLink = childArray.length === 1 && React.isValidElement(childArray[0]) && childArray[0].type === "a" if (isOnlyLink) { return <>{children} } // For other paragraphs, continue with normal processing const text = childArray .map((child) => { if (typeof child === "string") return child if (React.isValidElement(child) && child.props?.children) { return child.props.children } return "" }) .join("") let isMathOnly = false if (text.trim().startsWith("$$") && text.trim().endsWith("$$")) { const innerContent = text.trim().slice(2, -2) if (!innerContent.includes("$$")) { isMathOnly = true } } if (containsMath(text)) { return (

) } return (

{children}

) }, // Handle math in list items li: ({ node, children, ...props }) => { const text = React.Children.toArray(children) .map((child) => { if (typeof child === "string") return child // @ts-expect-error - children props vary if (child?.props?.children) return child.props.children return "" }) .join("") if (containsMath(text)) { return (
  • ) } return (
  • {children}
  • ) }, ul: ({ ordered, ...props }) => createMarkdownElement(ordered ? "ol" : "ul", { className: "ml-6 list-disc text-tuatara-600 font-sans text-lg font-normal", ...props, }), ol: ({ ordered, ...props }) => createMarkdownElement(ordered ? "ol" : "ul", { className: "ml-6 list-decimal text-tuatara-600 font-sans text-lg font-normal mt-3 dark:text-tuatara-200", ...props, }), table: Table, tr: TableRow, thead: TableHead, tbody: TableBody, "table-row-card": ({ node, children }: { node: any; children: string }) => { try { const content = JSON.parse(children) return } catch (error) { console.error("Error parsing table-row-card content:", error) return
    Error rendering table row card
    } }, accordion: ({ node, children }: { node: any; children: string }) => { try { const content = JSON.parse(children) return ( ) } catch (error) { console.error("Error parsing accordion content:", error) return
    Error rendering accordion
    } }, td: (props) => { const { node, children, ...rest } = props // Convert children to text to check for math const text = React.Children.toArray(children) .map((child) => { if (typeof child === "string") return child // @ts-expect-error - children props vary if (child?.props?.children) return child.props.children return "" }) .join("") // Handle line breaks in table cells by replacing
    with actual line breaks const hasBrTags = typeof text === "string" && text.includes("
    ") // Check if there's HTML content with style attribute in the cell const hasHtmlContent = typeof text === "string" && (text.includes("
    ) } if (hasHtmlContent) { const processedText = hasBrTags ? text.replace(/
    /g, "
    ") : text return ( ) } return ( {children} ) }, th: (props) => { const { node, children, ...rest } = props if (!children || (Array.isArray(children) && children.length === 0)) { return null } // Convert children to text to check for math const text = React.Children.toArray(children) .map((child) => { if (typeof child === "string") return child // @ts-expect-error - children props vary if (child?.props?.children) return child.props.children return "" }) .join("") // Handle line breaks in table headers by replacing
    with actual line breaks const hasBrTags = typeof text === "string" && text.includes("
    ") // Check if there's HTML content with style attribute in the cell const hasHtmlContent = typeof text === "string" && (text.includes("
    ) } if (hasHtmlContent) { const processedText = hasBrTags ? text.replace(/
    /g, "
    ") : text return ( ) } return ( {children} ) }, img: ({ ...props }) => createMarkdownElement("img", { className: "w-auto w-auto mx-auto rounded-lg object-cover dark:bg-white dark:p-3", ...props, }), blockquote: ({ children }) => (
    {children}
    ), "footnote-ref": ({ identifier, label }) => { const handleClick = useCallback( (e: React.MouseEvent) => { e.preventDefault() e.stopPropagation() const target = document.getElementById(`fn-${identifier}`) if (target) { scrollToElementWithOffset(target) } }, [identifier] ) React.useEffect(() => { console.log("Footnote ref mounted:", identifier) }, [identifier]) return ( ) }, "footnote-definition": ({ identifier, label, children }) => { const handleBackClick = useCallback( (e: React.MouseEvent) => { e.preventDefault() e.stopPropagation() const target = document.getElementById(`fnref-${identifier}`) if (target) { scrollToElementWithOffset(target) } }, [identifier] ) React.useEffect(() => { console.log("Footnote definition mounted:", identifier) }, [identifier]) return (
    [{label}]
    {children}
    ) }, footnotes: ({ children }) => { return (

    Footnotes

    {children}
    ) }, }) interface MarkdownProps { children: string components?: Components // components overrides the default components darkMode?: boolean } export const Markdown = ({ children, components, darkMode = false, }: MarkdownProps) => { const [content, setContent] = React.useState([]) React.useEffect(() => { const timer = setTimeout(() => { const hash = window.location.hash if (hash) { const id = hash.slice(1) // Remove the # from the hash const element = document.getElementById(id) if (element) { scrollToElementWithOffset(element) } } }, 100) return () => clearTimeout(timer) }, [content]) React.useEffect(() => { if (!children) { setContent([]) return } try { // Preprocess the content to convert $$ blocks to custom components const processedContent = preprocessMathBlocks(children) const mathComponents = { ...REACT_MARKDOWN_CONFIG(darkMode), "math-block": MathBlockComponent, ...components, } const rehypePlugins = [ rehypeRaw as any, rehypeProcessBrTags as any, rehypeStyleAside as any, ] setContent([ {processedContent} , ]) } catch (error) { console.error("Error processing markdown with math:", error) setContent([ {children} , ]) } }, [children, darkMode, components]) return <>{content} }