@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.
- package/HANDOFF.md +46 -5
- package/README.md +6 -2
- package/components/DiagramBlock.tsx +376 -0
- package/components/GraphvizBlock.tsx +22 -597
- package/components/MermaidBlock.tsx +17 -664
- package/components/Viewer.tsx +42 -2
- package/components/diagram/DiagramCanvas.tsx +431 -0
- package/components/diagram/DiagramComposer.tsx +135 -0
- package/components/diagram/DiagramOverlay.tsx +215 -0
- package/components/diagram/DiagramPopout.tsx +70 -0
- package/components/diagram/DiagramSourcePane.tsx +244 -0
- package/components/diagram/DiagramViewer.tsx +276 -0
- package/components/diagram/anchorClaims.ts +71 -0
- package/components/diagram/index.ts +36 -0
- package/components/diagram/useDiagramComments.ts +341 -0
- package/components/diagram/useDiagramRender.ts +91 -0
- package/components/diagram/useDiagramSourceDraft.ts +143 -0
- package/components/diagram/useDiagramViewport.ts +156 -0
- package/components/html-viewer/bridge-script.asset.js +25 -0
- package/components/html-viewer/bridge-script.lite.ts +1 -1
- package/components/html-viewer/bridge-script.ts +37 -0
- package/hooks/useAnnotationHighlighter.ts +5 -0
- package/package.json +5 -3
- package/styles.css +1 -1
- package/types.ts +5 -0
- package/utils/diagram-anchor-graphviz.ts +143 -0
- package/utils/diagram-anchor.ts +401 -0
- package/utils/diagram-projection.ts +66 -0
- package/utils/diagram-render.ts +668 -0
- package/utils/graphviz.ts +93 -0
- package/utils/parser.ts +15 -1
- package/components/mermaidSvg.ts +0 -33
|
@@ -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
|
+
}
|