45 lines
1.6 KiB
TypeScript
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 }} />;
|
|
}
|