"use client";
import { createCodePlugin } from "@streamdown/code";
import { defaultRehypePlugins, Streamdown } from "streamdown";
import type { Components, UrlTransform } from "streamdown";
import type { Root, RootContent } from "hast";
const codePlugin = createCodePlugin({
themes: ["github-dark", "github-dark"],
});
const SAFE_ANCHOR = /^#[A-Za-z][A-Za-z0-9:._-]*$/;
const ABSOLUTE_HTTP_URL = /^https?:\/\/[^/?#\\]/i;
const FORBIDDEN_URL_CHARACTERS = /[\u0000-\u001F\u007F\\]/;
const DISALLOWED_ELEMENTS = ["img"] as const;
const transformMarkdownUrl = (url: string, key: string) => {
if (key !== "href") return null;
if (url !== url.trim()) return null;
if (SAFE_ANCHOR.test(url)) return url;
if (FORBIDDEN_URL_CHARACTERS.test(url)) return null;
if (!ABSOLUTE_HTTP_URL.test(url)) return null;
try {
const parsed = new URL(url);
const safeProtocol =
parsed.protocol === "http:" || parsed.protocol === "https:";
return safeProtocol && parsed.hostname ? url : null;
} catch {
return null;
}
};
export const safeMarkdownUrl: UrlTransform = (url, key) =>
transformMarkdownUrl(url, key);
const enforceSafeHrefs = () => (tree: Root) => {
const visitNode = (node: Root | RootContent) => {
if (node.type === "element" && "href" in node.properties) {
const href = node.properties.href;
const safeHref =
typeof href === "string" ? transformMarkdownUrl(href, "href") : null;
if (safeHref) node.properties.href = safeHref;
else delete node.properties.href;
}
if ("children" in node) node.children.forEach(visitNode);
};
visitNode(tree);
};
const rehypePlugins = [
defaultRehypePlugins.sanitize,
enforceSafeHrefs,
defaultRehypePlugins.harden,
];
const components: Components = {
a: (componentProps) => {
const { node, href, ...anchorProps } = componentProps;
const sameDocument = href?.startsWith("#") ?? false;
void node;
return (
<a
{...anchorProps}
href={href}
rel={sameDocument ? undefined : "noopener noreferrer"}
target={sameDocument ? undefined : "_blank"}
/>
);
},
img: () => null,
};
type AssistantMarkdownProps = {
content: string;
isStreaming: boolean;
};
export function AssistantMarkdown({
content,
isStreaming,
}: AssistantMarkdownProps) {
return (
<Streamdown
className="assistant-markdown min-w-0"
components={components}
controls={{
code: { copy: true, download: false },
mermaid: false,
table: false,
}}
disallowedElements={DISALLOWED_ELEMENTS}
isAnimating={isStreaming}
linkSafety={{ enabled: false }}
lineNumbers={false}
mode={isStreaming ? "streaming" : "static"}
parseIncompleteMarkdown
plugins={{ code: codePlugin }}
rehypePlugins={rehypePlugins}
skipHtml
translations={{
copied: "Скопировано",
copyCode: "Копировать код",
}}
urlTransform={safeMarkdownUrl}
>
{content}
</Streamdown>
);
}