@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.
Files changed (111) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +295 -0
  3. package/astro.config.mjs +255 -0
  4. package/bin/anc.mjs +798 -0
  5. package/package.json +93 -0
  6. package/public/_headers +45 -0
  7. package/public/favicon.ico +0 -0
  8. package/public/favicon.svg +9 -0
  9. package/public/fonts/Temml.woff2 +0 -0
  10. package/scripts/build-snapshot.js +69 -0
  11. package/scripts/build-wasm.js +67 -0
  12. package/scripts/build-workspace.js +50 -0
  13. package/scripts/copy-snapshot.js +48 -0
  14. package/scripts/copy-wasm.js +57 -0
  15. package/scripts/emit-redirects.js +64 -0
  16. package/scripts/generate-corpus.js +421 -0
  17. package/scripts/init-repository.js +249 -0
  18. package/scripts/load-config.js +975 -0
  19. package/scripts/markdown-to-artifact.js +1286 -0
  20. package/scripts/preview-site.js +364 -0
  21. package/scripts/publish-set-review.js +157 -0
  22. package/scripts/resolve-links.js +640 -0
  23. package/scripts/run-pagefind.js +125 -0
  24. package/scripts/scan-residue.js +1016 -0
  25. package/scripts/scan-secrets.js +268 -0
  26. package/scripts/seed-gitignore.js +410 -0
  27. package/scripts/snapshot-rows.js +205 -0
  28. package/scripts/validate-content.js +95 -0
  29. package/scripts/vendor-provenance.js +307 -0
  30. package/scripts/verify-output-inventory.js +307 -0
  31. package/scripts/write-report.js +351 -0
  32. package/scripts/write-snapshot.js +136 -0
  33. package/src/components/CollectionExplorer.astro +149 -0
  34. package/src/components/FacetIndex.astro +45 -0
  35. package/src/components/LinkedNotes.astro +94 -0
  36. package/src/components/NoteGraph.astro +434 -0
  37. package/src/components/NoteList.astro +54 -0
  38. package/src/components/SiteMetadata.astro +143 -0
  39. package/src/components/TableOfContents.astro +69 -0
  40. package/src/components/TagBrowser.astro +70 -0
  41. package/src/layouts/Layout.astro +305 -0
  42. package/src/lib/artifact-source.js +72 -0
  43. package/src/lib/build-bindings.js +11 -0
  44. package/src/lib/collection-navigation.js +145 -0
  45. package/src/lib/content.js +92 -0
  46. package/src/lib/diagram-mode.js +18 -0
  47. package/src/lib/graph-layout.js +219 -0
  48. package/src/lib/graph-selection.js +84 -0
  49. package/src/lib/graph.js +42 -0
  50. package/src/lib/link-resolution.js +389 -0
  51. package/src/lib/markdown.js +1469 -0
  52. package/src/lib/math-mode.js +31 -0
  53. package/src/lib/math-output.js +131 -0
  54. package/src/lib/math.js +165 -0
  55. package/src/lib/mermaid-environment.js +475 -0
  56. package/src/lib/mermaid-render.js +769 -0
  57. package/src/lib/preview-model.js +96 -0
  58. package/src/lib/relations.js +242 -0
  59. package/src/lib/rendered-marker.js +55 -0
  60. package/src/lib/route-path.js +192 -0
  61. package/src/lib/routes.js +342 -0
  62. package/src/lib/schema.js +578 -0
  63. package/src/lib/site.js +578 -0
  64. package/src/lib/snapshot-contract.js +216 -0
  65. package/src/lib/snapshot-operations.js +147 -0
  66. package/src/lib/snapshot-queries.js +216 -0
  67. package/src/lib/snapshot-reader.js +210 -0
  68. package/src/lib/snapshot.js +233 -0
  69. package/src/lib/sqlite-warning.js +22 -0
  70. package/src/lib/sqlite.js +14 -0
  71. package/src/lib/tag-browser-model.js +18 -0
  72. package/src/lib/translations.js +533 -0
  73. package/src/lib/wasm-asset.js +47 -0
  74. package/src/lib/worker-protocol.js +93 -0
  75. package/src/pages/404.astro +35 -0
  76. package/src/pages/about.astro +100 -0
  77. package/src/pages/collections/[slug].astro +36 -0
  78. package/src/pages/collections/index.astro +33 -0
  79. package/src/pages/graph.astro +64 -0
  80. package/src/pages/index.astro +33 -0
  81. package/src/pages/notes/[slug].astro +509 -0
  82. package/src/pages/privacy.astro +121 -0
  83. package/src/pages/private.astro +59 -0
  84. package/src/pages/recent.astro +33 -0
  85. package/src/pages/robots.txt.js +15 -0
  86. package/src/pages/rss.xml.js +21 -0
  87. package/src/pages/sitemap.xml.js +20 -0
  88. package/src/pages/tags/[tag].astro +56 -0
  89. package/src/pages/tags/index.astro +37 -0
  90. package/src/scripts/code-copy.js +45 -0
  91. package/src/scripts/diagram-disabled.js +16 -0
  92. package/src/scripts/diagram.js +322 -0
  93. package/src/scripts/graph-client.js +222 -0
  94. package/src/scripts/graph-draw.js +166 -0
  95. package/src/scripts/link-preview.js +334 -0
  96. package/src/scripts/math-disabled.js +18 -0
  97. package/src/scripts/math.js +133 -0
  98. package/src/scripts/preferences.js +123 -0
  99. package/src/scripts/search-dialog.js +651 -0
  100. package/src/scripts/snapshot-client.js +157 -0
  101. package/src/scripts/snapshot-worker.js +341 -0
  102. package/src/scripts/tag-browser.js +153 -0
  103. package/src/scripts/theme-init.js +35 -0
  104. package/src/scripts/toc-active.js +38 -0
  105. package/src/styles/code.css +152 -0
  106. package/src/styles/diagram.css +149 -0
  107. package/src/styles/global.css +1739 -0
  108. package/src/styles/graph.css +317 -0
  109. package/src/styles/math.css +376 -0
  110. package/src/styles/tokens.css +171 -0
  111. 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
+ }