import { Children, isValidElement, useEffect, useMemo, useState, type ReactNode, } from "react"; import type { Components, Options as ReactMarkdownOptions } from "react-markdown"; import ReactMarkdown from "react-markdown"; import rehypeKatex from "rehype-katex"; import { Check, Globe2 } from "lucide-react"; import remarkBreaks from "remark-breaks"; import remarkGfm from "remark-gfm"; import remarkMath from "remark-math"; import { AttachmentTile } from "@/components/AttachmentTile"; import { CodeBlock } from "@/components/CodeBlock"; import { useFilePreviewAvailabilityResolver, type FilePreviewAvailabilityResolver, } from "@/components/FilePreviewAvailabilityContext"; import { FileReferenceChip, isFilePatternReference, isLikelyFilePath, } from "@/components/FileReferenceChip"; import { useLogoFallback } from "@/hooks/useLogoFallback"; import { inferMediaKind } from "@/lib/media"; import { faviconUrls } from "@/lib/provider-brand"; import { remarkTexMath } from "@/lib/remark-tex-math"; import { cn } from "@/lib/utils"; import "katex/dist/katex.min.css"; interface MarkdownTextRendererProps { children: string; className?: string; highlightCode?: boolean; onOpenFilePreview?: (path: string) => void; } type MarkdownAstNode = { type: string; value?: string; children?: MarkdownAstNode[]; data?: { hName?: string; }; }; type InlineLinkPreview = { href: string; host: string; prefix?: string; title: string; }; type AvailabilityResult = { available: boolean; path: string; resolve: FilePreviewAvailabilityResolver; }; function InferredFileReferenceChip({ path, onOpen, }: { path: string; onOpen?: (path: string) => void; }) { const resolve = useFilePreviewAvailabilityResolver(); const [result, setResult] = useState(null); useEffect(() => { if (!resolve || !onOpen) return; let cancelled = false; resolve(path) .then((available) => { if (!cancelled) setResult({ available, path, resolve }); }) .catch(() => { if (!cancelled) setResult({ available: false, path, resolve }); }); return () => { cancelled = true; }; }, [onOpen, path, resolve]); const resolvedAvailable = !resolve || ( result?.resolve === resolve && result.path === path && result.available ); return ( ); } const SAFE_INLINE_HTML_TAGS = new Set(["mark", "sub", "sup"]); function extensionOf(value: string): string { const clean = value.split(/[?#]/, 1)[0]?.trim() ?? ""; const slash = clean.lastIndexOf("/"); const name = slash >= 0 ? clean.slice(slash + 1) : clean; const dot = name.lastIndexOf("."); return dot > 0 ? name.slice(dot).toLowerCase() : ""; } function markdownAttachmentKind(source: string, label: string): "image" | "video" | "file" { const inferredKind = inferMediaKind({ url: source, name: label }); if (inferredKind !== "file") return inferredKind; return extensionOf(label) || extensionOf(source) ? "file" : "image"; } function safeHtmlNode(tagName: string, children: MarkdownAstNode[]): MarkdownAstNode { return { type: `nanobotSafeHtml${tagName}`, data: { hName: tagName }, children, }; } function safeText(value: string): MarkdownAstNode { return { type: "text", value }; } function htmlTag(node: MarkdownAstNode): { tag: string; closing: boolean } | null { if (node.type !== "html" || typeof node.value !== "string") return null; const match = /^<\s*(\/?)\s*(mark|sub|sup)\s*>$/i.exec(node.value.trim()); if (!match) return null; return { tag: match[2].toLowerCase(), closing: match[1] === "/" }; } function normalizeSafeInlineHtml(children: MarkdownAstNode[]): MarkdownAstNode[] { const next: MarkdownAstNode[] = []; for (let index = 0; index < children.length; index += 1) { const node = children[index]; if (node.children) { node.children = normalizeSafeInlineHtml(node.children); } const tag = htmlTag(node); if (!tag || tag.closing || !SAFE_INLINE_HTML_TAGS.has(tag.tag)) { next.push(node); continue; } let closeIndex = -1; for (let cursor = index + 1; cursor < children.length; cursor += 1) { const closeTag = htmlTag(children[cursor]); if (closeTag?.closing && closeTag.tag === tag.tag) { closeIndex = cursor; break; } } if (closeIndex === -1) { next.push(node); continue; } next.push( safeHtmlNode( tag.tag, normalizeSafeInlineHtml(children.slice(index + 1, closeIndex)), ), ); index = closeIndex; } return next; } function detailsOpen(node: MarkdownAstNode): { summary: string } | null { if (node.type !== "html" || typeof node.value !== "string") return null; const value = node.value.trim(); const match = /^<\s*details\s*>\s*<\s*summary\s*>([\s\S]*?)<\s*\/\s*summary\s*>$/i.exec(value); if (match) return { summary: match[1].trim() }; if (/^<\s*details\s*>$/i.test(value)) return { summary: "Details" }; return null; } function isDetailsClose(node: MarkdownAstNode): boolean { return node.type === "html" && typeof node.value === "string" && /^<\s*\/\s*details\s*>$/i.test(node.value.trim()); } function normalizeSafeDetails(children: MarkdownAstNode[]): MarkdownAstNode[] { const next: MarkdownAstNode[] = []; for (let index = 0; index < children.length; index += 1) { const node = children[index]; const open = detailsOpen(node); if (!open) { next.push(node); continue; } const closeIndex = children.findIndex( (candidate, candidateIndex) => candidateIndex > index && isDetailsClose(candidate), ); if (closeIndex === -1) { next.push(node); continue; } const body = normalizeSafeInlineHtml( normalizeSafeDetails(children.slice(index + 1, closeIndex)), ); next.push({ type: "nanobotSafeHtmlDetails", data: { hName: "details" }, children: [ { type: "nanobotSafeHtmlSummary", data: { hName: "summary" }, children: [safeText(open.summary)], }, ...body, ], }); index = closeIndex; } return next; } function remarkSafeHtmlSubset() { return (tree: MarkdownAstNode) => { if (tree.children) { tree.children = normalizeSafeInlineHtml(normalizeSafeDetails(tree.children)); } }; } const remarkPlugins: NonNullable = [ remarkBreaks, remarkGfm, [remarkMath, { singleDollarTextMath: false }], remarkTexMath, remarkSafeHtmlSubset, ]; const rehypePlugins: NonNullable = [rehypeKatex]; function nodeText(value: ReactNode): string { return Children.toArray(value) .map((child) => (typeof child === "string" || typeof child === "number" ? String(child) : "")) .join(""); } function cleanFileReferenceTarget(value: string): string { let target = value.trim(); if (!target) return ""; try { if (/^file:\/\//i.test(target)) { target = decodeURIComponent(new URL(target).pathname); } else { target = decodeURIComponent(target); } } catch { // Keep the raw value when URL/path decoding is not possible. } target = target.split("?", 1)[0]?.split("#", 1)[0]?.trim() ?? ""; if (!/^[A-Za-z]:[\\/]/.test(target)) { target = target.replace(/:\d+(?::\d+)?$/, ""); } return target; } function isPreviewableFileTarget(value: string): boolean { if (isFilePatternReference(value)) return false; if (isLikelyFilePath(value)) return true; if (/^[a-z][a-z0-9+.-]*:\/\//i.test(value)) return false; if (/[\\/]/.test(value)) return false; return /^[^?#]+\.[a-z0-9][a-z0-9_-]{0,12}$/i.test(value); } function isNonNavigableFilePatternLink(href: string | undefined): boolean { if (!href || /^https?:\/\//i.test(href) || href.startsWith("#")) return false; const target = cleanFileReferenceTarget(href); return Boolean(target && isFilePatternReference(target)); } function fileReferenceFromLink(href: string | undefined): string | null { if (!href || /^https?:\/\//i.test(href) || href.startsWith("#")) return null; const target = cleanFileReferenceTarget(href); return isPreviewableFileTarget(target) ? target : null; } function linkPreviewParts(value: ReactNode): { text: string; href?: string } { let text = ""; let href: string | undefined; for (const child of Children.toArray(value)) { if (typeof child === "string" || typeof child === "number") { text += String(child); continue; } if (!isValidElement(child)) { continue; } const props = child.props as { href?: unknown; children?: ReactNode }; if (!href && typeof props.href === "string" && /^https?:\/\//i.test(props.href)) { href = props.href; } const nested = linkPreviewParts(props.children); text += nested.text; href ||= nested.href; } return { text, href }; } function cleanLinkPreviewText(value: string): string { return value .replace(/\s+/g, " ") .replace(/^[\s"'“”‘’]+|[\s"'“”‘’]+$/g, "") .trim(); } function inlineLinkPreviewFromChildren(children: ReactNode): InlineLinkPreview | null { const { text: rawText, href } = linkPreviewParts(children); if (!href) return null; let url: URL; try { url = new URL(href); } catch { return null; } if (url.protocol !== "http:" && url.protocol !== "https:") return null; const strippedUrl = rawText .replace(/\s+/g, " ") .replace(href, "") .replace(url.toString(), "") .replace(/https?:\/\/\S+/i, "") .trim(); if (!strippedUrl || strippedUrl.length < 4) return null; const sourceMatch = /^(.*?)\s*(?:[—–]| - |:)\s*(.+)$/.exec(strippedUrl); const prefix = sourceMatch?.[1] ? cleanLinkPreviewText(sourceMatch[1]) : undefined; const title = cleanLinkPreviewText(sourceMatch?.[2] ?? strippedUrl); if (!title || /^https?:\/\//i.test(title)) return null; return { href, host: url.hostname, prefix, title, }; } function InlineLinkPreviewRow({ link }: { link: InlineLinkPreview }) { const { favicon, onFaviconError, onFaviconLoad } = useFaviconFallback(link.host); const label = link.prefix ? `${link.prefix} — ${link.title}` : link.title; return ( {favicon ? ( ) : ( )} {label} ); } function useFaviconFallback(host: string) { const faviconCandidates = useMemo(() => faviconUrls(host), [host]); const { logoUrl, onLogoError, onLogoLoad } = useLogoFallback(faviconCandidates); return { favicon: logoUrl ?? null, onFaviconError: onLogoError, onFaviconLoad: onLogoLoad, }; } function isRenderedCodeBlock(value: ReactNode): boolean { if (!isValidElement(value)) return false; const props = value.props as { code?: unknown }; return value.type === CodeBlock || typeof props.code === "string"; } function codeFenceFromPreChild(value: ReactNode): { code: string; language?: string } | null { if (!isValidElement(value)) return null; const props = value.props as { className?: unknown; children?: ReactNode }; if (!("children" in props)) return null; const className = typeof props.className === "string" ? props.className : ""; const language = /language-([^\s]+)/.exec(className)?.[1]; return { code: nodeText(props.children).replace(/\n$/, ""), language, }; } /** * Heavy markdown stack (GFM, math, KaTeX, syntax highlighting) kept in a * separate chunk so the app shell can paint sooner on refresh. */ export default function MarkdownTextRenderer({ children, className, highlightCode = true, onOpenFilePreview, }: MarkdownTextRendererProps) { const components = useMemo( () => ({ code({ className: cls, children: kids, ...props }) { const match = /language-(\w+)/.exec(cls || ""); if (match) { const code = String(kids).replace(/\n$/, ""); return ( ); } const raw = String(kids).replace(/\n$/, ""); if (isLikelyFilePath(raw)) { return ( ); } /** Plain fenced ``` blocks (no language) & wide one-liners: block monospace, not inline pill. */ const widePlainBlock = raw.includes("\n") || raw.length > 120; if (widePlainBlock) { return ( {kids} ); } return ( {kids} ); }, pre({ children: markdownChildren }) { const kids = Children.toArray(markdownChildren); const lone = kids.length === 1 ? kids[0] : null; /** Highlighted fences render ``CodeBlock`` (block shell); skip invalid ``
``. */ if (isRenderedCodeBlock(lone)) { return <>{markdownChildren}; } const fence = codeFenceFromPreChild(lone); if (fence) { return ( ); } return (
            {markdownChildren}
          
); }, a({ href, children: markdownChildren, ...props }) { const filePath = fileReferenceFromLink(href); if (filePath) { const label = nodeText(markdownChildren).trim(); return ( ); } if (isNonNavigableFilePatternLink(href)) { return <>{markdownChildren}; } return ( {markdownChildren} ); }, table({ children, ...props }) { // Wrap wide markdown tables in a horizontal-scroll container (the // pattern used by DeepSeek/others) so a 6+ column table scrolls inside // the conversation column instead of forcing the page wider than 100vw. // min-w-max keeps natural column widths; w-full stretches narrow tables. return (
{children}
); }, li({ children: markdownChildren, className: itemClassName }) { const link = inlineLinkPreviewFromChildren(markdownChildren); if (link) { return (
  • ); } return (
  • {markdownChildren}
  • ); }, input({ type, checked }) { if (type !== "checkbox") return null; return ( {checked ? : null} ); }, mark({ children: markdownChildren }) { return ( {markdownChildren} ); }, sub({ children: markdownChildren }) { return {markdownChildren}; }, sup({ children: markdownChildren }) { return {markdownChildren}; }, details({ children: markdownChildren }) { return (
    {markdownChildren}
    ); }, summary({ children: markdownChildren }) { return ( {markdownChildren} ); }, img({ src, alt, node: _node, className: imgClassName, ...props }) { void _node; void imgClassName; void props; const source = typeof src === "string" ? src : ""; if (!source) return null; const label = typeof alt === "string" ? alt : ""; const kind = markdownAttachmentKind(source, label); return ( ); }, }), [highlightCode, onOpenFilePreview], ); return (
    {children}
    ); }