/* eslint-disable @typescript-eslint/no-explicit-any */ import React, { useMemo } from "react"; import type { Components } from "react-markdown"; import ReactMarkdown from "react-markdown"; import type { ReactMarkdownProps } from "react-markdown/lib/ast-to-react"; import type { PluggableList } from "react-markdown/lib/react-markdown"; import remarkDirective from "remark-directive"; import remarkGfm from "remark-gfm"; import remarkMath from "remark-math"; import { visit } from "unist-util-visit"; import { Checkbox, Chip } from "@sparkle/components"; import { BlockquoteBlock } from "@sparkle/components/markdown/BlockquoteBlock"; import { CodeBlockWithExtendedSupport } from "@sparkle/components/markdown/CodeBlockWithExtendedSupport"; import { LiBlock, OlBlock, UlBlock } from "@sparkle/components/markdown/List"; import { MarkdownContentContext } from "@sparkle/components/markdown/MarkdownContentContext"; import { ParagraphBlock } from "@sparkle/components/markdown/ParagraphBlock"; import { PreBlock } from "@sparkle/components/markdown/PreBlock"; import { safeRehypeKatex } from "@sparkle/components/markdown/safeRehypeKatex"; import { TableBlock, TableBodyBlock, TableDataBlock, TableHeadBlock, TableHeaderBlock, } from "@sparkle/components/markdown/TableBlock"; import { preserveLineBreaks, sanitizeContent, } from "@sparkle/components/markdown/utils"; import { cn } from "@sparkle/lib/utils"; export const markdownHeaderClasses = { h1: "s-heading-2xl", h2: "s-heading-xl", h3: "s-heading-lg", h4: "s-text-base s-font-semibold", h5: "s-text-sm s-font-semibold", h6: "s-text-sm s-font-regular s-italic", }; const sizes = { p: "s-text-base s-leading-7", ...markdownHeaderClasses, }; function showUnsupportedDirective() { return (tree: any) => { visit(tree, ["textDirective"], (node) => { if (node.type === "textDirective") { // it's not a valid directive, so we'll leave it as plain text node.type = "text"; node.value = `:${node.name}${node.children ? node.children.map((c: any) => c.value).join("") : ""}`; } }); }; } export function Markdown({ content, isStreaming = false, textColor = "s-text-foreground dark:s-text-foreground-night", forcedTextSize, isLastMessage = false, compactSpacing = false, additionalMarkdownComponents, additionalMarkdownPlugins, canCopyQuotes = true, }: { content: string; isStreaming?: boolean; textColor?: string; isLastMessage?: boolean; compactSpacing?: boolean; // When true, removes vertical padding from paragraph blocks for tighter spacing forcedTextSize?: string; additionalMarkdownComponents?: Components; additionalMarkdownPlugins?: PluggableList; canCopyQuotes?: boolean; }) { const processedContent = useMemo(() => { let sanitized = sanitizeContent(content); if (compactSpacing) { sanitized = preserveLineBreaks(sanitized); } return sanitized; }, [content, compactSpacing]); // Note on re-renderings. A lot of effort has been put into preventing rerendering across markdown // AST parsing rounds (happening at each token being streamed). // // When adding a new directive and associated component that depends on external data (eg // workspace or message), you can use the customRenderer.visualization pattern. It is essential // for the customRenderer argument to be memoized to avoid re-renderings through the // markdownComponents memoization dependency on `customRenderer`. // // Make sure to spend some time understanding the re-rendering or lack thereof through the parser // rounds. // // Minimal test whenever editing this code: ensure that code block content of a streaming message // can be selected without blinking. // Memoize markdown components to avoid unnecessary re-renders that disrupt text selection const markdownComponents: Components = useMemo(() => { return { pre: ({ children }) => {children}, a: LinkBlock, ul: ({ children }) => ( {children} ), ol: ({ children, start }) => ( {children} ), li: ({ children }) => ( {children} ), p: ({ children }) => ( {children} ), table: TableBlock, thead: TableHeadBlock, tbody: TableBodyBlock, th: TableHeaderBlock, td: TableDataBlock, h1: ({ children }) => (

{children}

), h2: ({ children }) => (

{children}

), h3: ({ children }) => (

{children}

), h4: ({ children }) => (

{children}

), h5: ({ children }) => (
{children}
), h6: ({ children }) => (
{children}
), strong: ({ children }) => ( {children} ), input: Input, blockquote: ({ children }) => ( {children} ), hr: () => (
), code: CodeBlockWithExtendedSupport, ...additionalMarkdownComponents, }; }, [textColor, compactSpacing, additionalMarkdownComponents]); const markdownPlugins: PluggableList = useMemo( () => [ remarkDirective, remarkGfm, [remarkMath, { singleDollarTextMath: false }], ...(additionalMarkdownPlugins || []), showUnsupportedDirective, ], [additionalMarkdownPlugins] ); const rehypePlugins = [ [safeRehypeKatex, { output: "mathml" }], ] as PluggableList; try { return (
{processedContent}
); } catch (error) { return (
There was an error parsing this markdown content {processedContent}
); } } function LinkBlock({ href, children, }: { href?: string; children: React.ReactNode; }) { return ( {children} ); } type InputProps = Omit, "ref"> & ReactMarkdownProps & { ref?: React.Ref; }; function Input({ type, checked, className, onChange, ref, ...props }: InputProps) { const inputRef = React.useRef(null); React.useImperativeHandle(ref, () => inputRef.current!); if (type !== "checkbox") { return ( ); } const handleCheckedChange = (isChecked: boolean) => { onChange?.({ target: { type: "checkbox", checked: isChecked }, } as React.ChangeEvent); }; return (
} size="xs" checked={checked} className="s-translate-y-[3px]" onCheckedChange={handleCheckedChange} />
); }