import * as React from 'react' import ReactMarkdown, { type Components, defaultUrlTransform } from 'react-markdown' import rehypeRaw from 'rehype-raw' import rehypeSanitize, { defaultSchema } from 'rehype-sanitize' import remarkGfm from 'remark-gfm' import { FileText, Download } from 'lucide-react' import { cn } from '@multica/ui/lib/utils' import { CodeBlock, InlineCode } from './CodeBlock' import { preprocessFileCards } from './file-cards' import { preprocessLinks } from './linkify' import { preprocessMentionShortcodes } from './mentions' /** * Render modes for markdown content: * * - 'terminal': Raw output with minimal formatting, control chars visible * Best for: Debug output, raw logs, when you want to see exactly what's there * * - 'minimal': Clean rendering with syntax highlighting but no extra chrome * Best for: Chat messages, inline content, when you want readability without clutter * * - 'full': Rich rendering with beautiful tables, styled code blocks, proper typography * Best for: Documentation, long-form content, when presentation matters */ export type RenderMode = 'terminal' | 'minimal' | 'full' export interface MarkdownProps { children: string /** * Render mode controlling formatting level * @default 'minimal' */ mode?: RenderMode className?: string /** * Message ID for memoization (optional) * When provided, memoizes parsed blocks to avoid re-parsing during streaming */ id?: string /** * Callback when a URL is clicked */ onUrlClick?: (url: string) => void /** * Callback when a file path is clicked */ onFileClick?: (path: string) => void /** * Custom renderer for mention links (e.g. mention://issue/UUID). * When not provided, mentions render as a simple styled span. */ renderMention?: (props: { type: string; id: string }) => React.ReactNode /** * CDN hostname for file card detection (e.g. "multica-static.copilothub.ai"). * When provided, enables file card preprocessing and rendering. */ cdnDomain?: string } // Sanitization schema — extends GitHub defaults to allow code highlighting classes // and the mention:// protocol used for @mentions. const sanitizeSchema = { ...defaultSchema, protocols: { ...defaultSchema.protocols, href: [...(defaultSchema.protocols?.href ?? []), 'mention'], }, attributes: { ...defaultSchema.attributes, div: [ ...(defaultSchema.attributes?.div ?? []), 'dataType', 'dataHref', 'dataFilename', ], code: [ ...(defaultSchema.attributes?.code ?? []), ['className', /^language-/], ['className', /^hljs/], ], img: [ ...(defaultSchema.attributes?.img ?? []), 'alt', ], }, } /** * Custom URL transform that allows mention:// protocol (used for @mentions) * while keeping the default security for all other URLs. */ function urlTransform(url: string): string { if (url.startsWith('mention://')) return url return defaultUrlTransform(url) } // File path detection regex - matches paths starting with /, ~/, or ./ const FILE_PATH_REGEX = /^(?:\/|~\/|\.\/)[\w\-./@]+\.(?:ts|tsx|js|jsx|mjs|cjs|md|json|yaml|yml|py|go|rs|css|scss|less|html|htm|txt|log|sh|bash|zsh|swift|kt|java|c|cpp|h|hpp|rb|php|xml|toml|ini|cfg|conf|env|sql|graphql|vue|svelte|astro|prisma)$/i /** * Create custom components based on render mode */ function createComponents( mode: RenderMode, onUrlClick?: (url: string) => void, onFileClick?: (path: string) => void, renderMention?: (props: { type: string; id: string }) => React.ReactNode, ): Partial { const baseComponents: Partial = { // FileCard: intercept
from preprocessFileCards div: ({ node, children, ...props }) => { const dataType = node?.properties?.dataType as string | undefined if (dataType === 'fileCard') { const rawHref = (node?.properties?.dataHref as string) || '' // Only allow http(s) URLs to prevent javascript: and other dangerous schemes. const href = /^https?:\/\//i.test(rawHref) ? rawHref : '' const filename = (node?.properties?.dataFilename as string) || '' return (

{filename}

{href && ( )}
) } return
{children}
}, // Images: render uploaded images with constrained sizing img: ({ src, alt }) => ( {alt ), // Links: Make clickable with callbacks, or render as mention a: ({ href, children }) => { // Mention links: mention://member/id, mention://agent/id, mention://issue/id, mention://all/all if (href?.startsWith('mention://')) { const mentionMatch = href.match(/^mention:\/\/(member|agent|issue|all)\/(.+)$/) if (mentionMatch?.[1] && mentionMatch[2]) { const type = mentionMatch[1] const id = mentionMatch[2] if (renderMention) { // Let the custom renderer opt out for types it doesn't handle // by returning null/undefined — we then fall through to the // default styled span so nothing ever disappears silently. const rendered = renderMention({ type, id }) if (rendered) return <>{rendered} } // Fallback: render as a simple styled span return ( {children} ) } return ( {children} ) } const handleClick = (e: React.MouseEvent): void => { e.preventDefault() if (href) { // Check if it's a file path if (FILE_PATH_REGEX.test(href) && onFileClick) { onFileClick(href) } else if (onUrlClick) { onUrlClick(href) } else { // Default: open in new window window.open(href, '_blank', 'noopener,noreferrer') } } } return ( {children} ) } } // Terminal mode: minimal formatting if (mode === 'terminal') { return { ...baseComponents, // No special code handling - just monospace code: ({ children }) => {children}, pre: ({ children }) =>
{children}
, // Minimal paragraph spacing p: ({ children }) =>

{children}

, // Simple lists ul: ({ children }) =>
    {children}
, ol: ({ children }) =>
    {children}
, li: ({ children }) =>
  • {children}
  • , // Plain tables table: ({ children }) => {children}
    , th: ({ children }) => {children}, td: ({ children }) => {children} } } // Minimal mode: clean with syntax highlighting if (mode === 'minimal') { return { ...baseComponents, // Inline code code: ({ className, children, ...props }) => { const match = /language-(\w+)/.exec(className || '') const isBlock = 'node' in props && props.node?.position?.start.line !== props.node?.position?.end.line // Block code - use CodeBlock with full mode if (match || isBlock) { const code = String(children).replace(/\n$/, '') return } // Inline code return {children} }, pre: ({ children }) => <>{children}, // Comfortable paragraph spacing p: ({ children }) =>

    {children}

    , // Styled lists ul: ({ children }) => (
      {children}
    ), ol: ({ children }) =>
      {children}
    , li: ({ children }) =>
  • {children}
  • , // Clean tables table: ({ children }) => (
    {children}
    ), thead: ({ children }) => {children}, th: ({ children }) => ( {children} ), td: ({ children }) => {children}, // Headings - H1/H2 same size, differentiated by weight h1: ({ children }) =>

    {children}

    , h2: ({ children }) => (

    {children}

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

    {children}

    ), // Blockquotes blockquote: ({ children }) => (
    {children}
    ), // Horizontal rules hr: () =>
    , // Strong/emphasis strong: ({ children }) => {children}, em: ({ children }) => {children} } } // Full mode: rich styling return { ...baseComponents, // Full code blocks with copy button code: ({ className, children, ...props }) => { const match = /language-(\w+)/.exec(className || '') const isBlock = 'node' in props && props.node?.position?.start.line !== props.node?.position?.end.line if (match || isBlock) { const code = String(children).replace(/\n$/, '') return } return {children} }, pre: ({ children }) => <>{children}, // Rich paragraph spacing p: ({ children }) =>

    {children}

    , // Styled lists ul: ({ children }) => (
      {children}
    ), ol: ({ children }) =>
      {children}
    , li: ({ children }) =>
  • {children}
  • , // Beautiful tables table: ({ children }) => (
    {children}
    ), thead: ({ children }) => {children}, tbody: ({ children }) => {children}, th: ({ children }) => {children}, td: ({ children }) => {children}, tr: ({ children }) => {children}, // Rich headings h1: ({ children }) =>

    {children}

    , h2: ({ children }) => (

    {children}

    ), h3: ({ children }) =>

    {children}

    , h4: ({ children }) =>

    {children}

    , // Styled blockquotes blockquote: ({ children }) => (
    {children}
    ), // Task lists (GFM) input: ({ type, checked }) => { if (type === 'checkbox') { return ( ) } return }, // Horizontal rules hr: () =>
    , // Strong/emphasis strong: ({ children }) => {children}, em: ({ children }) => {children}, del: ({ children }) => {children} } } /** * Markdown - Customizable markdown renderer with multiple render modes * * Features: * - Three render modes: terminal, minimal, full * - Syntax highlighting via Shiki * - GFM support (tables, task lists, strikethrough) * - Clickable links and file paths * - Memoization for streaming performance * - Pluggable mention rendering via renderMention prop */ export function Markdown({ children, mode = 'minimal', className, onUrlClick, onFileClick, renderMention, cdnDomain }: MarkdownProps): React.JSX.Element { const components = React.useMemo( () => createComponents(mode, onUrlClick, onFileClick, renderMention), [mode, onUrlClick, onFileClick, renderMention] ) // Preprocess: convert mention shortcodes, raw URLs, and file cards to renderable content const processedContent = React.useMemo( () => { let result = preprocessMentionShortcodes(children) result = preprocessLinks(result) result = preprocessFileCards(result, cdnDomain ?? '') return result }, [children, cdnDomain] ) return (
    {processedContent}
    ) } /** * MemoizedMarkdown - Optimized for streaming scenarios * * Splits content into blocks and memoizes each block separately, * so only new/changed blocks re-render during streaming. */ export const MemoizedMarkdown = React.memo(Markdown, (prevProps, nextProps) => { // If id is provided, use it for memoization if (prevProps.id && nextProps.id) { return ( prevProps.id === nextProps.id && prevProps.children === nextProps.children && prevProps.mode === nextProps.mode ) } // Otherwise compare content and mode return prevProps.children === nextProps.children && prevProps.mode === nextProps.mode }) MemoizedMarkdown.displayName = 'MemoizedMarkdown'