Files
Sybil-2/web/src/components/markdown/markdown-content.tsx
T

59 lines
2.2 KiB
TypeScript
Raw Normal View History

2026-02-14 00:33:15 -08:00
import { useMemo } from "preact/hooks";
import DOMPurify from "dompurify";
2026-05-02 23:17:52 -07:00
import { marked, Renderer } from "marked";
2026-02-14 00:33:15 -08:00
import { cn } from "@/lib/utils";
type MarkdownMode = "default" | "citationTokens";
type Props = {
markdown: string;
className?: string;
mode?: MarkdownMode;
resolveCitationIndex?: (href: string) => number | undefined;
2026-07-19 16:08:38 -07:00
openLinksInNewTab?: boolean;
2026-02-14 00:33:15 -08:00
};
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>`;
});
}
2026-05-02 23:17:52 -07:00
const markdownRenderer = new Renderer();
const renderTable = markdownRenderer.table.bind(markdownRenderer);
markdownRenderer.table = (token) => {
return `<div class="md-table-scroll">${renderTable(token)}</div>`;
};
2026-07-19 16:08:38 -07:00
function setNewTabLinkAttributes(currentNode: Element) {
if (currentNode.tagName !== "A") return;
currentNode.setAttribute("target", "_blank");
currentNode.setAttribute("rel", "noopener noreferrer");
2026-02-14 00:33:15 -08:00
}
2026-07-19 16:08:38 -07:00
function renderMarkdown(markdown: string, openLinksInNewTab: boolean) {
const rawHtml = marked.parse(markdown, { gfm: true, breaks: true, renderer: markdownRenderer }) as string;
if (!openLinksInNewTab) return DOMPurify.sanitize(rawHtml, { ADD_ATTR: ["class", "target", "rel"] });
DOMPurify.addHook("afterSanitizeAttributes", setNewTabLinkAttributes);
try {
return DOMPurify.sanitize(rawHtml, { ADD_ATTR: ["class", "target", "rel"] });
} finally {
DOMPurify.removeHook("afterSanitizeAttributes", setNewTabLinkAttributes);
}
}
export function MarkdownContent({ markdown, className, mode = "default", resolveCitationIndex, openLinksInNewTab = false }: Props) {
2026-02-14 00:33:15 -08:00
const html = useMemo(() => {
const prepared =
mode === "citationTokens" ? replaceMarkdownLinksWithCitationTokens(markdown, resolveCitationIndex) : markdown;
2026-07-19 16:08:38 -07:00
return renderMarkdown(prepared, openLinksInNewTab);
}, [markdown, mode, openLinksInNewTab, resolveCitationIndex]);
2026-02-14 00:33:15 -08:00
return <div className={cn("md-content", className)} dangerouslySetInnerHTML={{ __html: html }} />;
}