| 1 | import DOMPurify from "dompurify"; |
| 2 | import type { Mermaid } from "mermaid"; |
| 3 | import { use, useState } from "react"; |
| 4 | |
| 5 | import { Button } from "../ui/button"; |
| 6 | |
| 7 | type MermaidRenderResult = |
| 8 | | { readonly status: "rendered"; readonly svg: string } |
| 9 | | { readonly status: "error"; readonly message: string; readonly retryable: boolean }; |
| 10 | |
| 11 | let mermaidModulePromise: Promise<Mermaid> | null = null; |
| 12 | let renderQueue: Promise<unknown> = Promise.resolve(); |
| 13 | let nextDiagramId = 0; |
| 14 | const 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. |
| 17 | const renderCache = new Map<string, Promise<MermaidRenderResult>>(); |
| 18 | const settledRenders = new WeakSet<Promise<MermaidRenderResult>>(); |
| 19 | |
| 20 | // Mermaid is ~1MB, so it only loads once a diagram is actually shown. |
| 21 | function 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 | |
| 31 | const REMOTE_CSS_URL = /url\(\s*(?!['"]?#)[^)]*\)/gi; |
| 32 | let 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. |
| 37 | function 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. |
| 68 | const CHUNK_LOAD_ERROR = /dynamically imported module|importing a module script|failed to fetch/i; |
| 69 | |
| 70 | async 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 | |
| 114 | function 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 | |
| 121 | function mermaidRenderKey(source: string, theme: "light" | "dark") { |
| 122 | return `${theme}\n${source}`; |
| 123 | } |
| 124 | |
| 125 | function 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 | |
| 144 | let 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 | */ |
| 152 | function 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. */ |
| 166 | export 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 | } |