@wxxb789/anc 0.0.1
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/LICENSE +21 -0
- package/README.md +295 -0
- package/astro.config.mjs +255 -0
- package/bin/anc.mjs +798 -0
- package/package.json +93 -0
- package/public/_headers +45 -0
- package/public/favicon.ico +0 -0
- package/public/favicon.svg +9 -0
- package/public/fonts/Temml.woff2 +0 -0
- package/scripts/build-snapshot.js +69 -0
- package/scripts/build-wasm.js +67 -0
- package/scripts/build-workspace.js +50 -0
- package/scripts/copy-snapshot.js +48 -0
- package/scripts/copy-wasm.js +57 -0
- package/scripts/emit-redirects.js +64 -0
- package/scripts/generate-corpus.js +421 -0
- package/scripts/init-repository.js +249 -0
- package/scripts/load-config.js +975 -0
- package/scripts/markdown-to-artifact.js +1286 -0
- package/scripts/preview-site.js +364 -0
- package/scripts/publish-set-review.js +157 -0
- package/scripts/resolve-links.js +640 -0
- package/scripts/run-pagefind.js +125 -0
- package/scripts/scan-residue.js +1016 -0
- package/scripts/scan-secrets.js +268 -0
- package/scripts/seed-gitignore.js +410 -0
- package/scripts/snapshot-rows.js +205 -0
- package/scripts/validate-content.js +95 -0
- package/scripts/vendor-provenance.js +307 -0
- package/scripts/verify-output-inventory.js +307 -0
- package/scripts/write-report.js +351 -0
- package/scripts/write-snapshot.js +136 -0
- package/src/components/CollectionExplorer.astro +149 -0
- package/src/components/FacetIndex.astro +45 -0
- package/src/components/LinkedNotes.astro +94 -0
- package/src/components/NoteGraph.astro +434 -0
- package/src/components/NoteList.astro +54 -0
- package/src/components/SiteMetadata.astro +143 -0
- package/src/components/TableOfContents.astro +69 -0
- package/src/components/TagBrowser.astro +70 -0
- package/src/layouts/Layout.astro +305 -0
- package/src/lib/artifact-source.js +72 -0
- package/src/lib/build-bindings.js +11 -0
- package/src/lib/collection-navigation.js +145 -0
- package/src/lib/content.js +92 -0
- package/src/lib/diagram-mode.js +18 -0
- package/src/lib/graph-layout.js +219 -0
- package/src/lib/graph-selection.js +84 -0
- package/src/lib/graph.js +42 -0
- package/src/lib/link-resolution.js +389 -0
- package/src/lib/markdown.js +1469 -0
- package/src/lib/math-mode.js +31 -0
- package/src/lib/math-output.js +131 -0
- package/src/lib/math.js +165 -0
- package/src/lib/mermaid-environment.js +475 -0
- package/src/lib/mermaid-render.js +769 -0
- package/src/lib/preview-model.js +96 -0
- package/src/lib/relations.js +242 -0
- package/src/lib/rendered-marker.js +55 -0
- package/src/lib/route-path.js +192 -0
- package/src/lib/routes.js +342 -0
- package/src/lib/schema.js +578 -0
- package/src/lib/site.js +578 -0
- package/src/lib/snapshot-contract.js +216 -0
- package/src/lib/snapshot-operations.js +147 -0
- package/src/lib/snapshot-queries.js +216 -0
- package/src/lib/snapshot-reader.js +210 -0
- package/src/lib/snapshot.js +233 -0
- package/src/lib/sqlite-warning.js +22 -0
- package/src/lib/sqlite.js +14 -0
- package/src/lib/tag-browser-model.js +18 -0
- package/src/lib/translations.js +533 -0
- package/src/lib/wasm-asset.js +47 -0
- package/src/lib/worker-protocol.js +93 -0
- package/src/pages/404.astro +35 -0
- package/src/pages/about.astro +100 -0
- package/src/pages/collections/[slug].astro +36 -0
- package/src/pages/collections/index.astro +33 -0
- package/src/pages/graph.astro +64 -0
- package/src/pages/index.astro +33 -0
- package/src/pages/notes/[slug].astro +509 -0
- package/src/pages/privacy.astro +121 -0
- package/src/pages/private.astro +59 -0
- package/src/pages/recent.astro +33 -0
- package/src/pages/robots.txt.js +15 -0
- package/src/pages/rss.xml.js +21 -0
- package/src/pages/sitemap.xml.js +20 -0
- package/src/pages/tags/[tag].astro +56 -0
- package/src/pages/tags/index.astro +37 -0
- package/src/scripts/code-copy.js +45 -0
- package/src/scripts/diagram-disabled.js +16 -0
- package/src/scripts/diagram.js +322 -0
- package/src/scripts/graph-client.js +222 -0
- package/src/scripts/graph-draw.js +166 -0
- package/src/scripts/link-preview.js +334 -0
- package/src/scripts/math-disabled.js +18 -0
- package/src/scripts/math.js +133 -0
- package/src/scripts/preferences.js +123 -0
- package/src/scripts/search-dialog.js +651 -0
- package/src/scripts/snapshot-client.js +157 -0
- package/src/scripts/snapshot-worker.js +341 -0
- package/src/scripts/tag-browser.js +153 -0
- package/src/scripts/theme-init.js +35 -0
- package/src/scripts/toc-active.js +38 -0
- package/src/styles/code.css +152 -0
- package/src/styles/diagram.css +149 -0
- package/src/styles/global.css +1739 -0
- package/src/styles/graph.css +317 -0
- package/src/styles/math.css +376 -0
- package/src/styles/tokens.css +171 -0
- package/tsconfig.json +25 -0
|
@@ -0,0 +1,769 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Mermaid diagrams as static, CSP-clean, theme-aware SVG, rendered at build time.
|
|
3
|
+
*
|
|
4
|
+
* Mermaid's raw output cannot be served under `style-src 'self'`. It carries a
|
|
5
|
+
* `<style>` element and dozens of `style=` attributes, which together produced
|
|
6
|
+
* **595 CSP violations across 28 diagrams** in a real browser under this site's
|
|
7
|
+
* exact policy. This module removes both, and the result was measured at **zero
|
|
8
|
+
* violations** on the same corpus while rendering pixel-identically.
|
|
9
|
+
*
|
|
10
|
+
* Three transforms do it, in order:
|
|
11
|
+
*
|
|
12
|
+
* 1. **Flatten the stylesheet.** Every rule in Mermaid's injected `<style>` is
|
|
13
|
+
* matched against the rendered tree and the winning declarations are written
|
|
14
|
+
* onto the elements as SVG presentation attributes, which are real
|
|
15
|
+
* attributes with identical semantics and no CSP relevance. The `<style>`
|
|
16
|
+
* element is then deleted. This is what makes the diagram self-contained: no
|
|
17
|
+
* per-diagram-type stylesheet ships, so a page with one flowchart pays for
|
|
18
|
+
* one flowchart. Flattening rather than hoisting was chosen on a
|
|
19
|
+
* measurement — **86% of the rules in that stylesheet (1,279 of 1,495) match
|
|
20
|
+
* nothing at all**, because Mermaid emits the union of every shape it *could*
|
|
21
|
+
* draw. Hoisting them into 23 per-type stylesheets would have shipped 6,354 B
|
|
22
|
+
* gzip of mostly-dead CSS.
|
|
23
|
+
* 2. **Convert the inline styles.** The `style=` attributes are the same
|
|
24
|
+
* presentation properties in a different syntax, plus the root's intrinsic
|
|
25
|
+
* `max-width`, which becomes a real `width` attribute.
|
|
26
|
+
* 3. **Pair the themes.** See {@link renderDiagram}.
|
|
27
|
+
*
|
|
28
|
+
* **The post-processing pass is also the sanitizer.** Mermaid's own
|
|
29
|
+
* sanitization is off — `securityLevel: 'loose'` is mandatory here, because
|
|
30
|
+
* both stricter levels run the output through DOMPurify, which under happy-dom
|
|
31
|
+
* destroys the entire SVG and returns an *empty string with no error*. So this
|
|
32
|
+
* module strips `<script>`, every `on*` handler, and every `style` attribute
|
|
33
|
+
* unconditionally, and rejects any external reference. That is stronger than
|
|
34
|
+
* `securityLevel: 'strict'` would have been, since DOMPurify preserves the
|
|
35
|
+
* inline styles the CSP forbids.
|
|
36
|
+
*
|
|
37
|
+
* Diagram source comes from the reviewed allowlist, not from untrusted input;
|
|
38
|
+
* this pass is defence in depth rather than the only boundary.
|
|
39
|
+
*/
|
|
40
|
+
import { createMermaidEnvironment, resetSeededRandom } from "./mermaid-environment.js";
|
|
41
|
+
/**
|
|
42
|
+
* Palette values handed to Mermaid, one set per theme.
|
|
43
|
+
*
|
|
44
|
+
* These are the literal hex values of the `--color-*` tokens in
|
|
45
|
+
* `src/styles/tokens.css`. They cannot be the custom properties themselves:
|
|
46
|
+
* Mermaid runs every theme value through `khroma`, which computes derived
|
|
47
|
+
* shades by parsing the colour, and `var(--color-bg)` throws
|
|
48
|
+
* `Unsupported color format` at `initialize()`. Verified, not assumed.
|
|
49
|
+
*
|
|
50
|
+
* `tests/math-and-diagrams.test.ts` asserts each value against the token it
|
|
51
|
+
* mirrors, so the two cannot drift.
|
|
52
|
+
*/
|
|
53
|
+
export const THEME_VARIABLES = {
|
|
54
|
+
light: {
|
|
55
|
+
background: '#fbfaf7',
|
|
56
|
+
primaryColor: '#f2f0ea',
|
|
57
|
+
primaryTextColor: '#1b1f24',
|
|
58
|
+
primaryBorderColor: '#767d87',
|
|
59
|
+
lineColor: '#767d87',
|
|
60
|
+
secondaryColor: '#ffffff',
|
|
61
|
+
tertiaryColor: '#ffffff',
|
|
62
|
+
textColor: '#1b1f24',
|
|
63
|
+
mainBkg: '#f2f0ea',
|
|
64
|
+
nodeBorder: '#767d87',
|
|
65
|
+
nodeTextColor: '#1b1f24',
|
|
66
|
+
},
|
|
67
|
+
dark: {
|
|
68
|
+
background: '#0d1013',
|
|
69
|
+
primaryColor: '#1c222a',
|
|
70
|
+
primaryTextColor: '#e9edf2',
|
|
71
|
+
primaryBorderColor: '#69737f',
|
|
72
|
+
lineColor: '#69737f',
|
|
73
|
+
secondaryColor: '#161b21',
|
|
74
|
+
tertiaryColor: '#161b21',
|
|
75
|
+
textColor: '#e9edf2',
|
|
76
|
+
mainBkg: '#1c222a',
|
|
77
|
+
nodeBorder: '#69737f',
|
|
78
|
+
nodeTextColor: '#e9edf2',
|
|
79
|
+
},
|
|
80
|
+
};
|
|
81
|
+
/**
|
|
82
|
+
* CSS properties that are also SVG presentation attributes: settable as real
|
|
83
|
+
* attributes with identical rendering semantics.
|
|
84
|
+
*
|
|
85
|
+
* The list is the SVG 1.1/2 presentation-attribute set restricted to what
|
|
86
|
+
* Mermaid actually emits. A property outside it cannot be expressed as an
|
|
87
|
+
* attribute and is handled by {@link LAYOUT_CLASSES} or dropped.
|
|
88
|
+
*/
|
|
89
|
+
const PRESENTATION_PROPERTIES = new Set([
|
|
90
|
+
'fill', 'fill-opacity', 'fill-rule', 'stroke', 'stroke-width', 'stroke-dasharray',
|
|
91
|
+
'stroke-dashoffset', 'stroke-linecap', 'stroke-linejoin', 'stroke-miterlimit',
|
|
92
|
+
'stroke-opacity', 'opacity', 'font-family', 'font-size', 'font-style', 'font-weight',
|
|
93
|
+
'text-anchor', 'dominant-baseline', 'letter-spacing', 'text-decoration', 'visibility',
|
|
94
|
+
'display', 'color', 'cursor', 'pointer-events', 'shape-rendering', 'overflow',
|
|
95
|
+
'paint-order', 'stop-color', 'stop-opacity', 'rx', 'ry', 'marker-end', 'marker-start',
|
|
96
|
+
'marker-mid', 'clip-path', 'mask', 'filter',
|
|
97
|
+
]);
|
|
98
|
+
/**
|
|
99
|
+
* The handful of non-presentational declarations that still change layout, each
|
|
100
|
+
* mapped to a class in `src/styles/diagram.css`.
|
|
101
|
+
*
|
|
102
|
+
* Measured rather than assumed: across all 23 diagram types, the matching rules
|
|
103
|
+
* carry 417 presentational declarations and only 18 non-presentational ones,
|
|
104
|
+
* and these six properties are all of them. `background-color` on a label and
|
|
105
|
+
* `text-align` inside a `foreignObject` are the two that visibly matter.
|
|
106
|
+
*
|
|
107
|
+
* A closed table rather than generated utility classes, so a Mermaid version
|
|
108
|
+
* bump that starts emitting a seventh property fails
|
|
109
|
+
* `tests/math-and-diagrams.test.ts` rather than silently growing the stylesheet.
|
|
110
|
+
*/
|
|
111
|
+
export const LAYOUT_CLASSES = {
|
|
112
|
+
'text-align:center': 'diagram-center',
|
|
113
|
+
'text-align:left': 'diagram-left',
|
|
114
|
+
'text-align:start': 'diagram-start',
|
|
115
|
+
'white-space:nowrap': 'diagram-nowrap',
|
|
116
|
+
'line-height:1.5': 'diagram-line',
|
|
117
|
+
'mix-blend-mode:multiply': 'diagram-blend',
|
|
118
|
+
// `display` is a presentation attribute on an SVG element and an ordinary
|
|
119
|
+
// property on the HTML labels inside a `foreignObject`, where an attribute
|
|
120
|
+
// does nothing. The journey diagram's sections and tasks are laid out with
|
|
121
|
+
// `display: table` on exactly those HTML elements, so without this they
|
|
122
|
+
// rendered as blocks — the one place a dropped declaration changed layout
|
|
123
|
+
// rather than only colour.
|
|
124
|
+
'display:table': 'diagram-table',
|
|
125
|
+
};
|
|
126
|
+
/** Attributes that may carry a URL, checked against the same-origin rule below. */
|
|
127
|
+
const URL_ATTRIBUTES = ['href', 'xlink:href', 'src'];
|
|
128
|
+
/** Presentation attributes only apply to elements in this namespace. */
|
|
129
|
+
const SVG_NAMESPACE = 'http://www.w3.org/2000/svg';
|
|
130
|
+
/** Thrown when a diagram cannot be rendered safely. The build stops. */
|
|
131
|
+
export class DiagramError extends Error {
|
|
132
|
+
source;
|
|
133
|
+
constructor(source, message) {
|
|
134
|
+
super(`${message}\n in diagram: ${source.trim().slice(0, 200)}`);
|
|
135
|
+
this.name = 'DiagramError';
|
|
136
|
+
this.source = source;
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
/** Split a declaration list, respecting quotes and parentheses. */
|
|
140
|
+
function declarationsOf(css) {
|
|
141
|
+
const parts = [];
|
|
142
|
+
let buffer = '';
|
|
143
|
+
let quote;
|
|
144
|
+
let depth = 0;
|
|
145
|
+
for (const character of css) {
|
|
146
|
+
if (quote !== undefined) {
|
|
147
|
+
buffer += character;
|
|
148
|
+
if (character === quote)
|
|
149
|
+
quote = undefined;
|
|
150
|
+
continue;
|
|
151
|
+
}
|
|
152
|
+
if (character === '"' || character === "'") {
|
|
153
|
+
quote = character;
|
|
154
|
+
buffer += character;
|
|
155
|
+
continue;
|
|
156
|
+
}
|
|
157
|
+
if (character === '(')
|
|
158
|
+
depth += 1;
|
|
159
|
+
if (character === ')')
|
|
160
|
+
depth -= 1;
|
|
161
|
+
if (character === ';' && depth === 0) {
|
|
162
|
+
if (buffer.trim() !== '')
|
|
163
|
+
parts.push(buffer.trim());
|
|
164
|
+
buffer = '';
|
|
165
|
+
continue;
|
|
166
|
+
}
|
|
167
|
+
buffer += character;
|
|
168
|
+
}
|
|
169
|
+
if (buffer.trim() !== '')
|
|
170
|
+
parts.push(buffer.trim());
|
|
171
|
+
return parts.flatMap((part) => {
|
|
172
|
+
const separator = part.indexOf(':');
|
|
173
|
+
if (separator < 0)
|
|
174
|
+
return [];
|
|
175
|
+
const raw = part.slice(separator + 1).trim();
|
|
176
|
+
const isImportant = /!\s*important$/i.test(raw);
|
|
177
|
+
return [{
|
|
178
|
+
property: part.slice(0, separator).trim().toLowerCase(),
|
|
179
|
+
value: raw.replace(/!\s*important$/i, '').trim(),
|
|
180
|
+
isImportant,
|
|
181
|
+
}];
|
|
182
|
+
});
|
|
183
|
+
}
|
|
184
|
+
/** Style rules in the injected stylesheet, in document order. At-rules are dropped. */
|
|
185
|
+
function styleRules(css) {
|
|
186
|
+
// `@keyframes` and `@media` blocks: the only two Mermaid emits, both for
|
|
187
|
+
// animated edges the static output never uses.
|
|
188
|
+
const withoutAtRules = css.replace(/@[\w-]+[^{]*\{(?:[^{}]|\{[^{}]*\})*\}/g, '');
|
|
189
|
+
return [...withoutAtRules.matchAll(/([^{}]+)\{([^{}]*)\}/g)].map(([, selector, body]) => ({
|
|
190
|
+
selector: selector.trim(),
|
|
191
|
+
body: body.trim(),
|
|
192
|
+
}));
|
|
193
|
+
}
|
|
194
|
+
/**
|
|
195
|
+
* Split a selector list at top-level commas.
|
|
196
|
+
*
|
|
197
|
+
* Load-bearing for {@link specificityOf}: CSS specificity is a property of each
|
|
198
|
+
* selector in a list, never of the list. Scoring
|
|
199
|
+
* `#d .actor-man circle, #d line` as one string counts two ids and beats
|
|
200
|
+
* `#d .messageLine0`, so a sequence diagram's message lines took the generic
|
|
201
|
+
* 2 px stroke instead of their own 1.5 px. Caught by a computed-style
|
|
202
|
+
* comparison against the unflattened SVG in a real browser.
|
|
203
|
+
*/
|
|
204
|
+
function splitSelectorList(selector) {
|
|
205
|
+
const parts = [];
|
|
206
|
+
let depth = 0;
|
|
207
|
+
let quote;
|
|
208
|
+
let start = 0;
|
|
209
|
+
for (let index = 0; index < selector.length; index += 1) {
|
|
210
|
+
const character = selector[index];
|
|
211
|
+
if (quote !== undefined) {
|
|
212
|
+
if (character === quote && selector[index - 1] !== '\\')
|
|
213
|
+
quote = undefined;
|
|
214
|
+
continue;
|
|
215
|
+
}
|
|
216
|
+
if (character === '"' || character === "'")
|
|
217
|
+
quote = character;
|
|
218
|
+
else if (character === '(' || character === '[')
|
|
219
|
+
depth += 1;
|
|
220
|
+
else if (character === ')' || character === ']')
|
|
221
|
+
depth -= 1;
|
|
222
|
+
else if (character === ',' && depth === 0) {
|
|
223
|
+
parts.push(selector.slice(start, index));
|
|
224
|
+
start = index + 1;
|
|
225
|
+
}
|
|
226
|
+
}
|
|
227
|
+
parts.push(selector.slice(start));
|
|
228
|
+
return parts.map((part) => part.trim()).filter((part) => part !== '');
|
|
229
|
+
}
|
|
230
|
+
/**
|
|
231
|
+
* Specificity as a single comparable number, for *one* selector.
|
|
232
|
+
*
|
|
233
|
+
* Needed, not speculative: 69 real conflicts were measured across the corpus —
|
|
234
|
+
* `class` alone has 41, where `.marker { fill: … }` and
|
|
235
|
+
* `[id$="-aggregationStart"], .aggregation { fill: transparent !important }`
|
|
236
|
+
* both target the same element with different values. Taking the last rule
|
|
237
|
+
* would paint every class-diagram arrowhead solid instead of hollow.
|
|
238
|
+
*
|
|
239
|
+
* Only the three columns CSS defines, in the same order. The multipliers are
|
|
240
|
+
* wide enough that no realistic selector overflows a column: Mermaid's most
|
|
241
|
+
* complex is four compound parts.
|
|
242
|
+
*/
|
|
243
|
+
function specificityOf(selector) {
|
|
244
|
+
const ids = (selector.match(/#[\w-]+/g) ?? []).length;
|
|
245
|
+
const classes = (selector.match(/\.[\w-]+|\[[^\]]*\]|:(?!:)[\w-]+/g) ?? []).length;
|
|
246
|
+
const types = (selector.match(/(?:^|[\s>+~])[a-zA-Z][\w-]*/g) ?? []).length;
|
|
247
|
+
return ids * 10000 + classes * 100 + types;
|
|
248
|
+
}
|
|
249
|
+
/** Whether declaration `a` wins over `b` under the cascade. */
|
|
250
|
+
function outranks(a, b) {
|
|
251
|
+
// Compared as a tuple rather than folded into one number. A single
|
|
252
|
+
// `important * K + specificity * N + order` score inverts as soon as the rule
|
|
253
|
+
// count exceeds `N`: at `N = 1000`, specificity 100 at order 1500 scores
|
|
254
|
+
// 101500 and beats specificity 101 at order 0. Mermaid's largest stylesheet
|
|
255
|
+
// is 204 rules today, so it was not reachable — but a version bump is exactly
|
|
256
|
+
// the event that would reach it, and a tuple cannot overflow at all.
|
|
257
|
+
if (a.isImportant !== b.isImportant)
|
|
258
|
+
return a.isImportant;
|
|
259
|
+
if (a.specificity !== b.specificity)
|
|
260
|
+
return a.specificity > b.specificity;
|
|
261
|
+
return a.order >= b.order;
|
|
262
|
+
}
|
|
263
|
+
/**
|
|
264
|
+
* Write the stylesheet's declarations onto the elements they select, then
|
|
265
|
+
* delete the stylesheet.
|
|
266
|
+
*
|
|
267
|
+
* Selectors are resolved by the DOM's own `querySelectorAll`, so this does not
|
|
268
|
+
* reimplement selector matching — only the cascade's ordering, and only over
|
|
269
|
+
* one stylesheet with no inheritance to consider (every property here is set
|
|
270
|
+
* directly on the element that needs it).
|
|
271
|
+
*/
|
|
272
|
+
function flattenStylesheet(root, css) {
|
|
273
|
+
const winners = new Map();
|
|
274
|
+
for (const [order, rule] of styleRules(css).entries()) {
|
|
275
|
+
const declarations = declarationsOf(rule.body).filter(({ property, value }) => PRESENTATION_PROPERTIES.has(property) || LAYOUT_CLASSES[`${property}:${value}`] !== undefined);
|
|
276
|
+
if (declarations.length === 0)
|
|
277
|
+
continue;
|
|
278
|
+
// Each selector in a list is matched and scored on its own, because CSS
|
|
279
|
+
// specificity is a property of one selector rather than of the list.
|
|
280
|
+
for (const selector of splitSelectorList(rule.selector)) {
|
|
281
|
+
let matches;
|
|
282
|
+
try {
|
|
283
|
+
// `querySelectorAll` searches *descendants only*, and Mermaid's very
|
|
284
|
+
// first rule targets the root: `#<renderId> { font-family; font-size;
|
|
285
|
+
// fill }`. Missing it dropped the fill that every SVG `<text>` inherits,
|
|
286
|
+
// so diagram text fell back to the initial black — invisible on the dark
|
|
287
|
+
// palette, and wrong on the light one.
|
|
288
|
+
matches = [...root.querySelectorAll(selector)];
|
|
289
|
+
if (root.matches(selector))
|
|
290
|
+
matches.unshift(root);
|
|
291
|
+
}
|
|
292
|
+
catch {
|
|
293
|
+
// A selector the DOM cannot parse selects nothing, which is also what a
|
|
294
|
+
// browser would do with it.
|
|
295
|
+
continue;
|
|
296
|
+
}
|
|
297
|
+
if (matches.length === 0)
|
|
298
|
+
continue;
|
|
299
|
+
const base = specificityOf(selector);
|
|
300
|
+
for (const { property, value, isImportant } of declarations) {
|
|
301
|
+
const candidate = { isImportant, specificity: base, order };
|
|
302
|
+
for (const element of matches) {
|
|
303
|
+
let byProperty = winners.get(element);
|
|
304
|
+
if (byProperty === undefined) {
|
|
305
|
+
byProperty = new Map();
|
|
306
|
+
winners.set(element, byProperty);
|
|
307
|
+
}
|
|
308
|
+
const existing = byProperty.get(property);
|
|
309
|
+
if (existing === undefined || outranks(candidate, existing)) {
|
|
310
|
+
byProperty.set(property, { value, ...candidate });
|
|
311
|
+
}
|
|
312
|
+
}
|
|
313
|
+
}
|
|
314
|
+
}
|
|
315
|
+
}
|
|
316
|
+
for (const [element, byProperty] of winners) {
|
|
317
|
+
for (const [property, { value }] of byProperty) {
|
|
318
|
+
// A stylesheet rule beats a presentation attribute the renderer already
|
|
319
|
+
// wrote — verified in Chromium, not assumed: given
|
|
320
|
+
// `.styled { fill: … }` and `<rect class="styled" fill="#eaeaea">`, the
|
|
321
|
+
// computed fill is the stylesheet's. Declining to overwrite here left the
|
|
322
|
+
// wrong colour on every element Mermaid had pre-painted, which showed as
|
|
323
|
+
// 20 mismatched sequence-diagram actors and 16 class-diagram paths in the
|
|
324
|
+
// computed-style comparison against the unflattened SVG.
|
|
325
|
+
//
|
|
326
|
+
// The cascade below the flattening is then: stylesheet rules first
|
|
327
|
+
// (resolved by specificity, here), then inline `style=` on top, which
|
|
328
|
+
// `convertInlineStyles` applies afterwards with `overrideExisting`.
|
|
329
|
+
applyDeclaration(element, property, value, true);
|
|
330
|
+
}
|
|
331
|
+
}
|
|
332
|
+
for (const style of [...root.querySelectorAll('style')]) {
|
|
333
|
+
style.remove();
|
|
334
|
+
}
|
|
335
|
+
}
|
|
336
|
+
/**
|
|
337
|
+
* Put one declaration on an element as a presentation attribute or a class.
|
|
338
|
+
*
|
|
339
|
+
* **Only on an SVG element.** A presentation attribute is an SVG concept: on an
|
|
340
|
+
* HTML element inside a `foreignObject` — which is where Mermaid puts every
|
|
341
|
+
* label — `fill="…"` and `display="table"` are inert junk attributes that style
|
|
342
|
+
* nothing. Writing them there both lost the declaration and put two
|
|
343
|
+
* meaningless attributes into every label; a computed-style comparison against
|
|
344
|
+
* the unflattened SVG showed the journey diagram's sections losing their
|
|
345
|
+
* `display: table`. For those elements the class table below is the only route,
|
|
346
|
+
* which is why `LAYOUT_CLASSES` covers exactly the properties that reach them.
|
|
347
|
+
*
|
|
348
|
+
* `overrideExisting` is the difference between the two callers. A stylesheet
|
|
349
|
+
* rule overrides a presentation attribute the renderer already wrote, because
|
|
350
|
+
* that is what the cascade does — verified in Chromium. An inline `style=`
|
|
351
|
+
* overrides both, and `convertInlineStyles` applies it afterwards.
|
|
352
|
+
*/
|
|
353
|
+
function applyDeclaration(element, property, value, overrideExisting) {
|
|
354
|
+
const layoutClass = LAYOUT_CLASSES[`${property}:${value.replace(/\s+/g, '')}`];
|
|
355
|
+
if (layoutClass !== undefined) {
|
|
356
|
+
const existing = element.getAttribute('class');
|
|
357
|
+
if (existing === null)
|
|
358
|
+
element.setAttribute('class', layoutClass);
|
|
359
|
+
else if (!existing.split(/\s+/).includes(layoutClass))
|
|
360
|
+
element.setAttribute('class', `${existing} ${layoutClass}`);
|
|
361
|
+
return;
|
|
362
|
+
}
|
|
363
|
+
if (!PRESENTATION_PROPERTIES.has(property))
|
|
364
|
+
return;
|
|
365
|
+
if (element.namespaceURI !== SVG_NAMESPACE)
|
|
366
|
+
return;
|
|
367
|
+
if (!overrideExisting && element.hasAttribute(property))
|
|
368
|
+
return;
|
|
369
|
+
element.setAttribute(property, value);
|
|
370
|
+
}
|
|
371
|
+
/**
|
|
372
|
+
* Convert every `style=` attribute into presentation attributes and classes,
|
|
373
|
+
* then remove it.
|
|
374
|
+
*
|
|
375
|
+
* The root `<svg>`'s `max-width` is the diagram's intrinsic width, which is why
|
|
376
|
+
* it becomes a real `width` attribute rather than a class: without it the SVG
|
|
377
|
+
* stretches to fill its container instead of sitting at its natural size. The
|
|
378
|
+
* paired `height` is deliberately not set — the `viewBox` already fixes the
|
|
379
|
+
* aspect ratio, and setting both would stop the diagram scaling down on a
|
|
380
|
+
* narrow screen, which is the one thing a 320 px viewport needs it to do.
|
|
381
|
+
*/
|
|
382
|
+
function convertInlineStyles(root) {
|
|
383
|
+
const all = [root, ...[...root.querySelectorAll('[style]')]];
|
|
384
|
+
for (const element of all) {
|
|
385
|
+
const css = element.getAttribute('style');
|
|
386
|
+
if (css === null)
|
|
387
|
+
continue;
|
|
388
|
+
for (const { property, value } of declarationsOf(css)) {
|
|
389
|
+
if (property === 'max-width' && element === root) {
|
|
390
|
+
const width = Number.parseFloat(value);
|
|
391
|
+
if (Number.isFinite(width))
|
|
392
|
+
element.setAttribute('width', String(width));
|
|
393
|
+
continue;
|
|
394
|
+
}
|
|
395
|
+
applyDeclaration(element, property, value, true);
|
|
396
|
+
}
|
|
397
|
+
element.removeAttribute('style');
|
|
398
|
+
}
|
|
399
|
+
}
|
|
400
|
+
/**
|
|
401
|
+
* Remove everything the projection does not vouch for, and fail on anything
|
|
402
|
+
* unexpected rather than shipping it.
|
|
403
|
+
*
|
|
404
|
+
* Mermaid emits none of these today — measured at 0 scripts and 0 `on*`
|
|
405
|
+
* handlers across the corpus — which is exactly why a *failure* is the right
|
|
406
|
+
* response to finding one: it would mean the renderer changed under us.
|
|
407
|
+
*/
|
|
408
|
+
function sanitizeDiagram(root, source) {
|
|
409
|
+
if (root.querySelector('script') !== null) {
|
|
410
|
+
throw new DiagramError(source, 'the renderer emitted a <script> element');
|
|
411
|
+
}
|
|
412
|
+
for (const element of [root, ...[...root.querySelectorAll('*')]]) {
|
|
413
|
+
for (const attribute of [...element.attributes].map((item) => item.name)) {
|
|
414
|
+
if (/^on/i.test(attribute)) {
|
|
415
|
+
throw new DiagramError(source, `the renderer emitted an inline handler: ${attribute}`);
|
|
416
|
+
}
|
|
417
|
+
}
|
|
418
|
+
for (const attribute of URL_ATTRIBUTES) {
|
|
419
|
+
const value = element.getAttribute(attribute);
|
|
420
|
+
if (value === null)
|
|
421
|
+
continue;
|
|
422
|
+
// A fragment reference into the diagram's own defs, or the one base64
|
|
423
|
+
// raster image C4 embeds for its icons. Anything else is an outbound
|
|
424
|
+
// request from a static diagram, which nothing in this pipeline should
|
|
425
|
+
// produce.
|
|
426
|
+
const isLocal = value.startsWith('#');
|
|
427
|
+
const isInlineRaster = /^data:image\/(?:png|jpe?g|gif|webp|avif);base64,[A-Za-z0-9+/=]*$/i.test(value);
|
|
428
|
+
if (!isLocal && !isInlineRaster) {
|
|
429
|
+
throw new DiagramError(source, `the renderer emitted an external reference: ${attribute}="${value.slice(0, 80)}"`);
|
|
430
|
+
}
|
|
431
|
+
}
|
|
432
|
+
}
|
|
433
|
+
}
|
|
434
|
+
/**
|
|
435
|
+
* Rewrite every generated id so it is unique across the page and stable across
|
|
436
|
+
* builds.
|
|
437
|
+
*
|
|
438
|
+
* Both halves are required and for different reasons. *Unique*, because
|
|
439
|
+
* Mermaid's ids are only unique within one diagram, and two diagrams on one
|
|
440
|
+
* page would otherwise both define `#arrowhead` — the second definition wins
|
|
441
|
+
* for both, silently. *Stable*, because Mermaid's module-level counters
|
|
442
|
+
* continue across renders in a shared process, so the same diagram gets
|
|
443
|
+
* `node-1` in one build and `node-10` in the next; geometry is byte-identical
|
|
444
|
+
* but the file is not, which breaks the determinism requirement and would
|
|
445
|
+
* change a content hash on every build.
|
|
446
|
+
*
|
|
447
|
+
* Sequential renumbering in document order fixes both: the id becomes a
|
|
448
|
+
* function of the diagram's structure and its position on the page, neither of
|
|
449
|
+
* which depends on what else the process rendered first.
|
|
450
|
+
*/
|
|
451
|
+
/**
|
|
452
|
+
* Attributes whose value is a bare id, or a space-separated list of them,
|
|
453
|
+
* rather than a `#`-prefixed fragment.
|
|
454
|
+
*
|
|
455
|
+
* These have to be rewritten alongside the fragment references or renumbering
|
|
456
|
+
* breaks them. Mermaid emits `aria-labelledby="chart-title-<renderId>"` beside
|
|
457
|
+
* a `<title>` carrying that id whenever a diagram declares `accTitle`, and
|
|
458
|
+
* `aria-describedby` likewise for `accDescr` — so without this the accessible
|
|
459
|
+
* name pointed at an element that no longer existed. A dangling IDREF is an
|
|
460
|
+
* axe failure and, worse, a *silent* one: the name simply vanishes.
|
|
461
|
+
*/
|
|
462
|
+
const IDREF_ATTRIBUTES = ['aria-labelledby', 'aria-describedby', 'headers'];
|
|
463
|
+
function renumberIds(root, prefix) {
|
|
464
|
+
const rename = new Map();
|
|
465
|
+
let next = 0;
|
|
466
|
+
for (const element of [root, ...[...root.querySelectorAll('[id]')]]) {
|
|
467
|
+
const id = element.getAttribute('id');
|
|
468
|
+
if (id === null || id === '')
|
|
469
|
+
continue;
|
|
470
|
+
const replacement = `${prefix}-${next}`;
|
|
471
|
+
next += 1;
|
|
472
|
+
rename.set(id, replacement);
|
|
473
|
+
element.setAttribute('id', replacement);
|
|
474
|
+
}
|
|
475
|
+
if (rename.size === 0)
|
|
476
|
+
return;
|
|
477
|
+
const substitute = (value) => value.replace(/url\(#([^)]*)\)/g, (match, reference) => {
|
|
478
|
+
const replacement = rename.get(reference);
|
|
479
|
+
return replacement === undefined ? match : `url(#${replacement})`;
|
|
480
|
+
});
|
|
481
|
+
for (const element of [root, ...[...root.querySelectorAll('*')]]) {
|
|
482
|
+
for (const { name, value } of Array.from(element.attributes, (item) => ({
|
|
483
|
+
name: item.name,
|
|
484
|
+
value: item.value,
|
|
485
|
+
}))) {
|
|
486
|
+
if (name === 'id')
|
|
487
|
+
continue;
|
|
488
|
+
if (IDREF_ATTRIBUTES.includes(name)) {
|
|
489
|
+
element.setAttribute(name, value
|
|
490
|
+
.split(/\s+/)
|
|
491
|
+
.filter(Boolean)
|
|
492
|
+
.map((token) => rename.get(token) ?? token)
|
|
493
|
+
.join(' '));
|
|
494
|
+
continue;
|
|
495
|
+
}
|
|
496
|
+
if (value.startsWith('#')) {
|
|
497
|
+
const replacement = rename.get(value.slice(1));
|
|
498
|
+
if (replacement !== undefined)
|
|
499
|
+
element.setAttribute(name, `#${replacement}`);
|
|
500
|
+
continue;
|
|
501
|
+
}
|
|
502
|
+
if (value.includes('url(#'))
|
|
503
|
+
element.setAttribute(name, substitute(value));
|
|
504
|
+
}
|
|
505
|
+
}
|
|
506
|
+
}
|
|
507
|
+
/**
|
|
508
|
+
* Every colour in the SVG, in document order, as (element, attribute) pairs.
|
|
509
|
+
*
|
|
510
|
+
* Used to pair a light render with a dark one. Order is the pairing key, which
|
|
511
|
+
* is sound precisely because the two renders are structurally identical — see
|
|
512
|
+
* {@link renderDiagram}.
|
|
513
|
+
*/
|
|
514
|
+
function colourSlots(root) {
|
|
515
|
+
const COLOUR_ATTRIBUTES = ['fill', 'stroke', 'stop-color', 'flood-color', 'color'];
|
|
516
|
+
const slots = [];
|
|
517
|
+
for (const element of [root, ...[...root.querySelectorAll('*')]]) {
|
|
518
|
+
for (const attribute of COLOUR_ATTRIBUTES) {
|
|
519
|
+
const value = element.getAttribute(attribute);
|
|
520
|
+
if (value !== null && /^(?:#[0-9a-fA-F]{3,8}|rgba?\(|hsla?\()/.test(value)) {
|
|
521
|
+
slots.push({ element, attribute });
|
|
522
|
+
}
|
|
523
|
+
}
|
|
524
|
+
}
|
|
525
|
+
return slots;
|
|
526
|
+
}
|
|
527
|
+
/** The document-order structure of an element tree, ignoring colour values. */
|
|
528
|
+
function structureOf(root) {
|
|
529
|
+
const COLOUR_ATTRIBUTES = new Set(['fill', 'stroke', 'stop-color', 'flood-color', 'color']);
|
|
530
|
+
const parts = [];
|
|
531
|
+
const visit = (element) => {
|
|
532
|
+
const attributes = [...element.attributes]
|
|
533
|
+
.map((item) => (COLOUR_ATTRIBUTES.has(item.name) ? `${item.name}=@` : `${item.name}=${item.value}`))
|
|
534
|
+
.sort()
|
|
535
|
+
.join(' ');
|
|
536
|
+
parts.push(`<${element.tagName}|${attributes}>`);
|
|
537
|
+
for (const child of [...element.children])
|
|
538
|
+
visit(child);
|
|
539
|
+
};
|
|
540
|
+
visit(root);
|
|
541
|
+
return parts.join('');
|
|
542
|
+
}
|
|
543
|
+
/**
|
|
544
|
+
* A complete colour value. Anchored at both ends, unlike the slot filter.
|
|
545
|
+
*
|
|
546
|
+
* `NaN` is excluded by construction — `[\d\s.,%/-]` admits no letters — and
|
|
547
|
+
* that is load-bearing rather than incidental. Mermaid emits
|
|
548
|
+
* `hsl(45, 23.5294117647%, NaN%)` for a quadrant chart's points under a `base`
|
|
549
|
+
* theme with no `quadrantPointFill` supplied: an invalid colour that every
|
|
550
|
+
* browser discards, painting the point the initial black. Pairing it would have
|
|
551
|
+
* produced `light-dark(<valid>, hsl(…NaN%))`, where one arm is invalid and the
|
|
552
|
+
* *whole function* is invalid, so the element would lose its colour in **both**
|
|
553
|
+
* themes rather than one.
|
|
554
|
+
*/
|
|
555
|
+
const COLOUR_VALUE = /^(?:#[0-9a-fA-F]{3,8}|(?:rgba?|hsla?)\([\d\s.,%/-]+\)|currentColor|transparent|none)$/;
|
|
556
|
+
/**
|
|
557
|
+
* Diagrams whose two renders could not be paired, and which therefore ship in
|
|
558
|
+
* the light palette in both themes.
|
|
559
|
+
*
|
|
560
|
+
* Four diagram types were measured as structurally unstable across renders and
|
|
561
|
+
* are expected here; anything else appearing is a regression, which
|
|
562
|
+
* `tests/math-and-diagrams.test.ts` asserts against. Exported rather than
|
|
563
|
+
* logged because a warning in a build log is not a gate.
|
|
564
|
+
*/
|
|
565
|
+
export const UNPAIRED_DIAGRAMS = new Set();
|
|
566
|
+
let environment;
|
|
567
|
+
/**
|
|
568
|
+
* Diagram renders run one at a time, process-wide.
|
|
569
|
+
*
|
|
570
|
+
* Mermaid is a singleton in three ways that all break under concurrency, and
|
|
571
|
+
* none of them fails loudly: `initialize()` sets **global** configuration, so
|
|
572
|
+
* an interleaved render reads whichever theme was installed last; module-level
|
|
573
|
+
* counters feed element ids; and the seeded PRNG this module resets before each
|
|
574
|
+
* render is one shared sequence. Astro renders pages concurrently, so this is
|
|
575
|
+
* not hypothetical — the failure it fixes was two renders of the *same* source
|
|
576
|
+
* in one build producing different bytes, which is precisely the determinism
|
|
577
|
+
* property the ticket requires.
|
|
578
|
+
*
|
|
579
|
+
* A promise chain rather than a lock: each call appends itself to the tail, so
|
|
580
|
+
* renders queue in call order and no caller has to know the queue exists. The
|
|
581
|
+
* cost is that diagram rendering is serial — at ~50 ms per warm render, a page
|
|
582
|
+
* with three diagrams spends 150 ms, which is not worth a second process to
|
|
583
|
+
* parallelize.
|
|
584
|
+
*
|
|
585
|
+
* ponytail: one global queue, not one per diagram type. Per-type queues would
|
|
586
|
+
* not help, because the contended state is Mermaid's global config, not
|
|
587
|
+
* anything per type.
|
|
588
|
+
*/
|
|
589
|
+
let renderQueue = Promise.resolve();
|
|
590
|
+
function serialize(work) {
|
|
591
|
+
// `catch` keeps one failed render from poisoning the queue for the next
|
|
592
|
+
// caller, which would turn a single bad diagram into a build-wide failure
|
|
593
|
+
// reported against an innocent page.
|
|
594
|
+
const result = renderQueue.then(work, work);
|
|
595
|
+
renderQueue = result.catch(() => undefined);
|
|
596
|
+
return result;
|
|
597
|
+
}
|
|
598
|
+
/**
|
|
599
|
+
* One diagram as sanitized, CSP-clean, theme-aware SVG markup.
|
|
600
|
+
*
|
|
601
|
+
* **Theme handling is the interesting part, and it costs zero client bytes.**
|
|
602
|
+
* The diagram is rendered twice — once with the light palette, once with the
|
|
603
|
+
* dark — and the two outputs are paired position by position into
|
|
604
|
+
* `light-dark(light, dark)` values on the *same* SVG. The browser then resolves
|
|
605
|
+
* each against `color-scheme` at paint time, so one diagram serves both themes,
|
|
606
|
+
* follows the system preference, and follows the site's own toggle, with no
|
|
607
|
+
* second render, no second file, and no script.
|
|
608
|
+
*
|
|
609
|
+
* That pairing is only sound because the two renders are structurally
|
|
610
|
+
* identical, which was measured rather than assumed: with a seeded PRNG and
|
|
611
|
+
* fixed ids, 20 of 24 diagram types produce byte-identical output apart from
|
|
612
|
+
* colour values. The four that do not (`sequence`, `class`, `sankey`,
|
|
613
|
+
* `architecture`) drift in element ids and in a colour *count*, so pairing
|
|
614
|
+
* them positionally would put a dark value in a light slot. They are detected
|
|
615
|
+
* here — not assumed away — and fall back to the light palette, which is
|
|
616
|
+
* legible in both themes because the palette's own borders and text are, and
|
|
617
|
+
* which is exactly what the site would have shipped without this feature.
|
|
618
|
+
*
|
|
619
|
+
* @param source The diagram body, without the fence.
|
|
620
|
+
* @param diagramId A page-unique identifier, used to namespace element ids.
|
|
621
|
+
* @param accessibleName The name assistive technology announces for the
|
|
622
|
+
* diagram. Mermaid gives the root `role="graphics-document"`, which axe
|
|
623
|
+
* requires to be named; the caller passes the same string it renders as the
|
|
624
|
+
* figure's caption, so a reader who hears the name and a reader who sees it
|
|
625
|
+
* get the same words.
|
|
626
|
+
*/
|
|
627
|
+
export async function renderDiagram(source, diagramId, accessibleName) {
|
|
628
|
+
return serialize(() => renderOne(source, diagramId, accessibleName));
|
|
629
|
+
}
|
|
630
|
+
async function renderOne(source, diagramId, accessibleName) {
|
|
631
|
+
environment ??= await createMermaidEnvironment();
|
|
632
|
+
const { mermaid, window } = environment;
|
|
633
|
+
const rendered = {};
|
|
634
|
+
for (const theme of ['light', 'dark']) {
|
|
635
|
+
// Restart the random sequence before every render, so a diagram's output is
|
|
636
|
+
// a function of its own source rather than of how many diagrams the process
|
|
637
|
+
// rendered first. Without this a gitgraph's commit hashes differ between
|
|
638
|
+
// the light and the dark pass, the two renders stop being structurally
|
|
639
|
+
// identical, and the theme pairing silently declines to pair them.
|
|
640
|
+
resetSeededRandom();
|
|
641
|
+
mermaid.initialize({
|
|
642
|
+
startOnLoad: false,
|
|
643
|
+
// Mandatory. Both stricter levels run the output through DOMPurify, which
|
|
644
|
+
// under happy-dom returns an empty string with no error — a silently
|
|
645
|
+
// blank diagram. The assertion below is the backstop for that.
|
|
646
|
+
securityLevel: 'loose',
|
|
647
|
+
// Determinism, part one: element ids derive from a fixed seed rather than
|
|
648
|
+
// a counter's current value.
|
|
649
|
+
deterministicIds: true,
|
|
650
|
+
deterministicIDSeed: diagramId,
|
|
651
|
+
// Determinism, part two: the hand-drawn renderers jitter their paths from
|
|
652
|
+
// `Math.random()` unless seeded, so `class` and `er` produce different
|
|
653
|
+
// path coordinates on every build.
|
|
654
|
+
handDrawnSeed: 42,
|
|
655
|
+
theme: 'base',
|
|
656
|
+
themeVariables: THEME_VARIABLES[theme],
|
|
657
|
+
// Inherit the article's own type stack, so a diagram label is set in the
|
|
658
|
+
// same face as the prose around it.
|
|
659
|
+
fontFamily: 'inherit',
|
|
660
|
+
maxTextSize: 50000,
|
|
661
|
+
maxEdges: 500,
|
|
662
|
+
});
|
|
663
|
+
let svg;
|
|
664
|
+
try {
|
|
665
|
+
({ svg } = await mermaid.render(`${diagramId}-${theme}`, source));
|
|
666
|
+
}
|
|
667
|
+
catch (error) {
|
|
668
|
+
throw new DiagramError(source, error instanceof Error ? error.message.split('\n')[0] : String(error));
|
|
669
|
+
}
|
|
670
|
+
// The DOMPurify failure mode is an empty string, returned successfully. A
|
|
671
|
+
// pipeline that does not assert here ships blank diagrams undetected.
|
|
672
|
+
if (svg.length === 0)
|
|
673
|
+
throw new DiagramError(source, 'the renderer produced empty output');
|
|
674
|
+
rendered[theme] = svg;
|
|
675
|
+
}
|
|
676
|
+
/**
|
|
677
|
+
* Parse with `DOMParser` as XML, never by assigning `innerHTML`.
|
|
678
|
+
*
|
|
679
|
+
* happy-dom's HTML parser mishandles a `<style>` element inside an `<svg>`:
|
|
680
|
+
* it consumes the rest of the document as that element's text, so the parsed
|
|
681
|
+
* tree is an `<svg>` containing one empty `<style>` and nothing else. Every
|
|
682
|
+
* node, every measurement, and every colour is silently gone, and the result
|
|
683
|
+
* is still well-formed markup with a correct `viewBox` — so nothing downstream
|
|
684
|
+
* notices. Mermaid emits exactly that shape on every diagram.
|
|
685
|
+
*
|
|
686
|
+
* The XML path parses it correctly. `image/svg+xml` is also the honest
|
|
687
|
+
* content type: the string is an SVG document, not an HTML fragment.
|
|
688
|
+
*/
|
|
689
|
+
const parse = (svg) => {
|
|
690
|
+
const document = new window.DOMParser().parseFromString(svg, 'image/svg+xml');
|
|
691
|
+
const root = document.documentElement;
|
|
692
|
+
if (root === null || root.tagName.toLowerCase() !== 'svg') {
|
|
693
|
+
throw new DiagramError(source, 'the renderer produced no <svg> element');
|
|
694
|
+
}
|
|
695
|
+
// An XML parse error is reported as a `<parsererror>` element rather than a
|
|
696
|
+
// throw, so it has to be looked for.
|
|
697
|
+
if (root.querySelector('parsererror') !== null) {
|
|
698
|
+
throw new DiagramError(source, 'the renderer produced markup that is not well-formed XML');
|
|
699
|
+
}
|
|
700
|
+
return root;
|
|
701
|
+
};
|
|
702
|
+
const light = parse(rendered.light);
|
|
703
|
+
const dark = parse(rendered.dark);
|
|
704
|
+
for (const root of [light, dark]) {
|
|
705
|
+
const style = root.querySelector('style');
|
|
706
|
+
flattenStylesheet(root, style?.textContent ?? '');
|
|
707
|
+
convertInlineStyles(root);
|
|
708
|
+
}
|
|
709
|
+
// `info` is a version banner with no geometry, and is the only type that
|
|
710
|
+
// legitimately has no viewBox.
|
|
711
|
+
const isInfoBanner = source.trim() === 'info';
|
|
712
|
+
if (!isInfoBanner && !light.hasAttribute('viewBox')) {
|
|
713
|
+
throw new DiagramError(source, 'the renderer produced an SVG with no viewBox');
|
|
714
|
+
}
|
|
715
|
+
renumberIds(light, diagramId);
|
|
716
|
+
renumberIds(dark, diagramId);
|
|
717
|
+
// Pair the palettes. Structural identity is the precondition; when it does
|
|
718
|
+
// not hold, the light render ships unchanged rather than mispaired.
|
|
719
|
+
//
|
|
720
|
+
// **The failure is reported, not silent.** A diagram that pairs nothing looks
|
|
721
|
+
// exactly like a correctly rendered one — it is simply stuck in the light
|
|
722
|
+
// palette — so a silent decline is invisible until a reader in dark mode
|
|
723
|
+
// meets it. That was not hypothetical: an `accTitle` baked the render id into
|
|
724
|
+
// `aria-labelledby`, which made the two structures differ, and every such
|
|
725
|
+
// diagram lost theming with nothing to show for it. `pairedColours` is
|
|
726
|
+
// returned so the caller can gate on it.
|
|
727
|
+
const lightSlots = colourSlots(light);
|
|
728
|
+
const darkSlots = colourSlots(dark);
|
|
729
|
+
let pairedColours = 0;
|
|
730
|
+
if (lightSlots.length === darkSlots.length && structureOf(light) === structureOf(dark)) {
|
|
731
|
+
for (const [index, slot] of lightSlots.entries()) {
|
|
732
|
+
const lightValue = slot.element.getAttribute(slot.attribute);
|
|
733
|
+
const darkValue = darkSlots[index].element.getAttribute(darkSlots[index].attribute);
|
|
734
|
+
if (lightValue === darkValue)
|
|
735
|
+
continue;
|
|
736
|
+
// Both arms must be real colours: the dark one because it is interpolated
|
|
737
|
+
// into a CSS function, and the light one because `light-dark()` is
|
|
738
|
+
// invalid as a whole if either arm is. Mermaid does emit an invalid
|
|
739
|
+
// colour — see `COLOUR_VALUE` — so this is a live case, not a hypothetical
|
|
740
|
+
// one. The pair is skipped rather than thrown on: an unpaired slot keeps
|
|
741
|
+
// whatever Mermaid produced, which is exactly what the diagram would have
|
|
742
|
+
// shipped without theming, whereas failing the build would reject a whole
|
|
743
|
+
// diagram type over one of its renderer's own defects.
|
|
744
|
+
if (!COLOUR_VALUE.test(lightValue) || !COLOUR_VALUE.test(darkValue))
|
|
745
|
+
continue;
|
|
746
|
+
slot.element.setAttribute(slot.attribute, `light-dark(${lightValue}, ${darkValue})`);
|
|
747
|
+
pairedColours += 1;
|
|
748
|
+
}
|
|
749
|
+
}
|
|
750
|
+
UNPAIRED_DIAGRAMS.delete(diagramId);
|
|
751
|
+
if (pairedColours === 0 && lightSlots.length > 0)
|
|
752
|
+
UNPAIRED_DIAGRAMS.add(diagramId);
|
|
753
|
+
sanitizeDiagram(light, source);
|
|
754
|
+
// The diagram is content, not decoration. Mermaid gives the root
|
|
755
|
+
// `role="graphics-document document"` and an `aria-roledescription`, and a
|
|
756
|
+
// role of that kind must have an accessible name or axe reports it — so the
|
|
757
|
+
// name is set here from the same string the caption renders.
|
|
758
|
+
light.setAttribute('aria-label', accessibleName);
|
|
759
|
+
// Not a tab stop. The SVG holds no control and no scrollable overflow, so
|
|
760
|
+
// making it focusable would add a stop that announces the name and offers
|
|
761
|
+
// nothing to do; the caption already carries the name in the reading order.
|
|
762
|
+
light.setAttribute('focusable', 'false');
|
|
763
|
+
// A width attribute plus `max-width: 100%` in the stylesheet lets the diagram
|
|
764
|
+
// shrink below its intrinsic size on a narrow screen without stretching past
|
|
765
|
+
// it on a wide one. The `viewBox` already fixes the aspect ratio, so setting
|
|
766
|
+
// `height` too would stop it scaling — the one thing 320 px needs.
|
|
767
|
+
light.removeAttribute('height');
|
|
768
|
+
return light.outerHTML;
|
|
769
|
+
}
|