1import DOMPurify from "dompurify";
2import type { Mermaid } from "mermaid";
3import { use, useState } from "react";
4
5import { Button } from "../ui/button";
6
7type MermaidRenderResult =
8 | { readonly status: "rendered"; readonly svg: string }
9 | { readonly status: "error"; readonly message: string; readonly retryable: boolean };
10
11let mermaidModulePromise: Promise<Mermaid> | null = null;
12let renderQueue: Promise<unknown> = Promise.resolve();
13let nextDiagramId = 0;
14const MAX_CACHED_RENDERS = 64;
15// Keyed by the full source so distinct diagrams never share an entry. Pending
16// renders are never evicted, because use() must get the same promise on retry.
17const renderCache = new Map<string, Promise<MermaidRenderResult>>();
18const settledRenders = new WeakSet<Promise<MermaidRenderResult>>();
19
20// Mermaid is ~1MB, so it only loads once a diagram is actually shown.
21function loadMermaid(): Promise<Mermaid> {
22 mermaidModulePromise ??= import("mermaid")
23 .then((module) => module.default)
24 .catch((error: unknown) => {
25 mermaidModulePromise = null;
26 throw error;
27 });
28 return mermaidModulePromise;
29}
30
31const REMOTE_CSS_URL = /url\(\s*(?!['"]?#)[^)]*\)/gi;
32let purifier: ReturnType<typeof DOMPurify> | null = null;
33
34// Diagrams can come from untrusted PR descriptions, so strip anything that can
35// navigate, run script, or fetch remote content on top of Mermaid's own strict
36// sanitization. CSS keeps only local url(#id) references; label text is untouched.
37function sanitizeMermaidSvg(svg: string): string {
38 if (!purifier) {
39 purifier = DOMPurify(window);
40 purifier.addHook("uponSanitizeElement", (node, data) => {
41 if (data.tagName === "style" && node.textContent) {
42 node.textContent = node.textContent.replace(REMOTE_CSS_URL, "none");
43 }
44 });
45 purifier.addHook("uponSanitizeAttribute", (_node, data) => {
46 if (data.attrName === "style")
47 data.attrValue = data.attrValue.replace(REMOTE_CSS_URL, "none");
48 });
49 }
50 const sanitized = purifier.sanitize(svg, {
51 ADD_TAGS: ["foreignObject"],
52 HTML_INTEGRATION_POINTS: { foreignobject: true },
53 FORBID_ATTR: ["href", "xlink:href", "src", "srcset"],
54 FORBID_TAGS: ["a", "img", "image", "script"],
55 USE_PROFILES: { svg: true, svgFilters: true, html: true },
56 });
57 const element = new DOMParser().parseFromString(sanitized, "image/svg+xml").documentElement;
58 const viewBox = element.getAttribute("viewBox")?.trim().split(/\s+/).map(Number);
59 if (viewBox?.length === 4 && viewBox.every(Number.isFinite)) {
60 element.setAttribute("width", String(viewBox[2]));
61 element.setAttribute("height", String(viewBox[3]));
62 }
63 return new XMLSerializer().serializeToString(element);
64}
65
66// Mermaid also lazy-loads diagram chunks inside render(); losing the network
67// there is worth a retry, unlike a syntax error.
68const CHUNK_LOAD_ERROR = /dynamically imported module|importing a module script|failed to fetch/i;
69
70async function renderMermaid(
71 source: string,
72 theme: "light" | "dark",
73): Promise<MermaidRenderResult> {
74 const id = `mermaid-diagram-${nextDiagramId++}`;
75 let mermaid: Mermaid;
76 try {
77 mermaid = await loadMermaid();
78 } catch {
79 return { status: "error", message: "Mermaid failed to load.", retryable: true };
80 }
81 try {
82 // initialize() mutates global config, so renders run one at a time.
83 mermaid.initialize({
84 startOnLoad: false,
85 securityLevel: "strict",
86 suppressErrorRendering: true,
87 // HTML labels and theme CSS are mounted while Mermaid lays the diagram
88 // out, before sanitizing, so diagram directives must not set them.
89 secure: [
90 "secure",
91 "securityLevel",
92 "startOnLoad",
93 "maxTextSize",
94 "suppressErrorRendering",
95 "maxEdges",
96 "htmlLabels",
97 "themeCSS",
98 ],
99 htmlLabels: false,
100 flowchart: { htmlLabels: false },
101 theme: theme === "dark" ? "dark" : "default",
102 fontFamily: getComputedStyle(document.body).fontFamily,
103 });
104 const { svg } = await mermaid.render(id, source);
105 return { status: "rendered", svg: sanitizeMermaidSvg(svg) };
106 } catch (error) {
107 const message = error instanceof Error ? error.message : "The diagram could not be rendered.";
108 return { status: "error", message, retryable: CHUNK_LOAD_ERROR.test(message) };
109 } finally {
110 document.getElementById(`d${id}`)?.remove();
111 }
112}
113
114function evictSettledRenders() {
115 for (const [key, result] of renderCache) {
116 if (renderCache.size <= MAX_CACHED_RENDERS) return;
117 if (settledRenders.has(result)) renderCache.delete(key);
118 }
119}
120
121function mermaidRenderKey(source: string, theme: "light" | "dark") {
122 return `${theme}\n${source}`;
123}
124
125function mermaidRenderPromise(source: string, theme: "light" | "dark") {
126 const key = mermaidRenderKey(source, theme);
127 const cached = renderCache.get(key);
128 if (cached) {
129 renderCache.delete(key);
130 renderCache.set(key, cached);
131 return cached;
132 }
133 const result = renderQueue.then(() => renderMermaid(source, theme));
134 renderQueue = result;
135 void result.then(() => {
136 settledRenders.add(result);
137 evictSettledRenders();
138 });
139 renderCache.set(key, result);
140 evictSettledRenders();
141 return result;
142}
143
144let expandedImageUrl: string | null = null;
145
146/**
147 * Converts a rendered diagram into a standalone image with fixed size and background.
148 * A blob URL, unlike a data URL, passes the desktop connect-src policy that media
149 * save and copy actions fetch through. Only one diagram is expanded at a time, so
150 * the previous URL is released.
151 */
152function mermaidImageUrl(svg: string): string {
153 const svgDocument = new DOMParser().parseFromString(svg, "image/svg+xml");
154 const element = svgDocument.documentElement;
155 element.setAttribute("xmlns", "http://www.w3.org/2000/svg");
156 element.style.maxWidth = "none";
157 element.style.backgroundColor = getComputedStyle(document.body).backgroundColor;
158 if (expandedImageUrl) URL.revokeObjectURL(expandedImageUrl);
159 expandedImageUrl = URL.createObjectURL(
160 new Blob([new XMLSerializer().serializeToString(element)], { type: "image/svg+xml" }),
161 );
162 return expandedImageUrl;
163}
164
165/** Suspends until the diagram renders; failures show the parser message above the source. */
166export function MermaidDiagram({
167 source,
168 theme,
169 onExpand,
170}: {
171 source: string;
172 theme: "light" | "dark";
173 onExpand: (imageUrl: string) => void;
174}) {
175 const [, setAttempt] = useState(0);
176 const result = use(mermaidRenderPromise(source.trim(), theme));
177
178 if (result.status === "error") {
179 return (
180 <div>
181 <div className="flex items-center justify-between gap-2">
182 <p className="m-0 text-xs text-destructive">Unable to render diagram: {result.message}</p>
183 {result.retryable ? (
184 <Button
185 type="button"
186 variant="ghost"
187 size="xs"
188 onClick={() => {
189 renderCache.delete(mermaidRenderKey(source.trim(), theme));
190 setAttempt((attempt) => attempt + 1);
191 }}
192 >
193 Retry
194 </Button>
195 ) : null}
196 </div>
197 {/* Find matches the source through the hidden copy beside the diagram. */}
198 <pre
199 data-thread-find-ignore
200 className="mt-2 mb-0 overflow-auto font-mono text-xs whitespace-pre-wrap"
201 >
202 {source}
203 </pre>
204 </div>
205 );
206 }
207
208 return (
209 <div className="overflow-x-auto">
210 <button
211 type="button"
212 aria-label="Expand diagram"
213 className="flex w-full cursor-zoom-in justify-center rounded-md focus-visible:outline-2 focus-visible:outline-ring [&_svg]:h-auto [&_svg]:max-w-full"
214 onClick={() => onExpand(mermaidImageUrl(result.svg))}
215 dangerouslySetInnerHTML={{ __html: result.svg }}
216 />
217 </div>
218 );
219}