@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,59 @@
1
+ ---
2
+ /**
3
+ * Where every link to a note this build did not publish lands.
4
+ *
5
+ * **One page for every withheld target, and it names none of them.** A link to
6
+ * an excluded note used to render as plain text; owner decision 2026-08-17 made
7
+ * it live, so it needs a destination that exists and tells the truth. This is
8
+ * that destination — the same static document whichever link led here, which is
9
+ * what makes it safe: it has no subject, so it discloses nothing beyond what
10
+ * the link the reader already read told them.
11
+ *
12
+ * The disclosure that *does* now happen is in the linking page, not in this one:
13
+ * a wikilink's own text is its target path, and that text is what ships. See
14
+ * `scripts/resolve-links.ts`'s `unpublished` branch, where the decision and its
15
+ * cost are recorded.
16
+ *
17
+ * **It says the note will not appear later**, and that sentence is the reason
18
+ * this is a page rather than a redirect to `/404/`. A 404 tells a reader the
19
+ * address is wrong, which is false — the author wrote that link deliberately and
20
+ * the build resolved it to a real file. Saying "not published, and not pending"
21
+ * is the honest version, and it is the one a reader can act on by not coming
22
+ * back.
23
+ *
24
+ * `noindex`, via `isIndexable`: a crawler that indexed this would list one page
25
+ * under the text of every withheld link on the site, which is the disclosure
26
+ * concentrated rather than dispersed. Nothing links here from the chrome — the
27
+ * only inbound links are the ones a note body wrote — so it is reachable exactly
28
+ * as often as an author chose.
29
+ *
30
+ * The route segment is reserved in `src/lib/schema.ts`; without that a user's
31
+ * own `private.md` would capture every withheld link on their site.
32
+ */
33
+ import Layout from '../layouts/Layout.astro';
34
+ import { SITE_MAP } from '../lib/routes.js';
35
+ import { TITLE_SUFFIX } from '../lib/site.js';
36
+ import { NAV_LANGUAGE, translate } from '../lib/translations.js';
37
+
38
+ // The navigation language, for the same reason `404.astro` uses it: this page
39
+ // renders no document, so there is no document language to read. A withheld
40
+ // note has a language and this page may not consult it — reading one would make
41
+ // the chrome here a fact about the file that was not published.
42
+ const t = translate(NAV_LANGUAGE);
43
+ ---
44
+
45
+ <Layout title={`${t.withheldTitle}${TITLE_SUFFIX}`} description={t.withheldDescription}>
46
+ <article class="prose">
47
+ <h1>{t.withheldTitle}</h1>
48
+
49
+ <p>{t.withheldBody}</p>
50
+
51
+ <p>{t.withheldPermanence}</p>
52
+
53
+ <h2>{t.notFoundWhereTo}</h2>
54
+
55
+ <ul>
56
+ {SITE_MAP.map((item) => <li><a href={item.href}>{t[item.label]}</a></li>)}
57
+ </ul>
58
+ </article>
59
+ </Layout>
@@ -0,0 +1,33 @@
1
+ ---
2
+ /**
3
+ * `/recent/` — every published note, most recently updated first.
4
+ *
5
+ * A corpus route rather than a document route, so its chrome is the navigation
6
+ * language. The note titles it lists are their authors' own words and carry
7
+ * their own `lang` where it differs; `NoteList` handles that.
8
+ */
9
+ import Layout from '../layouts/Layout.astro';
10
+ import NoteList from '../components/NoteList.astro';
11
+ import { entries } from '../lib/content.js';
12
+ import { noteTimestamp, recentFirst } from '../lib/routes.js';
13
+ import { TITLE_SUFFIX } from '../lib/site.js';
14
+ import { NAV_LANGUAGE, translate } from '../lib/translations.js';
15
+
16
+ const t = translate(NAV_LANGUAGE);
17
+ const ordered = recentFirst(entries);
18
+ // The artifact's date fields are optional. Saying which ordering a reader is
19
+ // actually looking at is the honest alternative to implying a recency the
20
+ // current artifact cannot support.
21
+ const dated = entries.some((entry) => noteTimestamp(entry) !== undefined);
22
+ ---
23
+
24
+ <Layout title={`${t.recentTitle}${TITLE_SUFFIX}`} description={t.recentDescription}>
25
+ <section aria-labelledby="recent-title">
26
+ <div class="section-heading">
27
+ <h1 id="recent-title">{t.recentTitle}</h1>
28
+ <span>{t.publishedCount(ordered.length)}</span>
29
+ </div>
30
+ <p class="facet-note">{dated ? t.recentOrderedByDate : t.recentOrderedBySlug}</p>
31
+ <NoteList entries={ordered} level={2} lang={NAV_LANGUAGE} />
32
+ </section>
33
+ </Layout>
@@ -0,0 +1,15 @@
1
+ /**
2
+ * `/robots.txt` — the indexing policy, and where the sitemap is.
3
+ *
4
+ * Generated rather than the committed `public/robots.txt` it replaces. The
5
+ * `Sitemap:` directive takes an absolute URL, so a static file would be a second
6
+ * place this site's origin is written down — and the origin is a placeholder
7
+ * until a domain is assigned, which makes "written once" the difference between
8
+ * a one-line change and a search-and-replace across the tree.
9
+ *
10
+ * The policy itself is in `renderRobots`.
11
+ */
12
+ import { renderRobots } from "../lib/site.js";
13
+ export const GET = ({ site }) => new Response(renderRobots(site), {
14
+ headers: { 'Content-Type': 'text/plain; charset=utf-8' },
15
+ });
@@ -0,0 +1,21 @@
1
+ /**
2
+ * `/rss.xml` — the Atom feed of every published note.
3
+ *
4
+ * An Astro endpoint rather than a build script, so the feed is part of the route
5
+ * model and `Astro.site` reaches it the same way it reaches every page. The
6
+ * `.xml.ts` name is what makes the emitted file `rss.xml`.
7
+ *
8
+ * `rss.xml` rather than `atom.xml` although the document is Atom 1.0: `rss` is
9
+ * the reserved route segment TK-01 already set aside, `/rss.xml` is the path
10
+ * readers and feed autodiscovery tools try first, and the `type` attribute on
11
+ * the discovery link states the actual format. The filename is an address, not
12
+ * a format declaration.
13
+ *
14
+ * The whole document, and the reasoning behind Atom over RSS 2.0, is in
15
+ * `src/lib/site.ts`.
16
+ */
17
+ import { entries } from "../lib/content.js";
18
+ import { renderFeed } from "../lib/site.js";
19
+ export const GET = ({ site }) => new Response(renderFeed(site, entries), {
20
+ headers: { 'Content-Type': 'application/atom+xml; charset=utf-8' },
21
+ });
@@ -0,0 +1,20 @@
1
+ /**
2
+ * `/sitemap.xml` — every public route, for crawlers.
3
+ *
4
+ * Hand-emitted rather than `@astrojs/sitemap`, and that is a correctness
5
+ * decision rather than a dependency-count one. The integration enumerates what
6
+ * Astro emitted, which would include `/404.html` and this file's siblings and
7
+ * would need a filter to exclude them; and it has no access to the artifact, so
8
+ * `<lastmod>` would either be absent or be the build clock — the second of which
9
+ * breaks the determinism this ticket is measured on. Reading the artifact
10
+ * directly gives the right URL set and a real modification date, in the same
11
+ * thirty lines.
12
+ *
13
+ * `tests/metadata.test.ts` proves the route set here is exactly the set the
14
+ * build wrote to `dist/`, so this cannot drift from what is actually served.
15
+ */
16
+ import { entries, tagFacets } from "../lib/content.js";
17
+ import { publicRoutes, renderSitemap } from "../lib/site.js";
18
+ export const GET = ({ site }) => new Response(renderSitemap(site, publicRoutes(entries, tagFacets())), {
19
+ headers: { 'Content-Type': 'application/xml; charset=utf-8' },
20
+ });
@@ -0,0 +1,56 @@
1
+ ---
2
+ /**
3
+ * `/tags/<key>/` — the notes carrying one tag.
4
+ *
5
+ * A facet aggregates many documents, so its chrome is the navigation language
6
+ * rather than any member's. Resolving it from, say, the first note would make
7
+ * this page's language depend on which entry sorts first — a public page
8
+ * changing language because an unrelated note was published.
9
+ *
10
+ * The tag itself is interpolated into the heading and the title as artifact
11
+ * text, unchanged.
12
+ *
13
+ * The complete static `NoteList` is always rendered. The enhanced region below it
14
+ * is hidden until `tag-browser.ts` runs, so a reader with no scripting (or whose
15
+ * chunk failed) sees the whole tag through the static route. The region itself,
16
+ * with the browser's shared ids and status sentences, is `TagBrowser.astro`.
17
+ */
18
+ import Layout from '../../layouts/Layout.astro';
19
+ import NoteList from '../../components/NoteList.astro';
20
+ import TagBrowser from '../../components/TagBrowser.astro';
21
+ import { tagFacets } from '../../lib/content.js';
22
+ import { TITLE_SUFFIX } from '../../lib/site.js';
23
+ import { NAV_LANGUAGE, translate } from '../../lib/translations.js';
24
+
25
+ export function getStaticPaths() {
26
+ // No facets means no paths, which is the correct static answer: `/tags/` still
27
+ // renders its empty state, and any `/tags/<anything>/` request is a 404
28
+ // because that tag genuinely does not exist.
29
+ return tagFacets().map((facet) => ({ params: { tag: facet.key }, props: { facet } }));
30
+ }
31
+
32
+ const { facet } = Astro.props;
33
+ const t = translate(NAV_LANGUAGE);
34
+ ---
35
+
36
+ <Layout
37
+ title={`${t.tagPageTitle(facet.label)}${TITLE_SUFFIX}`}
38
+ description={t.tagPageDescription(facet.label)}
39
+ >
40
+ <section aria-labelledby="tag-title">
41
+ <div class="section-heading">
42
+ <h1 id="tag-title">{t.tagPageTitle(facet.label)}</h1>
43
+ <span>{t.noteCount(facet.entries.length)}</span>
44
+ </div>
45
+ <p class="facet-note"><a href="/tags/">{t.allTags}</a></p>
46
+
47
+ <div id="tag-static-list">
48
+ <NoteList entries={facet.entries} level={2} lang={NAV_LANGUAGE} />
49
+ </div>
50
+
51
+ {/* The complete static list above stays the no-JS route; the region below is
52
+ the enhanced browser, hidden until the script runs — which unhides the
53
+ static list on any failure. */}
54
+ <TagBrowser mode="fixed" tagKey={facet.key} />
55
+ </section>
56
+ </Layout>
@@ -0,0 +1,37 @@
1
+ ---
2
+ /**
3
+ * `/tags/` — every public tag in the projection.
4
+ *
5
+ * A corpus route, so the chrome is the navigation language. The tag labels
6
+ * themselves are artifact text and are never translated: `security` stays
7
+ * `security`, `笔记` stays `笔记`.
8
+ *
9
+ * `FacetIndex`'s anchors remain the complete no-JS route to each tag's page. The
10
+ * chooser below is an additive enhancement that enumerates a selected tag in
11
+ * place through the snapshot; switching tags resets the continuation. The chooser
12
+ * itself, with the browser's shared ids and status sentences, is
13
+ * `TagBrowser.astro`.
14
+ */
15
+ import Layout from '../../layouts/Layout.astro';
16
+ import FacetIndex from '../../components/FacetIndex.astro';
17
+ import TagBrowser from '../../components/TagBrowser.astro';
18
+ import { tagFacets } from '../../lib/content.js';
19
+ import { tagRoute } from '../../lib/routes.js';
20
+ import { TITLE_SUFFIX } from '../../lib/site.js';
21
+ import { NAV_LANGUAGE, translate } from '../../lib/translations.js';
22
+
23
+ const t = translate(NAV_LANGUAGE);
24
+ const facets = tagFacets();
25
+ ---
26
+
27
+ <Layout title={`${t.tagsTitle}${TITLE_SUFFIX}`} description={t.tagsDescription}>
28
+ <section aria-labelledby="tags-title">
29
+ <div class="section-heading">
30
+ <h1 id="tags-title">{t.tagsTitle}</h1>
31
+ <span>{t.tagCount(facets.length)}</span>
32
+ </div>
33
+ <FacetIndex facets={facets} route={tagRoute} empty={t.tagsEmpty} lang={NAV_LANGUAGE} />
34
+
35
+ {facets.length > 0 && <TagBrowser mode="switch" facets={facets} />}
36
+ </section>
37
+ </Layout>
@@ -0,0 +1,45 @@
1
+ /** Progressive code-fence copy controls, inserted only when JavaScript runs. */
2
+ export {};
3
+ const article = document.querySelector('.prose[data-code-copy]');
4
+ const idle = article?.dataset['codeCopy'];
5
+ const copied = article?.dataset['codeCopied'];
6
+ const failed = article?.dataset['codeCopyFailed'];
7
+ if (article && idle && copied && failed && typeof navigator.clipboard?.writeText === 'function') {
8
+ for (const code of article.querySelectorAll('.code-block > pre > code')) {
9
+ const figure = code.closest('.code-block');
10
+ if (!figure || figure.querySelector('.code-copy') !== null)
11
+ continue;
12
+ const button = document.createElement('button');
13
+ button.type = 'button';
14
+ button.className = 'code-copy';
15
+ button.textContent = idle;
16
+ const status = document.createElement('span');
17
+ status.className = 'sr-only';
18
+ status.setAttribute('role', 'status');
19
+ let resetTimer;
20
+ button.addEventListener('click', async () => {
21
+ if (resetTimer !== undefined)
22
+ window.clearTimeout(resetTimer);
23
+ button.disabled = true;
24
+ try {
25
+ await navigator.clipboard.writeText(code.textContent ?? '');
26
+ button.textContent = copied;
27
+ status.textContent = copied;
28
+ }
29
+ catch {
30
+ button.textContent = failed;
31
+ status.textContent = failed;
32
+ }
33
+ finally {
34
+ button.disabled = false;
35
+ resetTimer = window.setTimeout(() => {
36
+ button.textContent = idle;
37
+ status.textContent = '';
38
+ resetTimer = undefined;
39
+ }, 1600);
40
+ }
41
+ });
42
+ figure.prepend(status);
43
+ figure.prepend(button);
44
+ }
45
+ }
@@ -0,0 +1,16 @@
1
+ /**
2
+ * The diagram runtime, in the mode that does not have one.
3
+ *
4
+ * `astro.config.mjs` aliases `src/scripts/diagram.ts` to this file whenever
5
+ * `DIAGRAM_MODE` is `'build-time'`. The mode that ships is `'client'`
6
+ * (`src/lib/diagram-mode.ts`), so this stub is bundled only when that constant
7
+ * is flipped. The page's
8
+ * `<script>` is bundled eagerly by Astro regardless of any condition around it,
9
+ * so the only way to keep Mermaid's 119 chunks out of `dist/` is to make the
10
+ * module they hang from import nothing.
11
+ *
12
+ * An empty module rather than a deleted `<script>`: the tag still exists, so
13
+ * the two modes emit the same page shape, and the difference between them is
14
+ * exactly one aliased import instead of a divergence in the template.
15
+ */
16
+ export {};
@@ -0,0 +1,322 @@
1
+ "use strict";
2
+ /**
3
+ * Client-mode diagram rendering.
4
+ *
5
+ * Loaded only when `DIAGRAM_MODE` is `'client'` and only on pages that contain
6
+ * a diagram. In the shipped mode (`'build-time'`) this file is never imported,
7
+ * so none of it — and none of Mermaid — reaches any page. See
8
+ * `src/lib/diagram-mode.ts` for what the two modes cost.
9
+ *
10
+ * Three properties this has to preserve, all of which the build-time path gets
11
+ * for free and this one has to work for:
12
+ *
13
+ * 1. **Nothing loads until it is needed.** Mermaid's floor is 201 KB gzip for
14
+ * the cheapest single diagram — 4× the §18 article budget — so the import is
15
+ * deferred behind an `IntersectionObserver`. A reader who never scrolls to
16
+ * the diagram never fetches the parser. That also places the cost outside
17
+ * "initial JS on the article route", which is the only reading under which
18
+ * client mode fits the stated budget at all.
19
+ * 2. **The source stays until the diagram replaces it.** The `<pre>` the
20
+ * renderer emitted is removed only after a successful render, so a failure
21
+ * at any point leaves the reader with the diagram's source rather than an
22
+ * empty figure.
23
+ * 3. **The accessible name survives.** The figure's caption already names the
24
+ * diagram, and the injected SVG is given the same name, so replacing the
25
+ * source does not silently drop it.
26
+ */
27
+ const DIAGRAM_SELECTOR = 'figure.diagram';
28
+ /**
29
+ * Attributes that may carry a URL. Mirrors `URL_ATTRIBUTES` in
30
+ * `src/lib/mermaid-render.ts`, which enforces the same rule at build time;
31
+ * the two must agree, or one mode admits a link the other rejects.
32
+ */
33
+ const URL_ATTRIBUTES = ['href', 'xlink:href', 'src'];
34
+ /** Mermaid is imported at most once per page, on first diagram in view. */
35
+ let runtime;
36
+ /**
37
+ * Mermaid's own configuration, applied on import and again on every theme
38
+ * change.
39
+ *
40
+ * `initialize` is what installs the palette, and it is *not* idempotent with
41
+ * respect to the theme: calling it again with new `themeVariables` is the only
42
+ * way to make a later `render()` draw different colours. Without the second
43
+ * call a re-render redraws the diagram in exactly the palette it already had —
44
+ * measured, 0 of 60 sampled fills changed.
45
+ */
46
+ function configure(mermaid) {
47
+ mermaid.initialize({
48
+ startOnLoad: false,
49
+ // **Deliberately not the build-time renderer's `'loose'`.** That level is
50
+ // forced there by happy-dom, under which DOMPurify returns an empty string
51
+ // (`src/lib/mermaid-render.ts`). A real browser has no such defect, so this
52
+ // path takes Mermaid's own label sanitizing, drops `click ... call`
53
+ // callbacks and `_blank` targets, and keeps the post-render pass below as
54
+ // a second boundary rather than the only one. Measured in Chromium: all
55
+ // four diagram types and the theme re-render still draw, and
56
+ // `tests/diagram-client.test.ts` gates a hostile label and `click`
57
+ // directive set against the rendered SVG.
58
+ securityLevel: 'strict',
59
+ deterministicIds: true,
60
+ handDrawnSeed: 42,
61
+ theme: 'base',
62
+ fontFamily: 'inherit',
63
+ themeVariables: currentThemeVariables(),
64
+ maxTextSize: 50000,
65
+ maxEdges: 500,
66
+ });
67
+ }
68
+ function loadMermaid() {
69
+ runtime ??= import('mermaid').then((module) => {
70
+ const mermaid = module.default;
71
+ configure(mermaid);
72
+ return mermaid;
73
+ });
74
+ return runtime;
75
+ }
76
+ /**
77
+ * Palette values for the theme in effect right now.
78
+ *
79
+ * Read from the live page rather than duplicated as literals, so the client
80
+ * path cannot drift from `src/styles/tokens.css` the way a second copy would.
81
+ *
82
+ * **Resolved through a probe element, not read off the custom property.**
83
+ * `getComputedStyle(root).getPropertyValue('--color-surface')` returns the
84
+ * *declared* value — `light-dark(#fff, #161b21)` — because a custom property is
85
+ * substituted, not computed. Mermaid runs every theme value through `khroma`,
86
+ * which parses colours and throws `Unsupported color format` on that string,
87
+ * and the whole render fails. Measured: it is what made every diagram in client
88
+ * mode stay as source.
89
+ *
90
+ * Assigning the property to a real `color` and reading *that* back makes the
91
+ * browser resolve `light-dark()` against the active `color-scheme`, which is
92
+ * exactly the value the diagram should use and follows the theme toggle.
93
+ */
94
+ function currentThemeVariables() {
95
+ const probe = document.createElement('span');
96
+ // Out of flow and invisible, but still styled: `display: none` would leave
97
+ // the computed colour unresolved in some engines.
98
+ probe.style.position = 'absolute';
99
+ probe.style.visibility = 'hidden';
100
+ document.body.append(probe);
101
+ const resolve = (name, fallback) => {
102
+ probe.style.color = '';
103
+ probe.style.color = `var(${name})`;
104
+ const value = getComputedStyle(probe).color;
105
+ return value === '' ? fallback : value;
106
+ };
107
+ const palette = {
108
+ background: resolve('--color-bg', '#fbfaf7'),
109
+ surface: resolve('--color-surface', '#ffffff'),
110
+ surfaceAlt: resolve('--color-surface-alt', '#f2f0ea'),
111
+ text: resolve('--color-text', '#1b1f24'),
112
+ line: resolve('--color-line-strong', '#767d87'),
113
+ };
114
+ probe.remove();
115
+ return {
116
+ background: palette.background,
117
+ primaryColor: palette.surfaceAlt,
118
+ primaryTextColor: palette.text,
119
+ primaryBorderColor: palette.line,
120
+ lineColor: palette.line,
121
+ secondaryColor: palette.surface,
122
+ tertiaryColor: palette.surface,
123
+ textColor: palette.text,
124
+ mainBkg: palette.surfaceAlt,
125
+ nodeBorder: palette.line,
126
+ nodeTextColor: palette.text,
127
+ };
128
+ }
129
+ async function renderInto(figure, index) {
130
+ const source = figure.querySelector('.diagram-source');
131
+ const text = source?.textContent;
132
+ if (source === null || text === undefined || text === null || text.trim() === '')
133
+ return;
134
+ try {
135
+ const mermaid = await loadMermaid();
136
+ const { svg } = await mermaid.render(`diagram-client-${index}`, text);
137
+ if (svg === '')
138
+ throw new Error('the renderer produced empty output');
139
+ // Parsed rather than assigned as `innerHTML`: requirements §19.2 asks for
140
+ // DOM APIs or trusted structured rendering rather than untrusted
141
+ // `innerHTML`, and the string here is renderer output over content that,
142
+ // although allowlisted, has not passed the build-time sanitizer on this
143
+ // path. `image/svg+xml` also parses `<style>` inside `<svg>` correctly,
144
+ // which the HTML parser does not.
145
+ //
146
+ // **`xmlns:xlink` is declared here because Mermaid does not declare it and
147
+ // still uses it.** XML has no implicit prefixes, so an `xlink:href` on an
148
+ // undeclared prefix is a well-formedness *error* rather than an unknown
149
+ // attribute — the parse yields a `parsererror` document and the reader is
150
+ // left looking at the fence source. Measured on Mermaid 11.16.1 in
151
+ // Chromium: a C4 diagram renders, and parsing its output gives
152
+ // `Namespace prefix xlink for href on image is not defined` at column
153
+ // 17664; seven other diagram types are unaffected because only C4 emits
154
+ // `<image xlink:href>` for its icons. Build-time mode never meets this —
155
+ // it parses as HTML, where an undeclared prefix is just a name.
156
+ //
157
+ // Declaring the standard binding is a smaller and safer repair than
158
+ // rewriting the attribute: the value is what Mermaid meant, and the URL
159
+ // allowlist below still governs what it may point at.
160
+ //
161
+ // Matched on the **root element's own tag**, not on the whole string. A
162
+ // `svg.includes('xmlns:xlink')` guard was the first version and is wrong in
163
+ // the direction that matters: measured through the real renderer, a diagram
164
+ // whose *label* contains the text `xmlns:xlink` puts it in a `<tspan>` or a
165
+ // `foreignObject`, so such a diagram would skip the declaration and fall
166
+ // back to source — the defect this exists to fix, reintroduced by its own
167
+ // guard.
168
+ const declared = /<svg\b[^>]*\bxmlns:xlink=/.test(svg)
169
+ ? svg
170
+ : svg.replace(/<svg\b/, '<svg xmlns:xlink="http://www.w3.org/1999/xlink"');
171
+ const parsed = new DOMParser().parseFromString(declared, 'image/svg+xml');
172
+ const root = parsed.documentElement;
173
+ if (root.querySelector('parsererror') !== null)
174
+ throw new Error('renderer output is not well-formed');
175
+ if (root.querySelector('script') !== null)
176
+ throw new Error('renderer emitted a script');
177
+ for (const element of [root, ...root.querySelectorAll('*')]) {
178
+ // `attributes` is live and `removeAttribute` mutates it, so the names are
179
+ // snapshotted first. Iterating it directly skips the entry after each
180
+ // removal — which would leave a handler on the page.
181
+ const names = Array.from(element.attributes, (attribute) => attribute.name);
182
+ for (const name of names) {
183
+ if (/^on/i.test(name))
184
+ element.removeAttribute(name);
185
+ }
186
+ // The same URL rule the build-time renderer enforces, for the same
187
+ // reason: Mermaid's `click` directive turns a node into an anchor
188
+ // pointing wherever the diagram source said, including a `javascript:`
189
+ // URL, and this SVG never passed through the build-time href allowlist.
190
+ // Build-time mode fails the build on one of these; here the diagram is
191
+ // already in front of a reader, so the link is dropped and the rest of
192
+ // the diagram still renders.
193
+ for (const attribute of URL_ATTRIBUTES) {
194
+ const value = element.getAttribute(attribute);
195
+ if (value === null)
196
+ continue;
197
+ const isLocal = value.startsWith('#');
198
+ const isInlineRaster = /^data:image\/(?:png|jpe?g|gif|webp|avif);base64,[A-Za-z0-9+/=]*$/i.test(value);
199
+ if (!isLocal && !isInlineRaster)
200
+ element.removeAttribute(attribute);
201
+ }
202
+ }
203
+ const caption = figure.querySelector('figcaption')?.textContent?.trim();
204
+ if (caption !== undefined && caption !== '')
205
+ root.setAttribute('aria-label', caption);
206
+ root.setAttribute('focusable', 'false');
207
+ const canvas = document.createElement('div');
208
+ canvas.className = 'diagram-canvas';
209
+ // The container scrolls horizontally when the diagram is wider than the
210
+ // measure, and a scrollable region must be keyboard reachable — axe
211
+ // `scrollable-region-focusable`, WCAG 2.1.1. Build-time mode sets the same
212
+ // attribute in the markup; the two modes must not differ on this.
213
+ canvas.tabIndex = 0;
214
+ canvas.append(document.importNode(root, true));
215
+ // Replace only now that a diagram exists: every failure above leaves the
216
+ // source in place, which is a readable page rather than a blank figure.
217
+ source.replaceWith(canvas);
218
+ }
219
+ catch {
220
+ // The source is still on the page and is the honest fallback. Nothing is
221
+ // logged: a console error on a published page is a gate failure in
222
+ // `tests/rendered-page.test.ts`, and a reader can act on neither.
223
+ }
224
+ }
225
+ const figures = [...document.querySelectorAll(DIAGRAM_SELECTOR)];
226
+ if (figures.length > 0) {
227
+ /**
228
+ * Each figure's source, captured before anything replaces it.
229
+ *
230
+ * A re-render needs the text back, and by then the `<pre>` that held it is
231
+ * gone — `renderInto` replaces it on success, which is the property that
232
+ * leaves a readable page when a render fails.
233
+ */
234
+ const sources = new WeakMap();
235
+ for (const figure of figures) {
236
+ const text = figure.querySelector('.diagram-source')?.textContent;
237
+ if (text !== null && text !== undefined && text.trim() !== '')
238
+ sources.set(figure, text);
239
+ }
240
+ const observer = new IntersectionObserver((entries) => {
241
+ for (const entry of entries) {
242
+ if (!entry.isIntersecting)
243
+ continue;
244
+ observer.unobserve(entry.target);
245
+ void renderInto(entry.target, figures.indexOf(entry.target));
246
+ }
247
+ },
248
+ // Start fetching a screen ahead, so a diagram is usually ready by the time
249
+ // it is scrolled to rather than appearing after it.
250
+ { rootMargin: '200px' });
251
+ for (const figure of figures)
252
+ observer.observe(figure);
253
+ /**
254
+ * Re-render every drawn diagram when the theme changes.
255
+ *
256
+ * Mermaid bakes the palette into the SVG at render time, so a diagram drawn
257
+ * in light mode stays light for ever. Build-time mode has no such problem —
258
+ * it renders both palettes and merges them into `light-dark()` values the
259
+ * browser re-resolves — which is why this regression is invisible until
260
+ * somebody runs the client path. Measured before this existed: toggling to
261
+ * dark changed 0 of 60 sampled fills and strokes.
262
+ *
263
+ * **A `MutationObserver` on `data-theme` rather than a custom event**, because
264
+ * `preferences.ts` already mutates that attribute and nothing else needs to
265
+ * know this file exists. `matchMedia` alone would miss the explicit toggle,
266
+ * which is the case a reader actually operates.
267
+ *
268
+ * **Passes are serialized rather than raced, and that is a correctness fix
269
+ * rather than tidiness.** An earlier version started a pass per change and
270
+ * guarded with a generation counter checked *after* `renderInto` — which is
271
+ * after the paint, so a stale pass had already committed its DOM before
272
+ * discovering it was stale. Worse, the newer pass skipped any figure without
273
+ * a `.diagram-canvas`, which is exactly the state an in-flight figure is in,
274
+ * so it stepped over the one figure the stale pass was about to paint in the
275
+ * old palette. The result was a deterministic mixed-palette page: every
276
+ * figure new except the one that had been rendering.
277
+ *
278
+ * Chaining removes the interleaving instead of detecting it. `pending` is the
279
+ * tail of the chain; a change appends to it, so a pass never observes a DOM
280
+ * another pass is midway through mutating. `wanted` is read at the start of
281
+ * each pass rather than captured per change, so a burst of toggles collapses
282
+ * into one redraw at the theme that is actually in effect when its turn
283
+ * comes — measured, a fast triple toggle settles byte-identical to a single
284
+ * clean toggle to the same theme.
285
+ *
286
+ * **Every figure with a captured source is redrawn**, including one still
287
+ * showing that source. A figure mid-initial-render is the case the skip used
288
+ * to miss: its `IntersectionObserver` pass may have called `render()` before
289
+ * the new palette was installed, and nothing would revisit it.
290
+ */
291
+ let pending = Promise.resolve();
292
+ const redraw = () => {
293
+ pending = pending.then(async () => {
294
+ // Re-configure before rendering: `initialize` is what installs the
295
+ // palette, so without this every re-render redraws the old colours.
296
+ configure(await loadMermaid());
297
+ for (const [index, figure] of figures.entries()) {
298
+ const text = sources.get(figure);
299
+ if (text === undefined)
300
+ continue;
301
+ const canvas = figure.querySelector('.diagram-canvas');
302
+ if (canvas !== null) {
303
+ const source = document.createElement('pre');
304
+ source.className = 'diagram-source';
305
+ source.textContent = text;
306
+ // Put the source back so `renderInto` has its input, then let it do
307
+ // the replacing exactly as on first draw — one code path draws a
308
+ // diagram, and its failure path leaves the source in place.
309
+ canvas.replaceWith(source);
310
+ }
311
+ await renderInto(figure, index);
312
+ }
313
+ });
314
+ };
315
+ new MutationObserver(redraw).observe(document.documentElement, {
316
+ attributes: true,
317
+ attributeFilter: ['data-theme'],
318
+ });
319
+ // The system-preference case, for a reader on "system" who changes it at the
320
+ // OS level: no attribute mutates, so the observer above never fires.
321
+ window.matchMedia('(prefers-color-scheme: dark)').addEventListener('change', redraw);
322
+ }