import type { Element } from 'hast'; import React from 'react'; import Markdown, { defaultUrlTransform } from 'react-markdown'; import rehypeKatex from 'rehype-katex'; import { defListHastHandlers, remarkDefinitionList } from 'remark-definition-list'; import remarkDirective from 'remark-directive'; import remarkGfm from 'remark-gfm'; import remarkAlert from 'remark-github-blockquote-alert'; import remarkMath from 'remark-math'; import remarkSupersub from 'remark-supersub'; import { SKIP, visit } from 'unist-util-visit'; import { CodeBlockHandlerProvider } from './CodeBlockContext'; import { createDefaultCodeBlockHandlers, ExpandCodeBlockHandler, isExpandLanguage, } from './codeBlockHandlers'; import { useCodeBlockRendererRegistry } from './CodeBlockRendering'; import { preprocessMathDelimiters } from './preprocessMathDelimiters'; import { MarkdownFigure } from './MarkdownFigure'; import { MarkdownImage } from './MarkdownImage'; import { MarkdownLink } from './MarkdownLink'; import { normalizeCustomSchemeLinks } from './normalizeCustomSchemeLinks'; import { normalizeDirectives } from './normalizeDirectives'; import { remarkDirectiveHandler } from './remarkDirectiveHandler'; // Custom URL schemes that we handle in our components const ALLOWED_CUSTOM_SCHEMES = [ 'artifact:', 'image:', 'store:', 'document:', 'document://', 'collection:', ]; /** * Custom URL transform that allows our custom schemes while using * the default transform for standard URLs (which sanitizes unsafe schemes). */ function customUrlTransform(url: string): string { if (ALLOWED_CUSTOM_SCHEMES.some(scheme => url.startsWith(scheme))) { return url; } return defaultUrlTransform(url); } /** * Remark plugin to remove HTML comments from markdown */ function remarkRemoveComments() { return (tree: any) => { visit(tree, 'html', (node: any, index: number | undefined, parent: any) => { if (node.value && //.test(node.value)) { if (parent && typeof index === 'number' && parent.children) { parent.children.splice(index, 1); return [SKIP, index]; } } }); }; } export interface MarkdownRendererProps { children: string; components?: any; remarkPlugins?: any[]; removeComments?: boolean; /** * Optional workflow run id used to resolve shorthand artifact paths (e.g. artifact:out/result.csv) */ artifactRunId?: string; /** Additional className for the markdown wrapper */ className?: string; /** Additional className for code blocks */ codeClassName?: string; /** Additional className for inline code */ inlineCodeClassName?: string; /** Additional className for links */ linkClassName?: string; /** Additional className for images */ imageClassName?: string; /** Callback when user selects a proposal option */ onProposalSelect?: (optionId: string) => void; /** Callback when user submits free-form response to proposal */ onProposalSubmit?: (response: string) => void; } // Create default handlers once, outside component const defaultCodeBlockHandlers = createDefaultCodeBlockHandlers(); export function MarkdownRenderer({ children, components, remarkPlugins = [], removeComments = true, artifactRunId, className, codeClassName, inlineCodeClassName, linkClassName, imageClassName, onProposalSelect, onProposalSubmit, }: MarkdownRendererProps) { const codeBlockRegistry = useCodeBlockRendererRegistry(); const normalizedMarkdown = React.useMemo( () => normalizeDirectives(normalizeCustomSchemeLinks(preprocessMathDelimiters(children))), [children] ); // Remark plugins (markdown parsing) // Order matters: GFM first, then directive (must precede handler), // then definition-list/supersub, then math, then user plugins. const remarkPluginsArray = React.useMemo(() => { const result: any[] = [ remarkGfm, remarkDirective, remarkDirectiveHandler, remarkAlert, remarkDefinitionList, remarkSupersub, remarkMath, ...remarkPlugins, ]; if (removeComments) { result.push(remarkRemoveComments); } return result; }, [remarkPlugins, removeComments]); // Rehype plugins (HTML processing, including KaTeX for math) const rehypePluginsArray = React.useMemo(() => [rehypeKatex], []); // Remark-rehype bridge options (custom AST handlers for definition lists) const remarkRehypeOptions = React.useMemo(() => ({ handlers: { ...defListHastHandlers, }, }), []); const componentsWithOverrides = React.useMemo(() => { const baseComponents = components || {}; const ExistingCode = baseComponents.code; const ExistingLink = baseComponents.a; const ExistingImg = baseComponents.img; const CodeComponent = ({ node, className: codeClassName_, children: codeChildren, ...props }: { node?: Element; className?: string; children?: React.ReactNode; }) => { const match = /language-([\w-]+)/.exec(codeClassName_ || ''); const isInline = !match; const language = match ? match[1] : ''; // Check registry for custom renderer (includes default handlers) if (!isInline && language) { // First check user-provided registry if (codeBlockRegistry) { const CustomComponent = codeBlockRegistry.getComponent(language); if (CustomComponent) { const code = String(codeChildren || '').trim(); return ; } } // Check for expand:* pattern (e.g., expand:chart, expand:table) if (isExpandLanguage(language)) { const code = String(codeChildren || '').trim(); return ; } // Then check default handlers (chart, vega-lite, mermaid, proposal, askuser) const DefaultHandler = defaultCodeBlockHandlers[language]; if (DefaultHandler) { const code = String(codeChildren || '').trim(); return ; } } // Delegate to existing code component if provided if (typeof ExistingCode === 'function') { return ( {codeChildren} ); } // Default code rendering const baseInlineClass = 'px-1.5 py-0.5 rounded'; const baseCodeClass = ''; return ( {codeChildren} ); }; const LinkComponent = (props: { node?: Element; href?: string; children?: React.ReactNode; }) => { const { node, href, children: linkChildren, ...rest } = props as any; return ( {linkChildren} ); }; const ImageComponent = (props: { node?: any; src?: string; alt?: string; title?: string }) => { const { node, src, alt, title, ...rest } = props as any; // If image has a title, render as figure with caption if (title) { return ( ); } return ( ); }; return { ...baseComponents, code: CodeComponent, a: LinkComponent, img: ImageComponent, }; }, [ components, artifactRunId, codeBlockRegistry, codeClassName, inlineCodeClassName, linkClassName, imageClassName, ]); const markdownContent = ( {normalizedMarkdown} ); if (className) { return
{markdownContent}
; } return
{markdownContent}
; }