@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,651 @@
1
+ /**
2
+ * Search dialog.
3
+ *
4
+ * Requirements section 11.4 and ticket TK-06. TK-12 made search *run* — it added
5
+ * `'wasm-unsafe-eval'` to `script-src`, without which Pagefind's WebAssembly
6
+ * never compiled. This file is the second half: the supported UI, the keyboard
7
+ * contract section 17 requires, and three load states told apart.
8
+ *
9
+ * **Why the modular UI rather than the Default UI.** The Default UI is
10
+ * deprecated and costs 29,579 B gzip; `pagefind-modular-ui.js` is 4,265 B and
11
+ * was already sitting in `dist/` unused. Nothing here hand-rolls a result
12
+ * renderer to save the remaining 4 KB — the parity plan §7.3 rejects that
13
+ * outright, and writing our own keyboard navigation and excerpt markup would
14
+ * cost more than it saves.
15
+ *
16
+ * **Everything loads on first open.** Not on first paint, not on hover — the
17
+ * gate in `tests/built-output.test.ts` proves no page requests a `/pagefind/`
18
+ * asset before the reader asks, which is the one budget assertion Quartz
19
+ * structurally cannot pass.
20
+ */
21
+ var __rewriteRelativeImportExtension = (this && this.__rewriteRelativeImportExtension) || function (path, preserveJsx) {
22
+ if (typeof path === "string" && /^\.\.?\//.test(path)) {
23
+ return path.replace(/\.(tsx)$|((?:\.d)?)((?:\.[^./]+?)?)\.([cm]?)ts$/i, function (m, tsx, d, ext, cm) {
24
+ return tsx ? preserveJsx ? ".jsx" : ".js" : d && (!ext || !cm) ? m : (d + ext + "." + cm.toLowerCase() + "js");
25
+ });
26
+ }
27
+ return path;
28
+ };
29
+ /* This file is a module — `otherLanguages` below is exported, and that is what
30
+ gives it its own top-level scope. TK-06 and TK-07 can each declare `dialog`
31
+ or `trigger` without colliding, because Astro concatenates these scripts into
32
+ one bundle and a script without a top-level export would share that scope.
33
+ TK-12 held the scope open with a bare `export {}`; a real export replaced it. */
34
+ /** Where the Pagefind bundle lives, as both a `<link>` href and an import base. */
35
+ import { MESSAGE_DATASET } from "../lib/translations.js";
36
+ const BUNDLE_PATH = '/pagefind/';
37
+ /**
38
+ * The documented shortcut, matched on `event.key`.
39
+ *
40
+ * `/` rather than the more fashionable `Ctrl-K`: it needs no modifier, it is
41
+ * what the trigger's own accessible name announces, and it collides with no
42
+ * browser or screen-reader binding. It is suppressed while the reader is typing
43
+ * — see `isTypingTarget` — because a reader filling in a field expects a slash.
44
+ */
45
+ const SHORTCUT_KEY = '/';
46
+ /**
47
+ * How long a secondary-language merge may take before it is abandoned.
48
+ *
49
+ * Generous, because it is not a performance budget: it exists only so a merge
50
+ * that *hangs* rather than fails cannot hold the dialog on "Loading" over an
51
+ * index that already works. Pagefind waits on internal state in `while` loops
52
+ * with no timeout of its own, so this is the only bound there is.
53
+ */
54
+ const MERGE_TIMEOUT_MS = 10_000;
55
+ /** Reject if `work` has not settled within `limit`, leaving `work` to its fate. */
56
+ function withTimeout(work, limit) {
57
+ return Promise.race([
58
+ work,
59
+ new Promise((_, reject) => setTimeout(() => reject(new Error('timed out')), limit)),
60
+ ]);
61
+ }
62
+ /** Elements that swallow the shortcut: a slash typed into one of these is a slash. */
63
+ function isTypingTarget(target) {
64
+ if (!(target instanceof HTMLElement))
65
+ return false;
66
+ return (target.isContentEditable ||
67
+ target instanceof HTMLInputElement ||
68
+ target instanceof HTMLTextAreaElement ||
69
+ target instanceof HTMLSelectElement);
70
+ }
71
+ /** Append a same-origin `<link>` or `<script>` and resolve when it has loaded. */
72
+ function load(element) {
73
+ return new Promise((resolve, reject) => {
74
+ const source = element.getAttribute('href') ?? element.getAttribute('src');
75
+ element.addEventListener('load', () => resolve(), { once: true });
76
+ element.addEventListener('error', () => reject(new Error(`failed to load ${source}`)), { once: true });
77
+ document.head.append(element);
78
+ });
79
+ }
80
+ /**
81
+ * The partition Pagefind will load for this document, by its own rule.
82
+ *
83
+ * Mirrors `findIndex` in `pagefind.js` exactly, and the order is the whole
84
+ * point: an exact match on the full lowercased tag first, then the base subtag,
85
+ * then — when neither exists — the partition with the most pages. Getting this
86
+ * wrong in either direction is a real defect rather than a nicety, because it
87
+ * decides which partition must *not* be merged.
88
+ */
89
+ function primaryLanguage(entry, documentLanguage) {
90
+ const languages = entry.languages ?? {};
91
+ const own = documentLanguage.toLowerCase();
92
+ if (own in languages)
93
+ return own;
94
+ const base = own.split('-')[0];
95
+ if (base in languages)
96
+ return base;
97
+ // Pagefind's own fallback when the document's language is indexed under
98
+ // neither form: the largest partition becomes primary.
99
+ let largest;
100
+ for (const [language, index] of Object.entries(languages)) {
101
+ if (largest === undefined || (index.page_count ?? 0) > (languages[largest]?.page_count ?? 0)) {
102
+ largest = language;
103
+ }
104
+ }
105
+ return largest;
106
+ }
107
+ /**
108
+ * Every indexed partition except the one Pagefind has already loaded.
109
+ *
110
+ * Two failures live in the difference between this and the obvious prefix test,
111
+ * and both were measured against real `pagefind` output:
112
+ *
113
+ * - **A sibling region tag is a separate partition.** Pagefind keys partitions on
114
+ * the exact lowercased `<html lang>`, so `en` and `en-gb` are two indexes with
115
+ * two hashes. A bidirectional prefix test excludes each from the other, and
116
+ * neither is ever merged — so on a corpus carrying both, half the notes stay
117
+ * unfindable, which is the exact defect this whole function exists to close.
118
+ * The base-subtag rule is only Pagefind's *fallback*, applied when no exact
119
+ * match exists; treating it as an equivalence is what hides the sibling.
120
+ * - **A document whose language is indexed under no form merges the index onto
121
+ * itself.** `<html lang="fr">` against an `en`-only index makes `en` primary by
122
+ * Pagefind's largest-partition fallback — and a prefix test would also report
123
+ * `en` as "other", so every result appeared twice, once per URL form. The
124
+ * artifact's `language` is a free-form tag, so one note in a third language is
125
+ * all it takes.
126
+ *
127
+ * Deriving the exclusion from `primaryLanguage` rather than from string shape
128
+ * makes both cases fall out of the same rule Pagefind is using.
129
+ */
130
+ export function otherLanguages(entry, documentLanguage) {
131
+ const primary = primaryLanguage(entry, documentLanguage);
132
+ return Object.keys(entry.languages ?? {}).filter((language) => language !== primary);
133
+ }
134
+ /**
135
+ * The `mergeIndex` entries that make the *other* languages searchable.
136
+ *
137
+ * **The bilingual defect this closes.** Pagefind auto-partitions its index by
138
+ * `<html lang>` and at query time loads exactly one partition — the current
139
+ * document's. On a bilingual corpus that means every English note is unfindable
140
+ * from a Chinese page and every Chinese note is unfindable from an English one,
141
+ * in both directions, with no error and no empty-state distinction: the reader
142
+ * is told "no results" about a note that exists.
143
+ *
144
+ * **The absolute URL is load-bearing.** Pagefind refuses a merge whose path is a
145
+ * prefix of the primary index's path — the guard that stops a site merging
146
+ * itself — and the primary path here *is* `/pagefind/`. Passing the relative
147
+ * path logs "Skipping mergeIndex /pagefind/ that appears to be the same as the
148
+ * primary index" and leaves the other language unsearchable, which from the
149
+ * outside looks exactly like success. An absolute URL is not a prefix of
150
+ * `/pagefind/`, so the merge proceeds against the very same files. Verified in a
151
+ * browser in both directions against the 32-note bilingual fixture corpus.
152
+ *
153
+ * **Each partition is proven reachable before it is merged, and that is a
154
+ * correctness requirement rather than an optimisation.** `mergeIndex` pushes its
155
+ * new instance onto Pagefind's instance list *synchronously*, before its first
156
+ * await, and every later `search()` flat-maps across that list. There is no way
157
+ * to remove one. So a partition whose own metadata chunk 404s does not merely
158
+ * fail to contribute results — it poisons the working primary index for the rest
159
+ * of the page's life. Measured on the fixture corpus: with the `zh-cn` metadata
160
+ * chunk returning 503, an English query on an English page returned **zero**
161
+ * results while the status line read "Type to search this site", and the console
162
+ * carried an uncaught "WASM Error (No pointer)". Catching the merge's rejection
163
+ * does not help, because the damage is done before it rejects. Fetching the one
164
+ * chunk first is the only point at which the decision is still reversible.
165
+ *
166
+ * Pagefind's documentation records that a merged index is queried using the
167
+ * *primary* index's language support, so CJK segmentation could in principle
168
+ * depend on which page the reader searched from. Measured on this corpus it does
169
+ * not: `设计`, `花园`, `排版`, and `设计笔记` return the same pages from an
170
+ * English page as from a Chinese one. The caveat is recorded because it is the
171
+ * corpus that makes it harmless, not this code.
172
+ */
173
+ async function mergeEntries() {
174
+ // Every fetch here is bounded. Nothing in this function is required for the
175
+ // page's own index to work, so a request that *hangs* rather than fails must
176
+ // not hold the dialog on "Loading" — which is worse than a plain failure,
177
+ // because the state never reaches `failed` and the Enter-retry is dead with
178
+ // it. `AbortSignal.timeout` rejects the fetch itself rather than merely
179
+ // abandoning a promise, so the connection is dropped too.
180
+ const bounded = (url) => fetch(url, { signal: AbortSignal.timeout(MERGE_TIMEOUT_MS) });
181
+ try {
182
+ const response = await bounded(`${BUNDLE_PATH}pagefind-entry.json`);
183
+ if (!response.ok)
184
+ return [];
185
+ const entry = (await response.json());
186
+ const bundlePath = new URL(BUNDLE_PATH, location.href).href;
187
+ const wanted = otherLanguages(entry, document.documentElement.lang);
188
+ const reachable = await Promise.all(wanted.map(async (language) => {
189
+ const hash = entry.languages?.[language]?.hash;
190
+ if (hash === undefined)
191
+ return undefined;
192
+ try {
193
+ const meta = await bounded(`${BUNDLE_PATH}pagefind.${hash}.pf_meta`);
194
+ return meta.ok ? { bundlePath, language } : undefined;
195
+ }
196
+ catch {
197
+ return undefined;
198
+ }
199
+ }));
200
+ return reachable.filter((entryOrNot) => entryOrNot !== undefined);
201
+ }
202
+ catch {
203
+ // One language's worth of results beats none: the reader still has the index
204
+ // for the page they are on. This is why the failure is swallowed here rather
205
+ // than propagating into the `failed` state.
206
+ return [];
207
+ }
208
+ }
209
+ /**
210
+ * Prove the search runtime is actually reachable, before any UI is built.
211
+ *
212
+ * **This is the difference between a failure state and a lie.** Pagefind's own
213
+ * loader catches a failed `import('/pagefind/pagefind.js')`, logs it, and carries
214
+ * on to call `.options()` on `undefined` — so the error surfaces as an uncaught
215
+ * `TypeError` from inside a promise this file never sees, the dialog sits on
216
+ * "Loading the search index…" forever, and the reader is told to wait for
217
+ * something that will never arrive. Awaiting only the modular-UI script is not
218
+ * enough, because that script loads fine; it is the runtime it pulls in
219
+ * afterwards that fails. Measured against the real build: with only
220
+ * `/pagefind/pagefind.js` returning 503, the status line never left "Loading"
221
+ * and the Enter-retry was dead, because `isMounted` was already true.
222
+ *
223
+ * The same is true one layer down, and worse. A working `pagefind.js` whose
224
+ * WebAssembly is missing or corrupt does **not** fail `init()`: Pagefind records
225
+ * the error and resolves anyway, rethrowing only when something first needs the
226
+ * index. Verified by serving a 503 for `wasm.en.pagefind` — `options()` and
227
+ * `init()` both resolved, and the reader was shown "Type to search this site"
228
+ * over an index that could never answer. A 503 on the metadata chunk behaves the
229
+ * same way, failing later with "WASM Error (No pointer)".
230
+ *
231
+ * So the runtime is imported *and exercised* here, where a rejection is a value
232
+ * this file can act on. `filters()` is the cheapest call that reaches the index —
233
+ * measured to reject on both failure modes above — and its result is needed
234
+ * anyway to decide which filter pills to build, so proving the index works costs
235
+ * nothing extra.
236
+ *
237
+ * `init()`'s own rejection is swallowed rather than propagated, and that is not
238
+ * laziness: Pagefind rejects the *same* WebAssembly failure from both `init()`
239
+ * and the next real call, so letting both surface would report one fault twice —
240
+ * once here and once as an unhandled rejection in the console of a page the site
241
+ * requires to stay clean. The later rejection is the one that carries the fault
242
+ * for every mode, including the two where `init()` resolves regardless, so it is
243
+ * the one worth keeping.
244
+ *
245
+ * **`destroy()` before re-initialising, which is what makes a retry real.**
246
+ * Pagefind memoises its wrapper in a module-scoped `var`, so a second `import()`
247
+ * hands back the same object with the same dead state; `destroy()` clears it and
248
+ * the next call rebuilds and re-fetches. It is a no-op on the first attempt.
249
+ *
250
+ * **The specifier is deliberately *not* cache-busted, and that is load-bearing.**
251
+ * The modular UI imports `${bundlePath}pagefind.js` itself, so this file and the
252
+ * UI share one module record and therefore one runtime — which is precisely why a
253
+ * `mergeIndex` performed here changes what the UI's own `search()` returns. Adding
254
+ * a query string here creates a second, separate module: measured, the merge then
255
+ * landed on this file's copy while the UI searched its own, and a query that had
256
+ * been returning results returned none.
257
+ *
258
+ * ponytail: the cost is that a failed *fetch* of `pagefind.js` cannot be retried
259
+ * within the page — a browser caches a failed module record for the life of the
260
+ * document, so the retry re-throws without touching the network. The other three
261
+ * failure modes (the UI script, the WebAssembly, the metadata chunk) all recover,
262
+ * and this one is one reload away. Making it recover would mean giving up the
263
+ * shared runtime, which costs the bilingual merge — a working feature traded for
264
+ * a rarer recovery. Revisit only if Pagefind exposes an injectable runtime.
265
+ */
266
+ async function loadRuntime() {
267
+ const runtime = (await import(__rewriteRelativeImportExtension(/* @vite-ignore */ `${BUNDLE_PATH}pagefind.js`, true)));
268
+ await runtime.destroy().catch(() => undefined);
269
+ await runtime.options({});
270
+ await runtime.init().catch(() => undefined);
271
+ return runtime;
272
+ }
273
+ /**
274
+ * The filter regions the dialog reserves.
275
+ *
276
+ * `selectMultiple` reflects the artifact: a note carries several tags, so those
277
+ * combine, and at most one collection, so those are exclusive. Each entry is
278
+ * spread straight into `FilterPills`, which takes exactly these three keys.
279
+ */
280
+ const FILTER_REGIONS = [
281
+ { filter: 'tag', containerElement: '#search-filter-tag', selectMultiple: true },
282
+ { filter: 'collection', containerElement: '#search-filter-collection', selectMultiple: false },
283
+ ];
284
+ /**
285
+ * `globalThis.document?.` rather than a bare `document.`: this module exports
286
+ * `otherLanguages`, and `tests/search.test.ts` imports it under Node to check
287
+ * the region-tag cases a browser gate cannot reach without a third fixture
288
+ * language. In a browser `document` always exists, so the optional chain never
289
+ * short-circuits there and every branch below is unchanged; under Node it
290
+ * yields `undefined` and the whole setup block is skipped, which is exactly what
291
+ * a module with no DOM should do.
292
+ */
293
+ const trigger = globalThis.document?.querySelector('#search-toggle');
294
+ const dialog = globalThis.document?.querySelector('#search-dialog');
295
+ if (trigger != null && dialog != null) {
296
+ const status = document.querySelector('#search-status');
297
+ const results = document.querySelector('#search-results');
298
+ const input = document.querySelector('#search-input');
299
+ /** The one control inside the dialog whose Enter must stay the browser's. */
300
+ const closeButton = dialog.querySelector('.search-close button');
301
+ let state = 'idle';
302
+ let loading;
303
+ let isMounted = false;
304
+ /** How many times the bundle has been asked for; 0 is the ordinary first load. */
305
+ let attempt = 0;
306
+ /**
307
+ * Announce a state, in the language of the document the dialog is on.
308
+ *
309
+ * The four sentences are read off `#search-status`'s own `data-message-*`
310
+ * attributes, which `Layout.astro` filled from the translation resolved for
311
+ * this document. That is what makes the search dialog bilingual at zero added
312
+ * bytes: a locale table in this bundle would put every language on the wire
313
+ * for every reader, to say what the build already knew — and it would grow
314
+ * with each language, on a script that ships on every page.
315
+ *
316
+ * `ready` is not among them: results are on screen and the list is the
317
+ * message, so a sentence there would sit above the rows contradicting them.
318
+ * It is the empty string, and it is the only state that is.
319
+ *
320
+ * `textContent`, never `innerHTML`: this is a live region, and what goes into
321
+ * it is now attribute text rather than a literal in this file — so it stays
322
+ * data throughout. The strings are chrome the build authored rather than
323
+ * artifact content, but the rule does not depend on that being true.
324
+ */
325
+ function announce(next) {
326
+ state = next;
327
+ if (status === null)
328
+ return;
329
+ status.textContent = next === 'ready' ? '' : (status.dataset[MESSAGE_DATASET[next]] ?? '');
330
+ }
331
+ /**
332
+ * Fetch the modular UI bundle and its stylesheet.
333
+ *
334
+ * Deliberately not `async`, which `astro check` suggests as hint ts(80006) and
335
+ * which would be wrong here: an async function's body runs when it is called,
336
+ * so `loading ??= fetchBundle()` would have started the fetch before the
337
+ * assignment landed, and two opens in the same tick would each append their
338
+ * own `<link>` and `<script>`. Returning the promise from a synchronous call
339
+ * keeps the start atomic with the memoisation. The hint is the only one this
340
+ * repository carries; it is recorded here rather than silenced.
341
+ *
342
+ * The stylesheet is awaited so the first frame is not unstyled, but its
343
+ * failure is **not** fatal: the script is what search needs, and refusing to
344
+ * mount a working index because its CSS 404'd would tell the reader search is
345
+ * broken when it works.
346
+ *
347
+ * `attempt` cache-busts a retry: a script the browser already failed to fetch
348
+ * stays failed for the life of the document unless the URL differs. It is empty
349
+ * on the first load, so the ordinary request is a plain one the CDN caches
350
+ * normally. The `<script>` left by a failed attempt stays in the head — it
351
+ * executed nothing, and removing a script element does not unload it anyway.
352
+ *
353
+ * `pagefind.js` deliberately does *not* get the same treatment; `loadRuntime`
354
+ * records why, and it is the reason that one failure mode needs a page reload.
355
+ */
356
+ const fetchBundle = () => {
357
+ const cacheBust = attempt === 0 ? '' : `?retry=${attempt}`;
358
+ const stylesheet = document.createElement('link');
359
+ stylesheet.rel = 'stylesheet';
360
+ stylesheet.href = `${BUNDLE_PATH}pagefind-modular-ui.css${cacheBust}`;
361
+ const script = document.createElement('script');
362
+ script.src = `${BUNDLE_PATH}pagefind-modular-ui.js${cacheBust}`;
363
+ const styled = load(stylesheet).catch(() => undefined);
364
+ // The runtime is loaded alongside the UI rather than left to Pagefind, and
365
+ // both are awaited: see `loadRuntime` for why a failure there is otherwise
366
+ // invisible. They start together because neither needs the other.
367
+ return Promise.all([load(script), loadRuntime(), styled]).then(([, runtime]) => {
368
+ const ui = window.PagefindModularUI;
369
+ if (ui === undefined)
370
+ throw new Error('the Pagefind bundle loaded but exposed no modular UI');
371
+ return { ui, runtime };
372
+ });
373
+ };
374
+ /**
375
+ * Build the search UI into the dialog's regions.
376
+ *
377
+ * `Input` is handed the existing `<input>` rather than a container, so the
378
+ * markup keeps its own `<label>` and the element the dialog focuses on open is
379
+ * the element Pagefind drives. Letting Pagefind mint the field instead would
380
+ * put an `action="javascript:void(0);"` form into a document whose CSP sets
381
+ * `form-action 'none'`, and would replace a labelled control with one whose
382
+ * label is a `data-` attribute.
383
+ *
384
+ * `showImages: false` because no artifact field carries a per-result image;
385
+ * leaving it on renders an empty thumbnail box beside every result.
386
+ *
387
+ * **Filter pills are added only once the index proves it has values for them**,
388
+ * and that is a bug fix rather than an optimisation. `FilterPills` initialises
389
+ * its `available` to `{}` and replaces it with an array only on a `filters`
390
+ * event carrying its own key; when the key is absent it logs a warning,
391
+ * returns early, and leaves `available` an object — after which `update()`
392
+ * calls `this.available.map` and throws. The throw lands inside Pagefind's
393
+ * synchronous hook dispatch, which aborts the rest of the hook list: on a
394
+ * corpus with no tags, the pills crash took out the `results` handler
395
+ * registered after it and the reader never saw the "no results" state at all.
396
+ * One unpopulated filter silently disabled the whole state machine.
397
+ *
398
+ * Registering from inside the `filters` event is what makes the ticket's
399
+ * "when those fields exist" conditional real: the artifact's `tags` and
400
+ * `collection` are both optional, the published corpus has neither, and the
401
+ * fixture corpus has both.
402
+ *
403
+ * **Pagefind's `loading` event is deliberately not subscribed to.** It fires at
404
+ * the top of every `__search__`, not only while the index is downloading — so
405
+ * mapping it to the `loading` state made "Loading the search index…" flash on
406
+ * every keystroke, long after the index was loaded. That is the load-state
407
+ * machine lying in the other direction, and it is why the `loading` state is
408
+ * driven only by `ensureMounted`, which is the only place an index is ever
409
+ * actually being fetched.
410
+ */
411
+ function mount(ui, filters) {
412
+ const instance = new ui.Instance({
413
+ bundlePath: BUNDLE_PATH,
414
+ // Pagefind escapes everything in an excerpt except its own `<mark>`, and
415
+ // the excerpt is drawn from `data-pagefind-body` — the sanitized article —
416
+ // so it can only ever be public text. Verified against a body containing a
417
+ // literal `<img onerror=…>`: it comes back entity-encoded.
418
+ excerptLength: 24,
419
+ });
420
+ instance.add(new ui.Input({ inputElement: '#search-input' }));
421
+ instance.add(new ui.ResultList({ containerElement: '#search-results', showImages: false }));
422
+ for (const region of FILTER_REGIONS) {
423
+ if (Object.keys(filters[region.filter] ?? {}).length === 0)
424
+ continue;
425
+ // ponytail: Pagefind writes this component's own two strings itself — an
426
+ // sr-only "Filter results by <filter>" label and an "All" pill — and they
427
+ // are English literals inside its bundle, with no option to supply them.
428
+ // So a pill that mounted on a zh-CN page would carry English chrome the
429
+ // TK-16 contract cannot reach. Not reachable today and measured, not
430
+ // assumed: `pagefind` reports "Indexed 0 filters" on both corpora, because
431
+ // nothing in the built HTML carries `data-pagefind-filter`, so this branch
432
+ // never runs. It becomes live the moment a ticket adds that attribute —
433
+ // at which point the fix is to relabel the two nodes after mounting, or to
434
+ // render the pills from the artifact's own facets rather than from
435
+ // Pagefind's.
436
+ instance.add(new ui.FilterPills({ ...region }));
437
+ }
438
+ return instance;
439
+ }
440
+ /**
441
+ * Load, merge, and mount — once per page, and retried after a failure.
442
+ *
443
+ * The `isMounted` re-check after the await is not redundant with the one
444
+ * before it: two opens during a single in-flight load both pass the cheap
445
+ * check at the top and both resume here, and the second would build a second
446
+ * search UI into the same elements.
447
+ */
448
+ async function ensureMounted() {
449
+ if (isMounted)
450
+ return;
451
+ announce('loading');
452
+ try {
453
+ // `loading` is cleared on failure, so a `??=` that assigns here is exactly
454
+ // a retry — and the counter it reads is what makes that retry re-fetch
455
+ // rather than replay a cached failure.
456
+ if (loading === undefined) {
457
+ loading = fetchBundle();
458
+ attempt += 1;
459
+ }
460
+ const [{ ui, runtime }, mergeIndex] = await Promise.all([loading, mergeEntries()]);
461
+ // The call that proves the page's own index can actually answer — see
462
+ // `loadRuntime`. It runs *before* any merge, and that order is
463
+ // load-bearing rather than tidy: Pagefind's `mergeIndex` waits on the
464
+ // primary index's WebAssembly in an unbounded `while (wasm === null)`
465
+ // loop, so merging onto a primary whose WebAssembly failed never resolves
466
+ // and never rejects. The dialog would sit on "Loading the search index…"
467
+ // for the life of the page. Validating first turns that hang into the
468
+ // failure state, which is a sentence the reader can act on.
469
+ //
470
+ // Bounded for the same reason, one layer down. Pagefind's `getPtr` spins
471
+ // `while (raw_ptr === null)` and its own fetches carry no abort signal, so
472
+ // a chunk that is *accepted and never answered* — a stalled connection
473
+ // rather than a 503 — leaves this promise permanently pending. Measured:
474
+ // holding the WebAssembly chunk open left the status on "Loading the
475
+ // search index…" past 45 s with a dead retry, because `loading` never
476
+ // settles and `ensureMounted` awaits it forever. The timeout is what makes
477
+ // that a `failed` state the reader can retry out of.
478
+ //
479
+ // The result is also what decides which filter pills exist, so proving the
480
+ // index works asks nothing of Pagefind twice.
481
+ let filters = await withTimeout(runtime.filters(), MERGE_TIMEOUT_MS);
482
+ // Merged onto the runtime this file loaded, rather than passed to the UI
483
+ // as a constructor option: the UI would import a *second* copy of
484
+ // `pagefind.js` and merge into that one, leaving the copy whose failure
485
+ // `loadRuntime` can actually see doing nothing.
486
+ let hasMerged = false;
487
+ for (const entry of mergeIndex) {
488
+ const { bundlePath, ...options } = entry;
489
+ // Not fatal, and deliberately so: the reader still has the index for the
490
+ // page they are on, and one language's worth of results beats none. Only
491
+ // the *primary* index's failure above is fatal.
492
+ //
493
+ // Bounded rather than merely caught. A merge whose own assets 404 can
494
+ // hang inside Pagefind rather than reject, and an unbounded await on it
495
+ // would hold the whole dialog on "Loading" over a search that is
496
+ // already working — the enhancement blocking the feature it enhances.
497
+ await withTimeout(runtime.mergeIndex(bundlePath, options), MERGE_TIMEOUT_MS)
498
+ .then(() => {
499
+ hasMerged = true;
500
+ })
501
+ .catch(() => undefined);
502
+ }
503
+ // A merge contributes its own filter values, so the pills are decided
504
+ // after the last one rather than before the first.
505
+ if (hasMerged)
506
+ filters = await runtime.filters().catch(() => filters);
507
+ if (isMounted)
508
+ return;
509
+ // Set before constructing rather than after: if the constructor throws,
510
+ // the catch resets it, so a genuine failure still retries while a
511
+ // concurrent resume cannot slip past.
512
+ isMounted = true;
513
+ const instance = mount(ui, filters);
514
+ instance.on('results', (payload) => {
515
+ const found = payload?.results?.length ?? 0;
516
+ // An empty field is the reader having cleared it, not a corpus with
517
+ // nothing in it. `idle` says so; `empty` would accuse the site.
518
+ if (input !== null && input.value.trim() === '')
519
+ announce('idle');
520
+ else
521
+ announce(found === 0 ? 'empty' : 'ready');
522
+ });
523
+ announce('idle');
524
+ // A query typed while the bundle was still downloading. Pagefind's `Input`
525
+ // component only binds its listener when it is constructed, so anything
526
+ // already in the field was never searched — the reader would be looking at
527
+ // their own query above an empty list and the words "Type to search this
528
+ // site", which is the same class of lie as collapsing the load states.
529
+ // Measured: typing during a 2.5 s delayed bundle left the field full and
530
+ // the results empty for as long as the dialog stayed open.
531
+ const pending = input?.value.trim() ?? '';
532
+ if (pending !== '')
533
+ instance.triggerSearch(pending);
534
+ }
535
+ catch {
536
+ // An unhandled rejection here would be a console error on a page that is
537
+ // otherwise clean, and the reader would be left looking at an empty dialog
538
+ // with no explanation. Clearing `loading` is what makes the next attempt a
539
+ // real retry rather than an await on the promise that already rejected.
540
+ loading = undefined;
541
+ isMounted = false;
542
+ results?.replaceChildren();
543
+ announce('failed');
544
+ }
545
+ }
546
+ /**
547
+ * Move focus through the result links.
548
+ *
549
+ * The results are a list of links, so the arrow keys move *focus* rather than
550
+ * maintaining a parallel `aria-activedescendant`: focus is what `Enter`
551
+ * already follows, what the browser already scrolls into view, and what a
552
+ * screen reader already announces.
553
+ *
554
+ * From the input, `ArrowDown` enters the list; from the first result, `ArrowUp`
555
+ * returns to the input rather than wrapping to the last, because the input is
556
+ * where a reader wants to be when they change their mind about the query.
557
+ */
558
+ function moveSelection(step) {
559
+ const links = [...dialog.querySelectorAll('#search-results a')];
560
+ if (links.length === 0)
561
+ return;
562
+ const current = links.indexOf(document.activeElement);
563
+ if (current === -1) {
564
+ (step > 0 ? links[0] : links[links.length - 1])?.focus();
565
+ return;
566
+ }
567
+ const next = current + step;
568
+ if (next < 0)
569
+ input?.focus();
570
+ else
571
+ links[Math.min(next, links.length - 1)]?.focus();
572
+ }
573
+ /** Focusable elements inside the dialog, in tab order. */
574
+ function focusables() {
575
+ return [
576
+ ...dialog.querySelectorAll('a[href], button:not([disabled]), input:not([disabled]), [tabindex]:not([tabindex="-1"])'),
577
+ ].filter((element) => element.offsetParent !== null || element === document.activeElement);
578
+ }
579
+ function open() {
580
+ if (dialog.open)
581
+ return;
582
+ // `showModal` puts the dialog in the top layer, which makes the rest of the
583
+ // document inert and gives `Escape` its close behaviour without a handler.
584
+ dialog.showModal();
585
+ input?.focus();
586
+ void ensureMounted();
587
+ }
588
+ trigger.addEventListener('click', open);
589
+ document.addEventListener('keydown', (event) => {
590
+ if (event.key !== SHORTCUT_KEY || event.ctrlKey || event.metaKey || event.altKey)
591
+ return;
592
+ if (dialog.open || isTypingTarget(event.target))
593
+ return;
594
+ // Only once the shortcut is certain to act: a `preventDefault` on a slash
595
+ // the reader meant to type would be a broken keyboard.
596
+ event.preventDefault();
597
+ open();
598
+ });
599
+ dialog.addEventListener('keydown', (event) => {
600
+ if (event.key === 'ArrowDown') {
601
+ event.preventDefault();
602
+ moveSelection(1);
603
+ return;
604
+ }
605
+ if (event.key === 'ArrowUp') {
606
+ event.preventDefault();
607
+ moveSelection(-1);
608
+ return;
609
+ }
610
+ if (event.key === 'Enter' && state === 'failed' && event.target !== closeButton) {
611
+ // The failure message tells the reader Enter retries. This is that.
612
+ //
613
+ // Scoped by exclusion rather than by naming the field, and the difference
614
+ // is a real one in both directions. Gating on `event.target === input`
615
+ // made the retry unreachable whenever focus sat on the dialog itself —
616
+ // which is where it lands after a click on any non-focusable part of it,
617
+ // with the message still promising that Enter retries. Not scoping at all
618
+ // cancelled the Close button's implicit `<form method="dialog">`
619
+ // submission through the `preventDefault` below, leaving the only other
620
+ // focusable control in the failed state inert. Excluding exactly that one
621
+ // control leaves Enter meaning "retry" everywhere else in the dialog.
622
+ event.preventDefault();
623
+ void ensureMounted();
624
+ return;
625
+ }
626
+ if (event.key !== 'Tab')
627
+ return;
628
+ // The focus trap. `showModal` already confines focus to the dialog in every
629
+ // current browser, so this is the belt to that braces — cheap enough to be
630
+ // worth having, because the failure it prevents is a keyboard reader tabbing
631
+ // into a page they cannot see.
632
+ const elements = focusables();
633
+ if (elements.length === 0)
634
+ return;
635
+ const first = elements[0];
636
+ const last = elements[elements.length - 1];
637
+ if (event.shiftKey && document.activeElement === first) {
638
+ event.preventDefault();
639
+ last.focus();
640
+ }
641
+ else if (!event.shiftKey && document.activeElement === last) {
642
+ event.preventDefault();
643
+ first.focus();
644
+ }
645
+ });
646
+ // A modal dialog restores focus to whatever opened it, but only while that
647
+ // element is still focusable — and the trigger is `[data-js-only]`, a class of
648
+ // element this site has already had disappear under a competing rule once.
649
+ // Restoring explicitly costs one line.
650
+ dialog.addEventListener('close', () => trigger.focus());
651
+ }