65 lines
2.6 KiB
TypeScript
65 lines
2.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;
|
|
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 `<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 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 <div className={cn("md-content", className)} dangerouslySetInnerHTML={{ __html: html }} />;
|
|
}
|