Files
Sybil-2/web/src/components/markdown/markdown-content.tsx
2026-05-02 23:17:52 -07:00

45 lines
1.6 KiB
TypeScript

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;
};
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 `<span class="md-cite-token">[${index}]</span>`;
});
}
const markdownRenderer = new Renderer();
const renderTable = markdownRenderer.table.bind(markdownRenderer);
markdownRenderer.table = (token) => {
return `<div class="md-table-scroll">${renderTable(token)}</div>`;
};
function renderMarkdown(markdown: string) {
const rawHtml = marked.parse(markdown, { gfm: true, breaks: true, renderer: markdownRenderer }) as string;
return DOMPurify.sanitize(rawHtml, { ADD_ATTR: ["class", "target", "rel"] });
}
export function MarkdownContent({ markdown, className, mode = "default", resolveCitationIndex }: Props) {
const html = useMemo(() => {
const prepared =
mode === "citationTokens" ? replaceMarkdownLinksWithCitationTokens(markdown, resolveCitationIndex) : markdown;
return renderMarkdown(prepared);
}, [markdown, mode, resolveCitationIndex]);
return <div className={cn("md-content", className)} dangerouslySetInnerHTML={{ __html: html }} />;
}