@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,69 @@
1
+ ---
2
+ /**
3
+ * The article's headings, as a real nested list.
4
+ *
5
+ * Three properties are load-bearing, and each is a decision the parity plan's
6
+ * L4 records against Quartz:
7
+ *
8
+ * 1. **Nested `<ol>`, not a flat list with a `depth-N` class.** The nesting is
9
+ * the structure, so a screen reader announces "list, 3 items, item 2, list,
10
+ * 2 items" rather than reading eleven siblings and leaving the reader to
11
+ * infer the hierarchy from indentation they cannot see.
12
+ * 2. **Complete in the server-rendered HTML.** Requirements section 5.3 makes
13
+ * reading and navigation work without JavaScript. A small progressive script
14
+ * may mark the current section, but it never creates or removes a TOC link.
15
+ * 3. **Collapse costs zero JavaScript.** `<details>`/`<summary>` is the native
16
+ * disclosure widget: keyboard-operable, announced correctly, and `open` by
17
+ * default so a reader who never interacts sees the whole list.
18
+ *
19
+ * Recursion is `Astro.self`, which renders at build time — the nesting is real
20
+ * markup by the time the page is written, not a component boundary a browser
21
+ * has to resolve.
22
+ *
23
+ * Quartz's own table of contents is the component worth studying, but its
24
+ * contrast is not: its unread links measure 2.04:1 in light and 2.92:1 in dark,
25
+ * both WCAG AA failures in the default state. Ours uses `--color-link`, which
26
+ * `tests/design-tokens.test.ts` proves at 6.72:1 and 10.37:1.
27
+ */
28
+ import type { TocEntry } from '../lib/markdown.js';
29
+ import { translate } from '../lib/translations.js';
30
+
31
+ interface Props {
32
+ entries: readonly TocEntry[];
33
+ /**
34
+ * Set on the outermost list only. Nested lists are rendered by `Astro.self`
35
+ * with it absent, which is what keeps the `<details>` shell and the labelled
36
+ * `<nav>` from being emitted once per level.
37
+ */
38
+ isRoot?: boolean;
39
+ /**
40
+ * The note's own language. Only the root renders a chrome string, so only the
41
+ * root needs it — the nested calls carry heading text, which is the article's
42
+ * own words in the article's own language either way.
43
+ */
44
+ lang?: string;
45
+ }
46
+
47
+ const { entries, isRoot = false, lang } = Astro.props;
48
+ ---
49
+
50
+ {isRoot ? (
51
+ <nav class="toc" aria-labelledby="toc-title">
52
+ {/* `open` so the list is visible by default and the disclosure is an
53
+ affordance to *close* it. A reader who never touches it loses nothing,
54
+ and with JavaScript disabled the element still opens and closes. */}
55
+ <details class="toc-details" open>
56
+ <summary id="toc-title">{translate(lang).tocHeading}</summary>
57
+ <Astro.self entries={entries} />
58
+ </details>
59
+ </nav>
60
+ ) : (
61
+ <ol class="toc-list">
62
+ {entries.map((entry) => (
63
+ <li>
64
+ <a href={`#${entry.id}`}>{entry.text}</a>
65
+ {entry.children.length > 0 && <Astro.self entries={entry.children} />}
66
+ </li>
67
+ ))}
68
+ </ol>
69
+ )}
@@ -0,0 +1,70 @@
1
+ ---
2
+ /**
3
+ * The enhanced tag browser, shared by `/tags/` and `/tags/<tag>/`.
4
+ *
5
+ * Both routes render the region's ids and the five `data-tag-browse-*`
6
+ * sentences that `src/scripts/tag-browser.ts` queries. When the shape lived on
7
+ * both pages a one-sided rename was silent: the client's `querySelector`
8
+ * returned null and the enhancement degraded without an error. The component
9
+ * owns that shape once; the script and this markup now move together or not at
10
+ * all.
11
+ *
12
+ * `mode` is the one thing that differs. `switch` renders the tag chooser;
13
+ * `fixed` renders the start button for the page's own tag. The complete static
14
+ * `FacetIndex`/`NoteList` beside it remains the no-JS route, so this region
15
+ * stays hidden until the script runs and unhides the static list on failure;
16
+ * `data-pagefind-ignore` keeps the region out of the Pagefind index.
17
+ */
18
+ import { TAG_PAGE_SIZE } from '../lib/tag-browser-model.js';
19
+ import { NAV_LANGUAGE, TAG_BROWSE_DATASET, datasetAttribute, translate } from '../lib/translations.js';
20
+
21
+ interface Props {
22
+ mode: 'switch' | 'fixed';
23
+ /** The fixed page's tag key, sent to the snapshot exactly as the route spells it. */
24
+ tagKey?: string;
25
+ /** The chooser's options; ignored when `mode` is `fixed`. */
26
+ facets?: readonly { key: string; label: string }[];
27
+ }
28
+
29
+ const { mode, tagKey, facets = [] } = Astro.props;
30
+ const t = translate(NAV_LANGUAGE);
31
+ // The five status sentences travel as data attributes so no locale table ships
32
+ // to the browser (`tag-browser.ts` reads them from `root.dataset`).
33
+ const browserData = Object.fromEntries([
34
+ [datasetAttribute(TAG_BROWSE_DATASET.loading), t.tagBrowseLoading],
35
+ [datasetAttribute(TAG_BROWSE_DATASET.empty), t.tagBrowseEmpty],
36
+ [datasetAttribute(TAG_BROWSE_DATASET.exhausted), t.tagBrowseExhausted],
37
+ [datasetAttribute(TAG_BROWSE_DATASET.unknown), t.tagBrowseUnknown],
38
+ [datasetAttribute(TAG_BROWSE_DATASET.failed), t.tagBrowseFailed],
39
+ ]);
40
+ ---
41
+
42
+ <section
43
+ id="tag-browser"
44
+ hidden
45
+ data-pagefind-ignore
46
+ data-mode={mode}
47
+ {...mode === 'fixed' ? { 'data-tag-key': tagKey } : {}}
48
+ data-page-size={String(TAG_PAGE_SIZE)}
49
+ aria-labelledby="tag-browser-title"
50
+ {...browserData}
51
+ >
52
+ <h2 id="tag-browser-title">{t.tagBrowseHeading}</h2>
53
+ {mode === 'switch' ? (
54
+ <>
55
+ <label for="tag-browser-select">{t.tagBrowseSelectLabel}</label>
56
+ <select id="tag-browser-select">
57
+ <option value="">{t.tagBrowsePrompt}</option>
58
+ {facets.map((facet) => (
59
+ <option value={facet.key}>{facet.label}</option>
60
+ ))}
61
+ </select>
62
+ </>
63
+ ) : (
64
+ <button id="tag-browse-start" type="button">{t.tagBrowseStart}</button>
65
+ )}
66
+ <p id="tag-browser-current" hidden></p>
67
+ <p id="tag-browser-status" role="status"></p>
68
+ <ol id="tag-browser-results"></ol>
69
+ <button id="tag-browse-more" type="button" hidden>{t.tagBrowseMore}</button>
70
+ </section>
@@ -0,0 +1,305 @@
1
+ ---
2
+ import '../styles/global.css';
3
+ // `?url` keeps this a classic, render-blocking script: Astro compiles a normal
4
+ // `<script>` to `type="module"`, which defers and would paint one frame of the
5
+ // wrong theme. Importing the URL keeps the source under `src/scripts/` and
6
+ // content-hashed, while the tag stays inline-free and same-origin.
7
+ import themeInit from '../scripts/theme-init.js?url';
8
+ // The deployed policy's one source. `?raw` inlines the file's text at build
9
+ // time, so the `<meta>` below is derived from the same bytes `_headers` ships
10
+ // rather than restated, and cannot drift from it.
11
+ import shippedHeaders from '../../public/_headers?raw';
12
+ import SiteMetadata from '../components/SiteMetadata.astro';
13
+ import CollectionExplorer from '../components/CollectionExplorer.astro';
14
+ import { entries } from '../lib/content.js';
15
+ import { collectionNavigation } from '../lib/collection-navigation.js';
16
+ import { PRIMARY_NAV, SITE_MAP } from '../lib/routes.js';
17
+ import { canonicalUrl, describe, SITE_NAME } from '../lib/site.js';
18
+ import {
19
+ MESSAGE_DATASET,
20
+ NAV_LANGUAGE,
21
+ THEME_DATASET,
22
+ THEME_NAMES,
23
+ datasetAttribute,
24
+ translate,
25
+ } from '../lib/translations.js';
26
+
27
+ interface Props {
28
+ title: string;
29
+ description?: string;
30
+ /** BCP 47 tag from the document's own metadata, when the artifact carries one. */
31
+ lang?: string;
32
+ }
33
+
34
+ const { title, lang = NAV_LANGUAGE } = Astro.props;
35
+
36
+ /**
37
+ * This document's chrome, resolved from this document's own language.
38
+ *
39
+ * The whole of TK-16 is that this line reads `lang` — a per-entry field on the
40
+ * validated contract — rather than one site-wide locale. A zh-CN note therefore
41
+ * renders Chinese chrome inside `<html lang="zh-CN">` while an English note in
42
+ * the same build renders English chrome, which is a shape Quartz's global
43
+ * `cfg.locale` cannot express. Routes that are not a single document pass no
44
+ * `lang` and land on `NAV_LANGUAGE`; `translations.ts` documents why.
45
+ */
46
+ const t = translate(lang);
47
+
48
+ /**
49
+ * `describe` rather than a default parameter, and that is a fix rather than a
50
+ * refactor: a default only fires on `undefined`, while the note page passes
51
+ * `entry.excerpt` — the one required string the content contract admits *empty*.
52
+ * A note with an empty excerpt therefore shipped
53
+ * `<meta name="description" content="">`, which is worse than no tag at all,
54
+ * since a consumer scrapes the page when the tag is absent and renders nothing
55
+ * when it is present and empty. The fixture corpus has exactly one such note.
56
+ *
57
+ * The fallback is this document's own, so a Chinese note with an empty excerpt
58
+ * describes itself in Chinese rather than falling back across a language.
59
+ */
60
+ const description = describe(Astro.props.description, t.siteDescription);
61
+
62
+ /**
63
+ * The explorer rail's groups, resolved against the route being rendered.
64
+ *
65
+ * Computed in the layout rather than passed in by each page, so every route
66
+ * carries the same navigation frame by construction — a page that forgets to
67
+ * ask is the failure mode this prevents, and it is the same reason
68
+ * `SiteMetadata` lives here. `Astro.url.pathname` is what makes the rail
69
+ * contextual: it opens the group holding the current page and no other, and it
70
+ * is what centres each group's window on the note being read.
71
+ *
72
+ * **This line is why the rail's size is a scaling property rather than a page's
73
+ * own concern.** It renders on every route, so whatever it costs is paid once
74
+ * per published page — the rail listing the whole corpus made a site O(n²) in
75
+ * bytes. `GROUP_WINDOW` in `collection-navigation.ts` is the bound that fixed
76
+ * it and carries the measurements.
77
+ */
78
+ const explorerGroups = collectionNavigation(entries, Astro.url.pathname, t.uncollected);
79
+
80
+ /**
81
+ * The site-wide Content-Security-Policy, as a `<meta http-equiv>`.
82
+ *
83
+ * `public/_headers` is Cloudflare Pages' format, so a host that ignores it —
84
+ * GitHub Pages, the documented zero-secret path, or any plain static server —
85
+ * served no CSP at all. The meta element carries the same policy to every host.
86
+ *
87
+ * Read from the `/*` rule of `_headers` rather than restated, minus the
88
+ * directives a meta-delivered policy cannot carry and the browser ignores with a
89
+ * console warning (CSP3 §3.3): `frame-ancestors`, `report-uri`, `report-to`, and
90
+ * `sandbox`. Clickjacking protection therefore still depends on a host that
91
+ * sends the header. Where `_headers` is honoured both policies apply; they are
92
+ * the same policy, so the intersection is unchanged.
93
+ *
94
+ * Omitted under `astro dev` only, whose client injects inline scripts the
95
+ * shipped policy forbids. Keyed on Vite's `MODE`, not `PROD`: `PROD` follows
96
+ * `NODE_ENV`, so a build started with `NODE_ENV=test` — every build a test
97
+ * harness spawns — would silently ship no policy. `tests/csp-meta.test.ts`
98
+ * asserts the emitted value against `_headers` and its enforcement in Chromium.
99
+ */
100
+ const META_UNSUPPORTED = new Set(['frame-ancestors', 'report-uri', 'report-to', 'sandbox']);
101
+ const metaPolicy = (() => {
102
+ let inSiteRule = false;
103
+ for (const line of shippedHeaders.split(/\r?\n/)) {
104
+ if (line.trim() === '' || line.trimStart().startsWith('#')) continue;
105
+ if (!/^\s/.test(line)) {
106
+ inSiteRule = line.trim() === '/*';
107
+ continue;
108
+ }
109
+ const match = inSiteRule ? /^\s*Content-Security-Policy:\s*(.+)$/i.exec(line) : null;
110
+ if (match === null) continue;
111
+ return match[1]!
112
+ .split(';')
113
+ .map((directive) => directive.trim())
114
+ .filter((directive) => directive !== '' && !META_UNSUPPORTED.has(directive.split(/\s+/)[0]!.toLowerCase()))
115
+ .join('; ');
116
+ }
117
+ throw new Error('public/_headers declares no site-wide Content-Security-Policy for the <meta> policy');
118
+ })();
119
+ ---
120
+
121
+ <!doctype html>
122
+ <html lang={lang}>
123
+ <head>
124
+ <meta charset="UTF-8" />
125
+ {/* First after the charset: a meta policy governs only what follows it. */}
126
+ {import.meta.env.MODE !== 'development' && <meta http-equiv="Content-Security-Policy" content={metaPolicy} />}
127
+ <meta name="viewport" content="width=device-width, initial-scale=1" />
128
+ <meta name="description" content={description} />
129
+ <meta name="generator" content={Astro.generator} />
130
+ <title>{title}</title>
131
+ <script is:inline src={themeInit}></script>
132
+ {/* TK-08: the canonical URL, the Open Graph and Twitter card metadata, and
133
+ the feed discovery link. Rendered here rather than by each page so that
134
+ every route carries them by construction — a page that forgets to ask
135
+ is the failure mode this prevents. It derives everything from
136
+ `Astro.site`, `Astro.url`, and the artifact; it takes no new props
137
+ beyond the three this layout already has. */}
138
+ <SiteMetadata title={title} description={description} lang={lang} />
139
+ {/* The one seam a page has into the document head.
140
+ TK-15 fills it with the math stylesheet and font, gated on
141
+ `RenderedNote.hasMath`, and the Mermaid assets gated on `.hasMermaid`.
142
+ `src/pages/notes/[slug].astro` already uses it for the `hasCode` case,
143
+ so the mechanism is exercised rather than speculative.
144
+
145
+ A named slot rather than a widening `Props` interface: a per-ticket prop
146
+ would make this file the contended one every time a ticket needs a tag
147
+ in the head, which is the collision the wave plan exists to avoid. */}
148
+ <slot name="head" />
149
+ </head>
150
+ <body>
151
+ <a class="skip-link" href="#main">{t.skipToContent}</a>
152
+ <header class="site-header">
153
+ {/* The site's name is a proper noun, so it is the one word in this header
154
+ that is not translated — see `SITE_NAME`. */}
155
+ <a class="brand" href="/">{SITE_NAME}</a>
156
+ <nav class="site-nav" aria-label={t.primaryNavLabel}>
157
+ {PRIMARY_NAV.map((item) => <a href={item.href}>{t[item.label]}</a>)}
158
+ {/* The shortcut is in the accessible name rather than in a `title`,
159
+ which no screen reader reliably announces and no keyboard reader
160
+ can discover. `aria-keyshortcuts` is the attribute that exists for
161
+ exactly this, and the visible text stays one word. */}
162
+ <button
163
+ id="search-toggle"
164
+ type="button"
165
+ aria-keyshortcuts="/"
166
+ aria-label={t.searchToggleLabel}
167
+ data-js-only
168
+ >{t.searchToggle}</button>
169
+ {/* The three theme labels travel as `data-` attributes rather than as a
170
+ locale table in the bundle: `preferences.ts` cycles the attribute and
171
+ reads back the label the build already resolved, so the script ships
172
+ no strings, knows no locale, and adds no bytes that scale with the
173
+ number of languages. The rendered text is the `system` label, which
174
+ is the state before any preference is stored. */}
175
+ <button
176
+ id="theme-toggle"
177
+ type="button"
178
+ data-js-only
179
+ {...Object.fromEntries(
180
+ THEME_NAMES.map((theme) => [datasetAttribute(THEME_DATASET[theme]), t.themeLabel(theme)]),
181
+ )}
182
+ >{t.themeLabel(THEME_NAMES[0])}</button>
183
+ <button id="reader-toggle" type="button" aria-pressed="false" data-js-only>{t.readerToggle}</button>
184
+ </nav>
185
+ </header>
186
+ {/* TK-06. Every region the search script fills is declared here in HTML
187
+ rather than constructed in JavaScript, for three reasons: the `<label>`
188
+ and the `<input>` stay a real labelled pair instead of Pagefind's
189
+ `data-`-attribute substitute; the dialog has something to focus the
190
+ instant it opens, before the bundle has loaded; and the status line is a
191
+ live region that exists before the first message is written into it, so
192
+ the message is announced rather than silently appearing.
193
+
194
+ `data-pagefind-ignore` on the whole dialog: it is on every page, so
195
+ without it the index would carry this chrome on all 32 notes — the same
196
+ defect TK-12 fixed for the back link and the heading anchors.
197
+
198
+ The close button is a `<form method="dialog">`, which needs no script
199
+ and — per the parity plan §7.6 — never navigates, so `form-action 'none'`
200
+ does not touch it. */}
201
+ <dialog id="search-dialog" aria-label={t.searchDialogLabel} data-pagefind-ignore>
202
+ <form method="dialog" class="search-close"><button>{t.searchClose}</button></form>
203
+ <label class="search-label" for="search-input">{t.searchFieldLabel}</label>
204
+ {/* `type="search"` for the browser's own clear control and the right
205
+ on-screen keyboard. `autocomplete="off"` because a site search field
206
+ offering the reader's previous queries from other sites is noise. */}
207
+ <input id="search-input" type="search" autocomplete="off" autocapitalize="none" enterkeyhint="search" />
208
+ <div id="search-filters">
209
+ <div id="search-filter-tag"></div>
210
+ <div id="search-filter-collection"></div>
211
+ </div>
212
+ {/* `role="status"` is an `aria-live="polite"` region: the reader hears
213
+ "Loading the search index" or "No results" without focus moving off
214
+ the field they are typing into.
215
+
216
+ The four load-state sentences travel as `data-` attributes for the same
217
+ reason the theme labels do: `search-dialog.ts` reads the one it needs
218
+ off this element, so the bundle carries no locale table and the page
219
+ announces its own language. There is no rendered text — the region is
220
+ empty until a state is announced, which is what makes the first message
221
+ an announcement rather than a change nobody hears. */}
222
+ <p id="search-status" role="status" {...Object.fromEntries([
223
+ [datasetAttribute(MESSAGE_DATASET.idle), t.searchIdle],
224
+ [datasetAttribute(MESSAGE_DATASET.loading), t.searchLoading],
225
+ [datasetAttribute(MESSAGE_DATASET.empty), t.searchEmpty],
226
+ [datasetAttribute(MESSAGE_DATASET.failed), t.searchFailed],
227
+ ])}></p>
228
+ <ol id="search-results"></ol>
229
+ </dialog>
230
+ {/* The reading frame: the article, and the collection rail beside it.
231
+ Requirements section 16 asks for an explorer, a centred article, and a
232
+ contextual rail on desktop; collapsible panels on tablet; one column on
233
+ mobile. This grid is all three — see `.page-shell` in `global.css`.
234
+
235
+ The rail follows `<main>` in document order at every width, and the
236
+ stylesheet never reorders it. That is what keeps the reading order, the
237
+ tab order, and the visual order the same thing: a reader tabbing forward
238
+ reaches the article before the site-wide navigation, and a reader with no
239
+ CSS gets the article first. Placing the explorer visually before the
240
+ article — Quartz's left-hand shape — would mean either a DOM order that
241
+ puts the site's whole navigation ahead of the page's own content, or a
242
+ `grid-column` that makes the tab order disagree with the layout. */}
243
+ <div class="page-shell">
244
+ <main id="main" tabindex="-1">
245
+ <slot />
246
+ {/* The link preview panel (TK-07). Empty until a preview opens, and
247
+ `position: fixed`, so it contributes no layout wherever it sits — but
248
+ it must sit inside a landmark. axe-core's `region` rule reports every
249
+ element of page content that is in none, and an open panel parented to
250
+ `<body>` was exactly that: it was reported on every route, in both
251
+ themes, at both widths, and moving it here is what cleared it.
252
+
253
+ That audit is run out of band and is not reproducible from this
254
+ repository — axe-core is in neither `dependencies` nor
255
+ `devDependencies`, so the check needs an `axe.min.js` fetched to a
256
+ scratch path. TK-09 owns the question of whether it becomes a release
257
+ gate. Recorded this way deliberately: the rule the panel was breaking
258
+ is nameable and checkable by anyone who runs axe, which is worth more
259
+ than a violation count nothing here can recompute.
260
+
261
+ After the slot, so the document order a screen reader walks puts a
262
+ transient description of a link after the content that contains the
263
+ link, rather than before the article it describes. */}
264
+ <div id="link-preview" role="tooltip" hidden></div>
265
+ </main>
266
+ {/* TK-05c. Built entirely at build time and collapsed with native
267
+ `<details>`, so it is complete and operable with scripting disabled —
268
+ the Quartz explorer failure the parity plan's L5 names. It renders
269
+ nothing at all on a corpus with fewer than two notes. */}
270
+ <CollectionExplorer groups={explorerGroups} lang={lang} />
271
+ </div>
272
+ <footer class="site-footer">
273
+ {/* Every fixed route is linked from here, on every page, so no route is an
274
+ orphan. This nav deliberately survives reader mode, which hides the
275
+ header's secondary links: hiding the only path to a route would make
276
+ reader mode remove information rather than chrome. */}
277
+ <nav class="site-map" aria-label={t.siteMapLabel}>
278
+ {SITE_MAP.map((item) => <a href={item.href}>{t[item.label]}</a>)}
279
+ </nav>
280
+ <p>{t.siteSubtitle}</p>
281
+ {/* Scope item 10 requires the printed page to carry its canonical URL.
282
+ A printed page has no address bar, so the full absolute URL is what
283
+ makes it findable again — TK-05a emitted the route and left this for
284
+ TK-08 to upgrade once `site:` was configured. */}
285
+ <p class="print-only">{t.publishedAt(canonicalUrl(Astro.site, Astro.url.pathname))}</p>
286
+ </footer>
287
+ <script>
288
+ import '../scripts/preferences.js';
289
+ import '../scripts/search-dialog.js';
290
+ import '../scripts/link-preview.js';
291
+ if (document.querySelector('.prose[data-code-copy] .code-block')) {
292
+ void import('../scripts/code-copy.js');
293
+ }
294
+ if (document.querySelector('.toc')) {
295
+ void import('../scripts/toc-active.js');
296
+ }
297
+ if (document.querySelector('#tag-browser')) {
298
+ void import('../scripts/tag-browser.js');
299
+ }
300
+ if (document.querySelector('[data-graph-controls]')) {
301
+ void import('../scripts/graph-client.js');
302
+ }
303
+ </script>
304
+ </body>
305
+ </html>
@@ -0,0 +1,72 @@
1
+ /**
2
+ * Which artifact file this build reads.
3
+ *
4
+ * The default is the producer-generated `src/data/content.json`. Setting
5
+ * `CONTENT_ARTIFACT` to another path — `pnpm run build:fixture` does exactly
6
+ * that — builds the whole site from a different artifact without touching the
7
+ * generated one, which the agent contract forbids editing by hand.
8
+ *
9
+ * Every consumer resolves the path through here rather than hardcoding it, so
10
+ * the build gate, the redirect emitter, the pages, and the tests over `dist/`
11
+ * can never end up reading two different artifacts and agreeing about it.
12
+ *
13
+ * The path is resolved against `process.cwd()` rather than against
14
+ * `import.meta.url`, and that is load-bearing: Astro bundles this module into
15
+ * `dist/.prerender/`, so `import.meta.url` points inside the build output at
16
+ * the moment the pages actually read it. `pnpm` runs every script with the
17
+ * working directory set to the directory holding `package.json`, and Astro
18
+ * resolves its own project root the same way — so cwd is the one anchor all
19
+ * three consumers agree on.
20
+ *
21
+ * ponytail: this means the build must be started from the repository root. It
22
+ * already had to be — `astro build` finds `astro.config.mjs` the same way.
23
+ */
24
+ import { readFileSync } from 'node:fs';
25
+ import { resolve } from 'node:path';
26
+ import { validateArtifact } from "./schema.js";
27
+ const DEFAULT_ARTIFACT = 'src/data/content.json';
28
+ /**
29
+ * Repository-relative path of the artifact, suitable for an error message.
30
+ *
31
+ * `||` rather than `??`: an empty `CONTENT_ARTIFACT` is a caller who meant to
32
+ * pass a path and passed nothing. With `??` it would resolve to the working
33
+ * directory and fail on a directory read, without naming the empty variable.
34
+ */
35
+ export const ARTIFACT_PATH = process.env['CONTENT_ARTIFACT'] || DEFAULT_ARTIFACT;
36
+ /**
37
+ * Absolute filesystem path to the artifact, anchored at the repository root.
38
+ *
39
+ * `path.resolve` rather than a hand-built `file://` URL: a URL parses `#` and
40
+ * `?` in the *directory* name as a fragment and a query, so a checkout under
41
+ * `Q:/re#po` silently resolves to the drive root, and it percent-decodes, so a
42
+ * literal `%20` in a path becomes a space. Neither is hypothetical on a machine
43
+ * whose worktrees live under generated directory names.
44
+ *
45
+ * A function rather than a constant so a test can change `CONTENT_ARTIFACT` and
46
+ * see the effect without re-importing the module.
47
+ */
48
+ export function artifactPath(path = ARTIFACT_PATH) {
49
+ return resolve(process.cwd(), path);
50
+ }
51
+ /**
52
+ * The artifact's raw bytes, exactly as they are on disk.
53
+ *
54
+ * Returned as text rather than parsed because two callers need the bytes
55
+ * themselves: `emit-redirects.ts` hashes them into the redirect map's
56
+ * `content_version`, and `built-routes.test.ts` recomputes that hash to prove
57
+ * the map came from the artifact `dist/` was built from.
58
+ */
59
+ export function readArtifact(path = ARTIFACT_PATH) {
60
+ return readFileSync(artifactPath(path), 'utf8');
61
+ }
62
+ /**
63
+ * The validated artifact, read from whichever file this build selected.
64
+ *
65
+ * One place where read, parse, and validate happen together, because three
66
+ * callers were doing all three and a fourth would have got the error label
67
+ * subtly wrong. Every caller sees an artifact that has already passed the
68
+ * contract, and a violation names the file it actually came from.
69
+ */
70
+ export function loadArtifact(path = ARTIFACT_PATH) {
71
+ return validateArtifact(JSON.parse(readArtifact(path)), path);
72
+ }
@@ -0,0 +1,11 @@
1
+ /**
2
+ * Build-time globals substituted by `vite.define` from the private snapshot
3
+ * workspace (`readStaged` in `astro.config.mjs`).
4
+ *
5
+ * They carry the digest-named same-origin binding for the snapshot and the
6
+ * SQLite WASM, or `null` when a build staged none, so the Worker fails closed
7
+ * and fetches nothing. Declared here rather than in a `.d.ts` because
8
+ * `scripts/compile-package.ts` builds its TypeScript program from `src` and
9
+ * `scripts` sources, not from ambient declaration files.
10
+ */
11
+ export {};