Markdown.tsx 9.46 KB
import React, {useState, useMemo, memo, useEffect} from "react";
import './base.less'
import './style.less'
import ReactMarkdown from 'react-markdown'
import { flow } from 'lodash-es'
import 'katex/dist/katex.min.css'
import RemarkMath from 'remark-math'
import RemarkBreaks from 'remark-breaks'
import RehypeKatex from 'rehype-katex'
import RemarkGfm from 'remark-gfm'
import RehypeRaw from 'rehype-raw'
import SyntaxHighlighter from 'react-syntax-highlighter'


import MarkdownButton from './markdown-button'
import MarkdownForm from './markdown-form'
import ThinkBlock from './markdown-block'
import VideoGallery from './videoGallery'

interface MarkdownProps {
    content: string
}

const capitalizationLanguageNameMap: Record<string, string> = {
    sql: 'SQL',
    javascript: 'JavaScript',
    java: 'Java',
    typescript: 'TypeScript',
    vbscript: 'VBScript',
    css: 'CSS',
    html: 'HTML',
    xml: 'XML',
    php: 'PHP',
    python: 'Python',
    yaml: 'Yaml',
    mermaid: 'Mermaid',
    markdown: 'MarkDown',
    makefile: 'MakeFile',
    echarts: 'ECharts',
    shell: 'Shell',
    powershell: 'PowerShell',
    json: 'JSON',
    latex: 'Latex',
    svg: 'SVG',
}

const getCorrectCapitalizationLanguageName = (language: string) => {
    if (!language)
        return 'Plain'

    if (language in capitalizationLanguageNameMap)
        return capitalizationLanguageNameMap[language]

    return language.charAt(0).toUpperCase() + language.substring(1)
}


const preprocessThinkTag = (content: string) => {
    return flow([
        (str: string) => str.replace('<think>\n', '<details data-think=true>\n'),
        (str: string) => str.replace('\n</think>', '\n[ENDTHINKFLAG]</details>'),
    ])(content)
}

const preprocessLaTeX = (content: string) => {
    if (typeof content !== 'string')
        return content

    return flow([
        (str: string) => str.replace(/\\\[(.*?)\\\]/g, (_, equation) => `$$${equation}$$`),
        (str: string) => str.replace(/\\\[(.*?)\\\]/gs, (_, equation) => `$$${equation}$$`),
        (str: string) => str.replace(/\\\((.*?)\\\)/g, (_, equation) => `$$${equation}$$`),
        (str: string) => str.replace(/(^|[^\\])\$(.+?)\$/g, (_, prefix, equation) => `${prefix}$${equation}$`),
    ])(content)
}


const CodeBlock: any = memo(({ inline, className, children, ...props }: any) => {
    const [isSVG, setIsSVG] = useState<boolean>()
    const match = /language-(\w+)/.exec(className || '')
    const language = match?.[1]
    const languageShowName = getCorrectCapitalizationLanguageName(language || '')
    const chartData = useMemo(() => {
        if (language === 'echarts') {
            try {
                return JSON.parse(String(children).replace(/\n$/, ''))
            }
            catch (error) { }
        }
        return JSON.parse('{"title":{"text":"ECharts error - Wrong JSON format."}}')
    }, [language, children])

    useEffect(() => {
        setIsSVG(true)
    }, [])

    const renderCodeContent = useMemo(() => {
        const content = String(children).replace(/\n$/, '')
        if (language === 'mermaid' && isSVG) {
            // return <Flowchart PrimitiveCode={content} />
            return ''
        }
        else if (language === 'echarts') {
            // return (
            //     <div style={{ minHeight: '350px', minWidth: '100%', overflowX: 'scroll' }}>
            //         <ErrorBoundary>
            //             <ReactEcharts option={chartData} style={{ minWidth: '700px' }} />
            //         </ErrorBoundary>
            //     </div>
            // )
            return ''
        }
        else if (language === 'svg' && isSVG) {
            // return (
            //     <ErrorBoundary>
            //         <SVGRenderer content={content} />
            //     </ErrorBoundary>
            // )
            return ''
        }
        else {
            return (
                <SyntaxHighlighter
                    {...props}
                    customStyle={{
                        paddingLeft: 12,
                        borderBottomLeftRadius: '10px',
                        borderBottomRightRadius: '10px',
                        backgroundColor: 'var(--color-components-input-bg-normal)',
                    }}
                    language={match?.[1]}
                    showLineNumbers
                    PreTag="div"
                >
                    {content}
                </SyntaxHighlighter>
            )
        }
    }, [language, match, props, children, chartData, isSVG])

    if (inline || !match)
        return <code {...props} className={className}>{children}</code>

    return (
        <div className='relative'>
            <div className='flex h-8 items-center justify-between rounded-t-[10px] border-b border-divider-subtle bg-components-input-bg-normal p-1 pl-3'>
                <div className='system-xs-semibold-uppercase text-text-secondary'>{languageShowName}</div>
                <div className='flex items-center gap-1'>
                    {/* todo */}
                    {/*{(['mermaid', 'svg']).includes(language!) && <SVGBtn isSVG={isSVG} setIsSVG={setIsSVG} />}*/}
                    {/*<ActionButton>*/}
                    {/*    <CopyIcon content={String(children).replace(/\n$/, '')} />*/}
                    {/*</ActionButton>*/}
                </div>
            </div>
            {renderCodeContent}
        </div>
    )
})
CodeBlock.displayName = 'CodeBlock'


const VideoBlock: any = memo(({ node }: any) => {
    const srcs = node.children.filter((child: any) => 'properties' in child).map((child: any) => (child as any).properties.src)
    if (srcs.length === 0)
        return null
    return <VideoGallery key={srcs.join()} srcs={srcs} />
})
VideoBlock.displayName = 'VideoBlock'

const AudioBlock: any = memo(({ node }: any) => {
    const srcs = node.children.filter((child: any) => 'properties' in child).map((child: any) => (child as any).properties.src)
    if (srcs.length === 0)
        return null
    // return <AudioGallery key={srcs.join()} srcs={srcs} />
    return ''
})
AudioBlock.displayName = 'AudioBlock'

const ScriptBlock = memo(({ node }: any) => {
    const scriptContent = node.children[0]?.value || ''
    return `<script>${scriptContent}</script>`
})
ScriptBlock.displayName = 'ScriptBlock'

const Paragraph = (paragraph: any) => {
    const children_node = paragraph?.node?.children
    if (children_node && children_node?.[0] && 'tagName' in children_node?.[0] && children_node?.[0]?.tagName === 'img') {
        // return (
        //     <>
        //         <ImageGallery srcs={[children_node?.[0].properties?.src]} />
        //         {
        //             Array.isArray(paragraph?.children) ? <p>{paragraph?.children?.slice(1)}</p> : null
        //         }
        //     </>
        // )
        return ''
    }
    return <p>{paragraph?.children}</p>
}

const Img = ({ src }: any) => {
    return (<img src={src} />)
}

const Link = ({ node, ...props }: any) => {
    if (node.properties?.href && node.properties.href?.toString().startsWith('abbr')) {
        // const hidden_text = decodeURIComponent(node.properties.href.toString().split('abbr:')[1])
        return <abbr className="cursor-pointer underline !decoration-primary-700 decoration-dashed" title={node.children[0]?.value}>{node.children[0]?.value}</abbr>
    }
    else {
        return <a {...props} target="_blank" className="cursor-pointer underline !decoration-primary-700 decoration-dashed">{node.children[0] ? node.children[0]?.value : 'Download'}</a>
    }
}



const Markdown: React.FC<MarkdownProps> = (props) => {

    const latexContent = flow([
        preprocessThinkTag,
        preprocessLaTeX,
    ])(props?.content || '')

    return (
        <div className={'mark-down'}>
            <ReactMarkdown
                remarkPlugins={[
                    RemarkGfm,
                    [RemarkMath, { singleDollarTextMath: false }],
                    RemarkBreaks,
                ]}
                rehypePlugins={[
                    RehypeKatex,
                    RehypeRaw as any,
                    // The Rehype plug-in is used to remove the ref attribute of an element
                    () => {
                        return (tree) => {
                            const iterate = (node: any) => {
                                if (node.type === 'element' && node.properties?.ref)
                                    delete node.properties.ref

                                if (node.type === 'element' && !/^[a-z][a-z0-9]*$/i.test(node.tagName)) {
                                    node.type = 'text'
                                    node.value = `<${node.tagName}`
                                }

                                if (node.children)
                                    node.children.forEach(iterate)
                            }
                            tree.children.forEach(iterate)
                        }
                    },
                ]}
                disallowedElements={['iframe', 'head', 'html', 'meta', 'link', 'style', 'body']}
                components={{
                    code: CodeBlock,
                    img: Img,
                    video: VideoBlock,
                    audio: AudioBlock,
                    a: Link,
                    p: Paragraph,
                    button: MarkdownButton,
                    form: MarkdownForm,
                    script: ScriptBlock as any,
                    details: ThinkBlock,
                }}
            >
                {latexContent}
            </ReactMarkdown>
        </div>
    );
};

export default Markdown;