import { useMemo } from "preact/hooks"; import DOMPurify from "dompurify"; import { marked, Renderer } from "marked"; import { cn } from "@/lib/utils"; type MarkdownMode = "default" | "citationTokens"; type Props = { markdown: string; className?: string; mode?: MarkdownMode; resolveCitationIndex?: (href: string) => number | undefined; openLinksInNewTab?: boolean; /** Agent images are delivered only through authenticated attachment IDs. */ allowImages?: boolean; }; function replaceMarkdownLinksWithCitationTokens(markdown: string, resolveCitationIndex?: (href: string) => number | undefined) { if (!resolveCitationIndex) return markdown; return markdown.replace(/\[([^\]]+)\]\((https?:\/\/[^\s)]+)\)/g, (_full, _label, href) => { const index = resolveCitationIndex(href); if (!index) return ""; return `[${index}]`; }); } const markdownRenderer = new Renderer(); const renderTable = markdownRenderer.table.bind(markdownRenderer); markdownRenderer.table = (token) => { return `
${renderTable(token)}
`; }; function setNewTabLinkAttributes(currentNode: Element) { if (currentNode.tagName !== "A") return; currentNode.setAttribute("target", "_blank"); currentNode.setAttribute("rel", "noopener noreferrer"); } function renderMarkdown(markdown: string, openLinksInNewTab: boolean, allowImages: boolean) { const rawHtml = marked.parse(markdown, { gfm: true, breaks: true, renderer: markdownRenderer }) as string; const config = { ADD_ATTR: ["class", "target", "rel"], ...(!allowImages ? { FORBID_TAGS: ["img", "picture", "source", "video", "audio", "svg", "math", "style"], FORBID_ATTR: ["style", "srcset", "background", "poster"], } : {}) }; if (!openLinksInNewTab) return DOMPurify.sanitize(rawHtml, config); DOMPurify.addHook("afterSanitizeAttributes", setNewTabLinkAttributes); try { return DOMPurify.sanitize(rawHtml, config); } finally { DOMPurify.removeHook("afterSanitizeAttributes", setNewTabLinkAttributes); } } export function MarkdownContent({ markdown, className, mode = "default", resolveCitationIndex, openLinksInNewTab = false, allowImages = true }: Props) { const html = useMemo(() => { const prepared = mode === "citationTokens" ? replaceMarkdownLinksWithCitationTokens(markdown, resolveCitationIndex) : markdown; return renderMarkdown(prepared, openLinksInNewTab, allowImages); }, [markdown, mode, openLinksInNewTab, resolveCitationIndex, allowImages]); return
; }