Files
nanobot/webui/src/components/MarkdownTextRenderer.tsx
T

671 lines
20 KiB
TypeScript

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<AvailabilityResult | null>(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 (
<FileReferenceChip
path={path}
onOpen={onOpen && resolvedAvailable ? onOpen : undefined}
/>
);
}
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<ReactMarkdownOptions["remarkPlugins"]> = [
remarkBreaks,
remarkGfm,
[remarkMath, { singleDollarTextMath: false }],
remarkTexMath,
remarkSafeHtmlSubset,
];
const rehypePlugins: NonNullable<ReactMarkdownOptions["rehypePlugins"]> = [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 (
<a
href={link.href}
target="_blank"
rel="noreferrer noopener"
aria-label={`Open link: ${label}`}
className={cn(
"not-prose inline-flex max-w-full items-center gap-2 align-baseline",
"text-blue-500 no-underline underline-offset-2 hover:underline dark:text-blue-300",
)}
>
<span
className={cn(
"relative grid h-4 w-4 shrink-0 place-items-center overflow-hidden rounded-[4px]",
"border border-border/65 bg-background text-muted-foreground",
)}
aria-hidden
>
{favicon ? (
<img
src={favicon}
alt=""
className="h-3 w-3 rounded-[2px] object-contain"
decoding="async"
loading="lazy"
onLoad={onFaviconLoad}
onError={onFaviconError}
/>
) : (
<Globe2 className="h-3 w-3" />
)}
</span>
<span className="min-w-0 [overflow-wrap:anywhere] leading-normal sm:truncate">
{label}
</span>
</a>
);
}
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<Components>(
() => ({
code({ className: cls, children: kids, ...props }) {
const match = /language-(\w+)/.exec(cls || "");
if (match) {
const code = String(kids).replace(/\n$/, "");
return (
<CodeBlock
language={match[1]}
code={code}
className="my-3"
highlight={highlightCode}
/>
);
}
const raw = String(kids).replace(/\n$/, "");
if (isLikelyFilePath(raw)) {
return (
<InferredFileReferenceChip
path={raw}
onOpen={onOpenFilePreview}
/>
);
}
/** Plain fenced ``` blocks (no language) & wide one-liners: block monospace, not inline pill. */
const widePlainBlock = raw.includes("\n") || raw.length > 120;
if (widePlainBlock) {
return (
<code
className={cn(
"block min-w-0 max-w-full overflow-x-auto whitespace-pre bg-transparent p-0 font-mono text-[0.8125rem]",
"leading-snug text-inherit",
cls,
)}
{...props}
>
{kids}
</code>
);
}
return (
<code
className={cn(
"rounded bg-muted px-1 py-0.5 font-mono text-[0.85em]",
cls,
)}
{...props}
>
{kids}
</code>
);
},
pre({ children: markdownChildren }) {
const kids = Children.toArray(markdownChildren);
const lone = kids.length === 1 ? kids[0] : null;
/** Highlighted fences render ``CodeBlock`` (block shell); skip invalid ``<pre><div>``. */
if (isRenderedCodeBlock(lone)) {
return <>{markdownChildren}</>;
}
const fence = codeFenceFromPreChild(lone);
if (fence) {
return (
<CodeBlock
language={fence.language || "text"}
code={fence.code}
className="my-3"
highlight={highlightCode}
/>
);
}
return (
<pre
className={cn(
"my-3 overflow-x-auto rounded-lg border border-border/60 bg-muted/35",
"p-3 font-mono text-[0.8125rem] leading-snug text-foreground/90",
"whitespace-pre [overflow-wrap:normal]",
)}
>
{markdownChildren}
</pre>
);
},
a({ href, children: markdownChildren, ...props }) {
const filePath = fileReferenceFromLink(href);
if (filePath) {
const label = nodeText(markdownChildren).trim();
return (
<FileReferenceChip
path={label || filePath}
tooltipPath={filePath}
previewPath={filePath}
onOpen={onOpenFilePreview}
/>
);
}
if (isNonNavigableFilePatternLink(href)) {
return <>{markdownChildren}</>;
}
return (
<a
href={href}
target="_blank"
rel="noreferrer noopener"
className="text-blue-500 underline underline-offset-2 hover:text-blue-600 dark:text-blue-300 dark:hover:text-blue-200"
{...props}
>
{markdownChildren}
</a>
);
},
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 (
<div className="w-full overflow-x-auto">
<table className="w-full min-w-max" {...props}>
{children}
</table>
</div>
);
},
li({ children: markdownChildren, className: itemClassName }) {
const link = inlineLinkPreviewFromChildren(markdownChildren);
if (link) {
return (
<li className={cn("list-none pl-0", itemClassName)}>
<InlineLinkPreviewRow link={link} />
</li>
);
}
return (
<li className={itemClassName}>
{markdownChildren}
</li>
);
},
input({ type, checked }) {
if (type !== "checkbox") return null;
return (
<span
aria-hidden
data-testid="markdown-task-checkbox"
className={cn(
"mr-2 inline-grid h-4 w-4 translate-y-[2px] place-items-center rounded-[4px]",
"border border-border/70 bg-muted/55 text-background",
checked && "border-foreground/55 bg-foreground/65",
)}
>
{checked ? <Check className="h-3 w-3 stroke-[3]" /> : null}
</span>
);
},
mark({ children: markdownChildren }) {
return (
<mark className="rounded-[5px] bg-yellow-200/75 px-1 py-0.5 text-inherit dark:bg-yellow-300/25">
{markdownChildren}
</mark>
);
},
sub({ children: markdownChildren }) {
return <sub className="text-[0.72em] leading-none">{markdownChildren}</sub>;
},
sup({ children: markdownChildren }) {
return <sup className="text-[0.72em] leading-none">{markdownChildren}</sup>;
},
details({ children: markdownChildren }) {
return (
<details className="my-3 rounded-xl border border-border/65 bg-muted/25 px-4 py-3 open:pb-4">
{markdownChildren}
</details>
);
},
summary({ children: markdownChildren }) {
return (
<summary className="cursor-pointer select-none text-sm font-medium text-foreground/88 marker:text-muted-foreground">
{markdownChildren}
</summary>
);
},
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 (
<AttachmentTile
attachment={{
kind,
url: source,
name: label,
}}
inline
/>
);
},
}),
[highlightCode, onOpenFilePreview],
);
return (
<div
className={cn(
"markdown-content prose max-w-none dark:prose-invert",
"prose-headings:mt-4 prose-headings:mb-2 prose-headings:font-semibold prose-headings:tracking-tight",
"prose-h1:text-lg prose-h2:text-base prose-h3:text-sm prose-h4:text-[13px]",
"prose-p:my-2",
"prose-ul:my-2 prose-ol:my-2 prose-li:my-0.5",
"prose-blockquote:my-3 prose-blockquote:border-l-2 prose-blockquote:font-normal",
"prose-blockquote:not-italic prose-blockquote:text-foreground/80",
"prose-a:text-blue-500 prose-a:underline-offset-2 hover:prose-a:text-blue-600 dark:prose-a:text-blue-300 dark:hover:prose-a:text-blue-200",
"prose-hr:my-6",
"prose-pre:my-0 prose-pre:bg-transparent prose-pre:p-0",
"prose-code:before:content-none prose-code:after:content-none prose-code:font-normal",
"prose-table:my-3 prose-th:text-left prose-th:font-medium",
className,
)}
style={{ lineHeight: "var(--cjk-line-height)" }}
>
<ReactMarkdown
remarkPlugins={remarkPlugins}
rehypePlugins={rehypePlugins}
components={components}
>
{children}
</ReactMarkdown>
</div>
);
}