import { marked } from 'marked' import { createContext, memo, useContext, useId, useMemo, useRef } from 'react' import ReactMarkdown from 'react-markdown' import rehypeRaw from 'rehype-raw' import rehypeSanitize from 'rehype-sanitize' import remarkBreaks from 'remark-breaks' import remarkGfm from 'remark-gfm' import { CodeBlock } from './code-block' import type { Components } from 'react-markdown' import { cn } from '@/lib/utils' /** * Rewrite Workspace-local `MEDIA:` tokens emitted by Hermes Agent to the * authenticated media endpoint. Messaging bridges intercept MEDIA tags before * rendering; the web chat sees raw markdown/HTML and needs this client-side * rewrite so browsers can load the file through Workspace instead of trying to * resolve a local filesystem path directly. */ export function rewriteLocalMediaSources(content: string): string { const rewritePath = (rawPath: string): string | null => { const path = rawPath.trim() if (!path || /^https?:\/\//i.test(path)) return null return `/api/media?path=${encodeURIComponent(path)}` } const markdownImage = /(!\[[^\]]*\]\()MEDIA:([^\)\s]+)(\))/g const withMarkdownImages = content.replace( markdownImage, (_match, prefix: string, mediaPath: string, suffix: string) => { const rewritten = rewritePath(mediaPath) return rewritten ? `${prefix}${rewritten}${suffix}` : `${prefix}MEDIA:${mediaPath}${suffix}` }, ) const htmlImage = /(]*\bsrc=)(["'])MEDIA:([^"']+)\2/gi return withMarkdownImages.replace( htmlImage, (_match, prefix: string, quote: string, mediaPath: string) => { const rewritten = rewritePath(mediaPath) return rewritten ? `${prefix}${quote}${rewritten}${quote}` : `${prefix}${quote}MEDIA:${mediaPath}${quote}` }, ) } export type MarkdownProps = { children: string id?: string className?: string components?: Partial } function parseMarkdownIntoBlocks(markdown: string): Array { const tokens = marked.lexer(markdown) return tokens.map((token) => token.raw) } function extractLanguage(className?: string): string { if (!className) return 'text' const match = className.match(/language-(\w+)/) return match ? match[1] : 'text' } type TableRenderContextValue = { headersRef: React.MutableRefObject> columnIndexRef: React.MutableRefObject collectingHeaderRef: React.MutableRefObject } const TableRenderContext = createContext(null) function useTableRenderContext() { return useContext(TableRenderContext) } function textFromNode(node: React.ReactNode): string { if (typeof node === 'string' || typeof node === 'number') { return String(node) } if (Array.isArray(node)) { return node.map((item: React.ReactNode) => textFromNode(item)).join('') } if (node && typeof node === 'object' && 'props' in node) { const element = node as { props: { children?: React.ReactNode } } return textFromNode(element.props.children) } return '' } function slugifyHeading(children: React.ReactNode): string { const raw = textFromNode(children) .trim() .toLowerCase() .replace(/[^\w\s-]/g, '') .replace(/\s+/g, '-') return raw.length > 0 ? raw : 'section' } const INITIAL_COMPONENTS: Partial = { code: function CodeComponent({ className, children }) { const isInline = !className?.includes('language-') if (isInline) { return ( {children} ) } const language = extractLanguage(className) return ( ) }, pre: function PreComponent({ children }) { return <>{children} }, h1: function H1Component({ children }) { return (

{children}

) }, h2: function H2Component({ children }) { const id = slugifyHeading(children) return (

{children}

) }, h3: function H3Component({ children }) { const id = slugifyHeading(children) return (

{children}

) }, h4: function H4Component({ children }) { return (

{children}

) }, h5: function H5Component({ children }) { return (
{children}
) }, h6: function H6Component({ children }) { return (
{children}
) }, p: function PComponent({ children }) { return (

{children}

) }, ul: function UlComponent({ children }) { return (
    {children}
) }, ol: function OlComponent({ children }) { return (
    {children}
) }, li: function LiComponent({ children }) { return
  • {children}
  • }, a: function AComponent({ children, href }) { if (!href) { return {children} } return ( {children} ) }, img: function ImgComponent({ src, alt, ...props }) { if (!src) { return null } return {alt }, blockquote: function BlockquoteComponent({ children }) { return (
    {children}
    ) }, strong: function StrongComponent({ children }) { return {children} }, em: function EmComponent({ children }) { return {children} }, hr: function HrComponent() { return
    }, table: function TableComponent({ children }) { const headersRef = useRef>([]) const columnIndexRef = useRef(0) const collectingHeaderRef = useRef(false) return (
    {children}
    ) }, thead: function TheadComponent({ children }) { const context = useTableRenderContext() if (context) { context.collectingHeaderRef.current = true context.columnIndexRef.current = 0 context.headersRef.current = [] } return ( {children} ) }, tbody: function TbodyComponent({ children }) { const context = useTableRenderContext() if (context) { context.collectingHeaderRef.current = false context.columnIndexRef.current = 0 } return ( {children} ) }, tr: function TrComponent({ children }) { const context = useTableRenderContext() if (context) { context.columnIndexRef.current = 0 } return ( {children} ) }, th: function ThComponent({ children }) { const context = useTableRenderContext() if (context) { const index = context.columnIndexRef.current context.columnIndexRef.current += 1 if (context.collectingHeaderRef.current) { context.headersRef.current[index] = textFromNode(children).trim() } } return ( {children} ) }, td: function TdComponent({ children }) { const context = useTableRenderContext() let label = '' if (context) { const index = context.columnIndexRef.current context.columnIndexRef.current += 1 label = context.headersRef.current[index] ?? `Column ${index + 1}` } return ( {children} ) }, tfoot: function TfootComponent({ children }) { return ( {children} ) }, } const HTML_SANITIZE_SCHEMA = { tagNames: [ 'a', 'abbr', 'article', 'b', 'bdi', 'blockquote', 'br', 'caption', 'center', 'cite', 'code', 'col', 'colgroup', 'data', 'dd', 'del', 'details', 'dfn', 'div', 'dl', 'dt', 'em', 'figcaption', 'figure', 'footer', 'h1', 'h2', 'h3', 'h4', 'h5', 'h6', 'header', 'hgroup', 'hr', 'i', 'img', 'ins', 'kbd', 'li', 'main', 'mark', 'nav', 'ol', 'p', 'pre', 'q', 'rp', 'rt', 'ruby', 's', 'samp', 'section', 'small', 'span', 'strong', 'sub', 'summary', 'sup', 'table', 'tbody', 'td', 'tfoot', 'th', 'thead', 'time', 'tr', 'u', 'ul', 'var', 'wbr', ], attributes: { '*': ['className', 'class', 'title', 'lang', 'dir'], a: ['href', 'target', 'rel', 'download'], img: ['src', 'alt', 'width', 'height', 'loading'], td: ['colspan', 'rowspan', 'headers'], th: ['colspan', 'rowspan', 'headers', 'scope'], col: ['span'], colgroup: ['span'], ol: ['start', 'type'], li: ['value'], details: ['open'], time: ['datetime'], data: ['value'], del: ['datetime'], ins: ['datetime'], }, protocols: { a: { href: ['http', 'https', 'mailto', 'tel'] }, img: { src: ['http', 'https', 'data'] }, }, } const MemoizedMarkdownBlock = memo( function MarkdownBlock({ content, components = INITIAL_COMPONENTS, }: { content: string components?: Partial }) { return ( {content} ) }, function propsAreEqual(prevProps, nextProps) { return prevProps.content === nextProps.content }, ) MemoizedMarkdownBlock.displayName = 'MemoizedMarkdownBlock' function MarkdownComponent({ children, id, className, components = INITIAL_COMPONENTS, }: MarkdownProps) { const generatedId = useId() const blockId = id ?? generatedId const blocks = useMemo( () => parseMarkdownIntoBlocks(rewriteLocalMediaSources(children)), [children], ) return (
    {blocks.map((block, index) => ( ))}
    ) } const Markdown = memo(MarkdownComponent) Markdown.displayName = 'Markdown' export { Markdown }