@plannotator/ui 0.40.0 → 0.41.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,668 @@
1
+ /**
2
+ * The renderer slot: ONE seam between "a diagram kind" and "the engine that
3
+ * turns its source into an svg". `renderDiagram(kind, renderId, source,
4
+ * theme)` looks the kind up in a per-kind map (`mermaid` through the
5
+ * runtime slot in `./mermaid`, `graphviz` through the one in `./graphviz`);
6
+ * the entry loads its engine lazily and hands back the sanitized svg NODE
7
+ * plus the finder that knows that engine's id grammar (`diagramFinder(kind)`
8
+ * hands the viewer the same finder for the pointer and the anchors). A later
9
+ * engine is one entry here behind a lazy import, one finder and tests over a
10
+ * captured svg; nothing in the viewer, the source pane or the comment UI
11
+ * changes.
12
+ *
13
+ * Errors are values: a source that does not parse resolves to
14
+ * `{ ok: false, message, line, runtimeUnavailable: false }`, never a throw,
15
+ * so the canvas keeps the last good render dimmed under the strip. A
16
+ * runtime that could not be LOADED (a chunking host's fetch) resolves with
17
+ * `runtimeUnavailable: true`, which is the one failure a Retry can change:
18
+ * the entry re-attempts the load once after the slot's retry delay before
19
+ * giving up, and the block's Retry re-runs the render with a fresh import.
20
+ *
21
+ * Security: Mermaid runs under MERMAID_CONFIG with `securityLevel: 'strict'`
22
+ * (pinned by components/MermaidBlock.test.ts). The svg it emits is the
23
+ * runtime's output, sanitized by Mermaid's own DOMPurify pass;
24
+ * `sanitizeDiagramSvg` is the belt over that boundary, and it hands the
25
+ * viewer a NODE, never markup: DOMPurify parses the string under the svg
26
+ * profile and returns a DOM fragment, the post-pass walks that fragment, and
27
+ * the canvas mounts the element with `replaceChildren`. No html string
28
+ * crosses into the app DOM anywhere in the viewer. What the pass removes: no
29
+ * script, no event handler attribute, no javascript: or data: reference
30
+ * survives it, and no `<a>` keeps an href: under strict Mermaid disables
31
+ * click CALLBACKS but a `click A "https://..."` binding still wraps the node
32
+ * in an svg `<a href>`, which would turn the pinpoint click on that node
33
+ * into a navigation. The canvas owns every click.
34
+ */
35
+ import DOMPurify from 'dompurify';
36
+ import type { DiagramKind } from '@plannotator/core/diagram-anchor';
37
+ import { MERMAID_FINDER, type DiagramFinder } from './diagram-anchor';
38
+ import { GRAPHVIZ_FINDER } from './diagram-anchor-graphviz';
39
+ import { getGraphvizRetryDelayMs, loadGraphvizRuntime, type GraphvizRuntime } from './graphviz';
40
+ import { loadMathRenderer } from './math';
41
+ import { getMermaidRetryDelayMs, loadMermaidRuntime } from './mermaid';
42
+ import { hasMermaidMath } from './mermaid-math-slot';
43
+ import { applyMermaidTheme, mermaidThemeKey, type MermaidThemeMode } from './mermaidTheme';
44
+
45
+ export type { DiagramKind } from '@plannotator/core/diagram-anchor';
46
+
47
+ type Mermaid = Awaited<ReturnType<typeof loadMermaidRuntime>>;
48
+
49
+ /**
50
+ * The (palette, mode) a render is for: the same pair `useTheme()` resolves
51
+ * for code fences. The mermaid entry passes it to `applyMermaidTheme`, which
52
+ * runs the global `initialize` once per key; the graphviz entry recolors its
53
+ * defaults onto CSS tokens and needs neither. A host without ThemeProvider
54
+ * passes any palette id with the mode it renders in.
55
+ */
56
+ export interface DiagramTheme {
57
+ readonly colorTheme: string;
58
+ readonly mode: MermaidThemeMode;
59
+ }
60
+
61
+ export type DiagramRenderError = {
62
+ readonly ok: false;
63
+ readonly message: string;
64
+ readonly line: number | null;
65
+ /** The engine could not be loaded (as opposed to the source not
66
+ * parsing): the one failure a Retry can change. */
67
+ readonly runtimeUnavailable: boolean;
68
+ };
69
+
70
+ export type DiagramRenderResult =
71
+ | {
72
+ readonly ok: true;
73
+ /** The sanitized svg root, ready to mount. The viewer never holds
74
+ * the markup: see the header. */
75
+ readonly svgNode: SVGSVGElement;
76
+ readonly findTarget: DiagramFinder['findTarget'];
77
+ }
78
+ | DiagramRenderError;
79
+
80
+ interface DiagramRenderer {
81
+ render(renderId: string, source: string, theme: DiagramTheme): Promise<DiagramRenderResult>;
82
+ readonly finder: DiagramFinder;
83
+ }
84
+
85
+ /** Elements that never belong in a rendered diagram. DOMPurify's profiles
86
+ * already drop every one of them; the sweep below is the belt that does not
87
+ * depend on which profile a later config edit turns on. */
88
+ const FORBIDDEN_ELEMENTS = 'script, iframe, object, embed, link, meta';
89
+
90
+ /**
91
+ * The belt over the engine's output (see the header). DOMPurify parses the
92
+ * string under the svg, svg-filter and html profiles and hands back a
93
+ * detached fragment, so nothing executes and no markup reaches the app DOM.
94
+ * `foreignobject` and its html children are added back the way Mermaid's
95
+ * own pass adds them (the same ADD_TAGS, ADD_ATTR and
96
+ * HTML_INTEGRATION_POINTS entries); without them every html label in the
97
+ * diagram would be dropped. The walk afterwards closes what DOMPurify leaves
98
+ * open for an svg: an `<a href>` around a node, and a `data:` reference on
99
+ * an `<image>`.
100
+ *
101
+ * Returns the sanitized svg root, or null when the string carries no svg or
102
+ * there is no document to parse it with.
103
+ */
104
+ export function sanitizeDiagramSvg(svg: string): SVGSVGElement | null {
105
+ const root = parseSvg(svg);
106
+ if (root === null) return null;
107
+ scrubDiagramSvg(root);
108
+ return root;
109
+ }
110
+
111
+ /**
112
+ * Step one of the sanitizer: DOMPurify parses the markup under the svg,
113
+ * svg-filter and html profiles and hands back a detached fragment; the svg
114
+ * root is adopted into the page's document so the node the canvas mounts
115
+ * belongs to it. Null when the markup carries no svg root, when there is no
116
+ * document, or when the fragment cannot be adopted.
117
+ */
118
+ export function parseDiagramSvg(svg: string): SVGSVGElement | null {
119
+ if (typeof document === 'undefined') return null;
120
+ const fragment = DOMPurify.sanitize(svg, {
121
+ USE_PROFILES: { svg: true, svgFilters: true, html: true },
122
+ ADD_TAGS: ['foreignobject'],
123
+ ADD_ATTR: ['dominant-baseline'],
124
+ HTML_INTEGRATION_POINTS: { foreignobject: true },
125
+ RETURN_DOM_FRAGMENT: true,
126
+ });
127
+ const root = fragment.querySelector('svg');
128
+ if (root === null) return null;
129
+ try {
130
+ return document.adoptNode(root) as SVGSVGElement;
131
+ } catch {
132
+ return null;
133
+ }
134
+ }
135
+
136
+ /**
137
+ * Step two, in place on the tree that is about to be mounted: the belt that
138
+ * does not depend on which DOMPurify profile a later config edit turns on.
139
+ * Forbidden elements go, every `<style>` keeps only rules scoped under the
140
+ * svg's own root id (`scopeDiagramCss`), every `<a>` loses its target (svg `<a>` from a
141
+ * click binding, `<a>` in an html label alike — the element stays so the
142
+ * label text survives), every `on*` attribute goes, and a reference
143
+ * attribute keeps only a fragment or an http(s) URL.
144
+ */
145
+ export function scrubDiagramSvg(root: SVGSVGElement): void {
146
+ for (const el of Array.from(root.querySelectorAll(FORBIDDEN_ELEMENTS))) el.remove();
147
+ // A `<style>` inside the svg is a page stylesheet once mounted: it can
148
+ // restyle elements OUTSIDE the diagram and fetch (`@import`, `url(`).
149
+ // Mermaid needs its own scoped sheet, so style elements are not dropped
150
+ // wholesale; each keeps only rules scoped under the svg's root id.
151
+ const rootId = root.getAttribute('id') ?? '';
152
+ for (const style of Array.from(root.querySelectorAll('style'))) {
153
+ const kept = scopeDiagramCss(style.textContent ?? '', rootId);
154
+ if (kept === '') style.remove();
155
+ else style.textContent = kept;
156
+ }
157
+ for (const anchor of Array.from(root.querySelectorAll('a'))) {
158
+ anchor.removeAttribute('href');
159
+ anchor.removeAttribute('xlink:href');
160
+ }
161
+ const walker = root.ownerDocument.createTreeWalker(root, NodeFilter.SHOW_ELEMENT);
162
+ for (let node = walker.nextNode(); node !== null; node = walker.nextNode()) {
163
+ scrubAttributes(node as Element);
164
+ }
165
+ scrubAttributes(root);
166
+ }
167
+
168
+ function scrubAttributes(el: Element): void {
169
+ for (const attribute of Array.from(el.attributes)) {
170
+ const name = attribute.name.toLowerCase();
171
+ const value = attribute.value.trim().toLowerCase();
172
+ if (name.startsWith('on')) {
173
+ el.removeAttribute(attribute.name);
174
+ continue;
175
+ }
176
+ if (
177
+ (name === 'href' || name === 'xlink:href' || name === 'src') &&
178
+ !value.startsWith('#') &&
179
+ !value.startsWith('http://') &&
180
+ !value.startsWith('https://') &&
181
+ value !== ''
182
+ ) {
183
+ el.removeAttribute(attribute.name);
184
+ }
185
+ }
186
+ }
187
+
188
+ /** The attribute an edge's widened hit path carries (its value is the
189
+ * edge's index in the layer); `diagramHitSource` maps one back to the
190
+ * visible edge it stands for. */
191
+ export const DIAGRAM_HIT_ATTR = 'data-diagram-hit';
192
+ /** The one group, appended LAST in the svg root, that holds every hit path. */
193
+ export const DIAGRAM_HIT_LAYER_ATTR = 'data-diagram-hit-layer';
194
+ /** The invisible stroke width of an edge hit path. A rendered edge is a 1–2
195
+ * px stroke that the pointer can only catch at random spots (owner
196
+ * feedback); 14 px is a comfortable target without swallowing its
197
+ * neighbours. */
198
+ export const EDGE_HIT_STROKE_WIDTH = 14;
199
+
200
+ /** Every edge element an engine draws: Mermaid's edge paths (flowchart,
201
+ * state transitions, class relations, ER relationship lines) and the
202
+ * sequence diagram's message lines; Graphviz's edge paths. */
203
+ const EDGE_SELECTOR = [
204
+ 'g.edgePaths > path',
205
+ 'path.flowchart-link',
206
+ 'path.transition',
207
+ 'path.relation',
208
+ 'path.relationshipLine',
209
+ 'line.messageLine0',
210
+ 'line.messageLine1',
211
+ 'path.messageLine0',
212
+ 'path.messageLine1',
213
+ 'g.edge > path',
214
+ ].join(', ');
215
+
216
+ /** The geometry a hit path keeps from its edge; everything else (id, class,
217
+ * every `data-*`, markers, inline style, dash pattern) is left behind, so a
218
+ * host's `[data-id="L_A_B_0"]` still matches exactly one element. */
219
+ const HIT_GEOMETRY_ATTRS = ['d', 'x1', 'y1', 'x2', 'y2', 'points'];
220
+
221
+ const hitSources = new WeakMap<Element, Element>();
222
+
223
+ /** The visible edge a hit path stands for, or null for any other element. */
224
+ export function diagramHitSource(el: Element): Element | null {
225
+ return hitSources.get(el) ?? null;
226
+ }
227
+
228
+ /** The transform list that places `el` in the svg root's user space: every
229
+ * ancestor's `transform` attribute, outermost first, then the element's
230
+ * own. An svg transform list composes left to right, so joining the
231
+ * attribute strings IS the composed transform — no layout needed, which is
232
+ * what lets this run on the detached node the slot hands over. */
233
+ function transformToRoot(el: Element, root: Element): string {
234
+ const parts: string[] = [];
235
+ for (let node: Element | null = el; node !== null && node !== root; node = node.parentElement) {
236
+ const transform = node.getAttribute('transform');
237
+ if (transform !== null && transform.trim() !== '') parts.unshift(transform.trim());
238
+ }
239
+ return parts.join(' ');
240
+ }
241
+
242
+ /**
243
+ * Give every edge an invisible hit target, in ONE layer appended last in
244
+ * the svg root. A sibling clone is not enough: an edge's own LABEL box is
245
+ * painted after the edge paths and covers it exactly where a person clicks
246
+ * an edge, and a nested subgraph's cluster rect covers a parent edge the
247
+ * same way. Each hit path is a bare element of the edge's tag with only its
248
+ * geometry, a `transform` that re-creates the ancestors' placement,
249
+ * `stroke: transparent`, `fill: none`, `stroke-width: 14` (set `!important`
250
+ * so no inherited or scoped rule can narrow it) and `pointer-events:
251
+ * stroke`. The layer sits over the nodes too, so the canvas resolves a click
252
+ * by priority over everything under the pointer (node, then edge, then
253
+ * cluster), never by the topmost element alone. Idempotent; runs after the
254
+ * scrub, on the tree about to be mounted, so nothing it adds passes through
255
+ * DOMPurify.
256
+ */
257
+ export function widenEdgeHitAreas(svg: SVGSVGElement): void {
258
+ const doc = svg.ownerDocument;
259
+ svg.querySelector(`:scope > [${DIAGRAM_HIT_LAYER_ATTR}]`)?.remove();
260
+ const edges = Array.from(new Set(Array.from(svg.querySelectorAll(EDGE_SELECTOR))));
261
+ if (edges.length === 0) return;
262
+ const layer = doc.createElementNS('http://www.w3.org/2000/svg', 'g');
263
+ layer.setAttribute(DIAGRAM_HIT_LAYER_ATTR, '');
264
+ layer.setAttribute('aria-hidden', 'true');
265
+ edges.forEach((edge, index) => {
266
+ const hit = doc.createElementNS('http://www.w3.org/2000/svg', edge.tagName.toLowerCase()) as SVGElement;
267
+ for (const name of HIT_GEOMETRY_ATTRS) {
268
+ const value = edge.getAttribute(name);
269
+ if (value !== null) hit.setAttribute(name, value);
270
+ }
271
+ const transform = transformToRoot(edge, svg);
272
+ if (transform !== '') hit.setAttribute('transform', transform);
273
+ hit.setAttribute(DIAGRAM_HIT_ATTR, String(index));
274
+ hit.setAttribute('fill', 'none');
275
+ hit.setAttribute('stroke', 'transparent');
276
+ hit.setAttribute('stroke-width', String(EDGE_HIT_STROKE_WIDTH));
277
+ hit.setAttribute('pointer-events', 'stroke');
278
+ const style = hit.style;
279
+ if (style) {
280
+ style.setProperty('fill', 'none', 'important');
281
+ style.setProperty('stroke', 'transparent', 'important');
282
+ style.setProperty('stroke-width', `${EDGE_HIT_STROKE_WIDTH}px`, 'important');
283
+ style.setProperty('stroke-dasharray', 'none', 'important');
284
+ style.setProperty('marker-start', 'none', 'important');
285
+ style.setProperty('marker-end', 'none', 'important');
286
+ style.setProperty('pointer-events', 'stroke', 'important');
287
+ }
288
+ hitSources.set(hit, edge);
289
+ layer.appendChild(hit);
290
+ });
291
+ svg.appendChild(layer);
292
+ }
293
+
294
+ /** CSS functions that fetch. `url(#fragment)` is the one reference kept. */
295
+ const CSS_FETCHERS = /(?:^|[^a-z-])(?:image-set|image|src|cross-fade|paint|element)\(/iu;
296
+
297
+ /** Resolve CSS escapes (`u\72l(` IS `url(` to a browser) so the checks
298
+ * below read what the engine would. */
299
+ function decodeCssEscapes(css: string): string {
300
+ return css.replace(/\\([0-9a-f]{1,6})\s?|\\(.)/giu, (_m, hex: string | undefined, ch: string | undefined) =>
301
+ hex !== undefined ? String.fromCodePoint(Math.min(Number.parseInt(hex, 16) || 0xfffd, 0x10ffff)) : (ch ?? ''),
302
+ );
303
+ }
304
+
305
+ function cssFetches(block: string): boolean {
306
+ const decoded = decodeCssEscapes(block).toLowerCase();
307
+ if (decoded.includes('@import')) return true;
308
+ if (CSS_FETCHERS.test(decoded)) return true;
309
+ for (let at = decoded.indexOf('url('); at !== -1; at = decoded.indexOf('url(', at + 4)) {
310
+ const arg = decoded.slice(at + 4).trimStart().replace(/^["']/u, '');
311
+ if (!arg.startsWith('#')) return true;
312
+ }
313
+ return false;
314
+ }
315
+
316
+ /** Split on top-level commas (not inside parens, brackets or strings). */
317
+ function splitSelectors(list: string): string[] {
318
+ const out: string[] = [];
319
+ let depth = 0;
320
+ let quote = '';
321
+ let current = '';
322
+ for (const ch of list) {
323
+ if (quote !== '') {
324
+ if (ch === quote) quote = '';
325
+ } else if (ch === '"' || ch === "'") quote = ch;
326
+ else if (ch === '(' || ch === '[') depth += 1;
327
+ else if (ch === ')' || ch === ']') depth = Math.max(0, depth - 1);
328
+ else if (ch === ',' && depth === 0) {
329
+ out.push(current.trim());
330
+ current = '';
331
+ continue;
332
+ }
333
+ current += ch;
334
+ }
335
+ if (current.trim() !== '') out.push(current.trim());
336
+ return out;
337
+ }
338
+
339
+ /**
340
+ * Keep only what a diagram's own stylesheet may say. Mermaid scopes every
341
+ * rule it emits under the svg's root id (`#<renderId> .node rect{…}`) plus
342
+ * its `@keyframes`; that is exactly what survives. Dropped: `@import`, any
343
+ * rule that fetches (`url(` that is not a `#fragment`, `image-set(`, …),
344
+ * any rule with a selector NOT scoped under `#<rootId>` (which could
345
+ * restyle the page around the diagram), and every at-rule but `@keyframes`
346
+ * and the grouping rules, which are filtered recursively. A brace-matching
347
+ * pass over the text: a detached `<style>` has no CSSOM to ask.
348
+ */
349
+ export function scopeDiagramCss(css: string, rootId: string): string {
350
+ const text = css.replace(/\/\*[\s\S]*?\*\//gu, '');
351
+ const scoped = (selector: string): boolean => {
352
+ if (rootId === '') return false;
353
+ const decoded = decodeCssEscapes(selector).trim();
354
+ if (!decoded.startsWith(`#${rootId}`)) return false;
355
+ const next = decoded[rootId.length + 1];
356
+ return next === undefined || !/[\w-]/u.test(next);
357
+ };
358
+ let out = '';
359
+ let i = 0;
360
+ while (i < text.length) {
361
+ // The prelude runs to the first top-level `{` or `;`.
362
+ let j = i;
363
+ let quote = '';
364
+ while (j < text.length) {
365
+ const ch = text[j] as string;
366
+ if (quote !== '') {
367
+ if (ch === '\\') j += 1;
368
+ else if (ch === quote) quote = '';
369
+ } else if (ch === '"' || ch === "'") quote = ch;
370
+ else if (ch === '{' || ch === ';') break;
371
+ j += 1;
372
+ }
373
+ const prelude = text.slice(i, j).trim();
374
+ if (j >= text.length) break;
375
+ if (text[j] === ';') {
376
+ // A statement at-rule (`@import …;`, `@charset`, `@namespace`): dropped.
377
+ i = j + 1;
378
+ continue;
379
+ }
380
+ // The block runs to its matching `}`.
381
+ let depth = 0;
382
+ let k = j;
383
+ quote = '';
384
+ for (; k < text.length; k += 1) {
385
+ const ch = text[k] as string;
386
+ if (quote !== '') {
387
+ if (ch === '\\') k += 1;
388
+ else if (ch === quote) quote = '';
389
+ } else if (ch === '"' || ch === "'") quote = ch;
390
+ else if (ch === '{') depth += 1;
391
+ else if (ch === '}') {
392
+ depth -= 1;
393
+ if (depth === 0) break;
394
+ }
395
+ }
396
+ const body = text.slice(j + 1, k);
397
+ i = k + 1;
398
+ if (prelude === '') continue;
399
+ if (prelude.startsWith('@')) {
400
+ const name = /^@([\w-]+)/u.exec(prelude)?.[1]?.toLowerCase() ?? '';
401
+ if (/^(?:-\w+-)?keyframes$/u.test(name)) {
402
+ if (!cssFetches(prelude) && !cssFetches(body)) out += `${prelude}{${body}}`;
403
+ } else if (name === 'media' || name === 'supports' || name === 'container' || name === 'layer') {
404
+ if (cssFetches(prelude)) continue;
405
+ const inner = scopeDiagramCss(body, rootId);
406
+ if (inner !== '') out += `${prelude}{${inner}}`;
407
+ }
408
+ continue;
409
+ }
410
+ if (cssFetches(body)) continue;
411
+ const selectors = splitSelectors(prelude);
412
+ if (selectors.length === 0 || !selectors.every(scoped)) continue;
413
+ out += `${prelude}{${body}}`;
414
+ }
415
+ return out;
416
+ }
417
+
418
+ let parseSvg: (svg: string) => SVGSVGElement | null = parseDiagramSvg;
419
+
420
+ /**
421
+ * Test hook: stand in for the DOMPurify parse step. happy-dom cannot host
422
+ * DOMPurify (its `DOMParser` lands in a foreign realm and mislabels svg
423
+ * namespaces, and its in-place mode reads `nodeName` through a cached
424
+ * `Node.prototype` getter happy-dom overrides on `Element`), so DOM tests
425
+ * parse through an inert `<template>` instead; `scrubDiagramSvg` still runs
426
+ * on every test render. The real parse is proven in a browser.
427
+ */
428
+ export function __setDiagramSvgParserForTests(next: ((svg: string) => SVGSVGElement | null) | undefined): void {
429
+ parseSvg = next ?? parseDiagramSvg;
430
+ }
431
+
432
+ /** Mermaid's parse errors say "Parse error on line N" (the hash carries the
433
+ * line for the parser errors that have one); Graphviz's say "syntax error
434
+ * in line N near '...'". */
435
+ function errorLine(error: unknown): number | null {
436
+ if (typeof error === 'object' && error !== null) {
437
+ const hash = (error as { hash?: { line?: unknown; loc?: { first_line?: unknown } } }).hash;
438
+ const line = hash?.line ?? hash?.loc?.first_line;
439
+ if (typeof line === 'number' && Number.isFinite(line)) return line + 1;
440
+ }
441
+ const message = error instanceof Error ? error.message : String(error);
442
+ const m = /line (\d+)/iu.exec(message);
443
+ return m === null ? null : Number(m[1]);
444
+ }
445
+
446
+ function errorMessage(error: unknown, fallback: string): string {
447
+ const message = error instanceof Error ? error.message : String(error);
448
+ // Mermaid appends the expected-token dump after the first line; the
449
+ // first line is the human sentence.
450
+ return message.split('\n')[0]?.trim() || fallback;
451
+ }
452
+
453
+ const wait = (ms: number) => new Promise<void>((resolve) => setTimeout(resolve, ms));
454
+
455
+ /**
456
+ * Load an engine through its slot with ONE automatic re-attempt after a
457
+ * short delay: a transient chunk failure on a chunking host. In a
458
+ * single-file build the first await never rejects, so the second attempt is
459
+ * unreachable there and the success path is unchanged.
460
+ */
461
+ async function loadWithRetry<T>(load: () => Promise<T>, delayMs: number): Promise<T> {
462
+ try {
463
+ return await load();
464
+ } catch {
465
+ await wait(delayMs);
466
+ return load();
467
+ }
468
+ }
469
+
470
+ async function renderMermaid(runtime: Mermaid, renderId: string, source: string): Promise<DiagramRenderResult> {
471
+ try {
472
+ const { svg } = await runtime.render(renderId, source);
473
+ const svgNode = sanitizeDiagramSvg(svg);
474
+ if (svgNode === null) {
475
+ return { ok: false, message: 'The diagram could not be rendered.', line: null, runtimeUnavailable: false };
476
+ }
477
+ widenEdgeHitAreas(svgNode);
478
+ return { ok: true, svgNode, findTarget: MERMAID_FINDER.findTarget };
479
+ } catch (error) {
480
+ return {
481
+ ok: false,
482
+ message: errorMessage(error, 'Failed to render diagram'),
483
+ line: errorLine(error),
484
+ runtimeUnavailable: false,
485
+ };
486
+ }
487
+ }
488
+
489
+ const mermaidRenderer: DiagramRenderer = {
490
+ finder: MERMAID_FINDER,
491
+ async render(renderId, source, theme) {
492
+ let runtime: Mermaid;
493
+ try {
494
+ runtime = await loadWithRetry(loadMermaidRuntime, getMermaidRetryDelayMs());
495
+ } catch (error) {
496
+ return {
497
+ ok: false,
498
+ message: errorMessage(error, 'Failed to render diagram'),
499
+ line: null,
500
+ runtimeUnavailable: true,
501
+ };
502
+ }
503
+ // A `$$` label makes Mermaid render KaTeX. On a host that redirects
504
+ // Mermaid's `katex` import to `utils/mermaid-math-slot` the label is
505
+ // typeset through the math slot, which must be filled by then: warm it
506
+ // with the registered loader first. A filled slot resolves at once; a
507
+ // load failure is left to the render, whose error names it.
508
+ if (hasMermaidMath(source)) {
509
+ try {
510
+ await loadMathRenderer();
511
+ } catch {
512
+ // Reported by the render below.
513
+ }
514
+ }
515
+ // Derive every Mermaid theme variable from the page's tokens before each
516
+ // render, so the diagram follows the palette and mode. Keyed on the
517
+ // runtime plus (palette, mode), so the lazy runtime is themed on its
518
+ // first render and re-initialized only when one of the three changes.
519
+ // With no tokens on the page it is a no-op and the static
520
+ // MERMAID_CONFIG (securityLevel strict) applies.
521
+ applyMermaidTheme(runtime, mermaidThemeKey(theme.colorTheme, theme.mode));
522
+ return renderMermaid(runtime, renderId, source);
523
+ },
524
+ };
525
+
526
+ /** A Graphviz default color, as `viz` writes it: a named default or its
527
+ * hex spelling. An author's hex spelling of the same default is
528
+ * indistinguishable from the default and is themed with it. */
529
+ function isGraphvizDefault(value: string | null, ...names: string[]): boolean {
530
+ if (value === null) return false;
531
+ const bare = value.trim().toLowerCase();
532
+ return names.includes(bare.startsWith('#') ? bare.slice(1) : bare);
533
+ }
534
+
535
+ const BLACK = ['black', '000000', '000'];
536
+ const WHITE = ['white', 'ffffff', 'fff'];
537
+ const LIGHTGREY = ['lightgrey', 'lightgray', 'd3d3d3'];
538
+
539
+ /**
540
+ * Graphviz has no theme system; every color lands as a presentation
541
+ * attribute from the DOT defaults (`stroke="black"`, `fill="none"`, a white
542
+ * page polygon) or from the author's attributes. This pass recolors the
543
+ * DEFAULTS onto the page tokens and leaves every author color alone:
544
+ * - the page background polygon (the first polygon under `g.graph`,
545
+ * `fill="white"`) is removed: the canvas is the ground;
546
+ * - a node or cluster shape with `fill="none"` gets `var(--card)` (an
547
+ * opaque interior, so the hover ring and the edge routing read), a
548
+ * black stroke gets `var(--foreground)` on a node and `var(--border)`
549
+ * on a cluster, a `lightgrey` fill (the `style=filled` default) gets
550
+ * `var(--muted)`;
551
+ * - an edge line or arrowhead in black gets `var(--foreground)`;
552
+ * - text with no fill or a black fill gets `var(--foreground)`.
553
+ * What it does NOT touch: any named or hex color the author set
554
+ * (`fillcolor=gold`, `color=red`), `bgcolor`, font families and sizes,
555
+ * stroke widths and dash patterns. Idempotent.
556
+ */
557
+ export function themeGraphvizSvg(svg: SVGSVGElement): void {
558
+ const graph = svg.querySelector(':scope > g.graph');
559
+ const page = graph?.querySelector(':scope > polygon') ?? null;
560
+ if (page !== null && isGraphvizDefault(page.getAttribute('fill'), ...WHITE)) {
561
+ page.remove();
562
+ }
563
+ const shapes = 'polygon, ellipse, circle, path, polyline, rect';
564
+ for (const group of Array.from(svg.querySelectorAll('g.node, g.cluster'))) {
565
+ const cluster = group.classList.contains('cluster');
566
+ for (const shape of Array.from(group.querySelectorAll(`:scope > ${shapes.replaceAll(', ', ', :scope > ')}`))) {
567
+ const fill = shape.getAttribute('fill');
568
+ if (isGraphvizDefault(fill, 'none', 'transparent')) {
569
+ shape.setAttribute('fill', 'var(--card)');
570
+ } else if (isGraphvizDefault(fill, ...LIGHTGREY)) {
571
+ shape.setAttribute('fill', 'var(--muted)');
572
+ }
573
+ if (isGraphvizDefault(shape.getAttribute('stroke'), ...BLACK)) {
574
+ shape.setAttribute('stroke', cluster ? 'var(--border)' : 'var(--foreground)');
575
+ }
576
+ }
577
+ }
578
+ for (const part of Array.from(svg.querySelectorAll('g.edge > path, g.edge > polygon, g.edge > ellipse'))) {
579
+ if (isGraphvizDefault(part.getAttribute('stroke'), ...BLACK)) {
580
+ part.setAttribute('stroke', 'var(--foreground)');
581
+ }
582
+ if (isGraphvizDefault(part.getAttribute('fill'), ...BLACK)) {
583
+ part.setAttribute('fill', 'var(--foreground)');
584
+ }
585
+ }
586
+ for (const text of Array.from(svg.querySelectorAll('text'))) {
587
+ const fill = text.getAttribute('fill');
588
+ if (fill === null || isGraphvizDefault(fill, ...BLACK)) {
589
+ text.setAttribute('fill', 'var(--foreground)');
590
+ }
591
+ }
592
+ }
593
+
594
+ /**
595
+ * The Graphviz entry: `@viz-js/viz` behind the runtime slot,
596
+ * `render(source, { format: "svg" })` as a value (`status: "failure"`
597
+ * carries the messages; nothing throws for a bad graph), the same sanitizer
598
+ * as Mermaid (Graphviz output has no foreignObject, no style, no url()
599
+ * reference, and a DOT `URL=` attribute emits the `<a xlink:href>` the pass
600
+ * strips), then the theme pass. The engine's output ids (`nodeN`) are
601
+ * positional and never prefixed by `renderId`; the finder keys on the DOT
602
+ * name from each `<title>`. The entry passes NO `images` option, so a DOT
603
+ * `image=` attribute is refused by the engine with a warning and emits no
604
+ * `<image>` at all.
605
+ */
606
+ const graphvizRenderer: DiagramRenderer = {
607
+ finder: GRAPHVIZ_FINDER,
608
+ async render(_renderId, source) {
609
+ let viz: GraphvizRuntime;
610
+ try {
611
+ viz = await loadWithRetry(loadGraphvizRuntime, getGraphvizRetryDelayMs());
612
+ } catch (error) {
613
+ return {
614
+ ok: false,
615
+ message: errorMessage(error, 'The Graphviz engine could not be loaded.'),
616
+ line: null,
617
+ runtimeUnavailable: true,
618
+ };
619
+ }
620
+ try {
621
+ const result = viz.render(source, { format: 'svg' });
622
+ if (result.status === 'failure') {
623
+ const first = result.errors.find((entry) => entry.level !== 'warning') ?? result.errors[0];
624
+ const message = first?.message.trim() || 'The graph could not be rendered.';
625
+ return { ok: false, message, line: errorLine(message), runtimeUnavailable: false };
626
+ }
627
+ const svgNode = sanitizeDiagramSvg(result.output);
628
+ if (svgNode === null) {
629
+ return { ok: false, message: 'The graph could not be rendered.', line: null, runtimeUnavailable: false };
630
+ }
631
+ themeGraphvizSvg(svgNode);
632
+ widenEdgeHitAreas(svgNode);
633
+ return { ok: true, svgNode, findTarget: GRAPHVIZ_FINDER.findTarget };
634
+ } catch (error) {
635
+ return {
636
+ ok: false,
637
+ message: errorMessage(error, 'Failed to render diagram'),
638
+ line: errorLine(error),
639
+ runtimeUnavailable: false,
640
+ };
641
+ }
642
+ },
643
+ };
644
+
645
+ const RENDERERS: Record<DiagramKind, DiagramRenderer> = {
646
+ mermaid: mermaidRenderer,
647
+ graphviz: graphvizRenderer,
648
+ };
649
+
650
+ /** The finder for a kind, synchronously: the viewer wires the pointer and
651
+ * the anchors to it before the first render lands. */
652
+ export function diagramFinder(kind: DiagramKind): DiagramFinder {
653
+ return RENDERERS[kind].finder;
654
+ }
655
+
656
+ /**
657
+ * Render one diagram source. `renderId` is the caller's stable prefix for
658
+ * the element ids; the finder in the result strips it when it matches
659
+ * anchors.
660
+ */
661
+ export function renderDiagram(
662
+ kind: DiagramKind,
663
+ renderId: string,
664
+ source: string,
665
+ theme: DiagramTheme,
666
+ ): Promise<DiagramRenderResult> {
667
+ return RENDERERS[kind].render(renderId, source, theme);
668
+ }