@wxxb789/anc 0.0.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +295 -0
- package/astro.config.mjs +255 -0
- package/bin/anc.mjs +798 -0
- package/package.json +93 -0
- package/public/_headers +45 -0
- package/public/favicon.ico +0 -0
- package/public/favicon.svg +9 -0
- package/public/fonts/Temml.woff2 +0 -0
- package/scripts/build-snapshot.js +69 -0
- package/scripts/build-wasm.js +67 -0
- package/scripts/build-workspace.js +50 -0
- package/scripts/copy-snapshot.js +48 -0
- package/scripts/copy-wasm.js +57 -0
- package/scripts/emit-redirects.js +64 -0
- package/scripts/generate-corpus.js +421 -0
- package/scripts/init-repository.js +249 -0
- package/scripts/load-config.js +975 -0
- package/scripts/markdown-to-artifact.js +1286 -0
- package/scripts/preview-site.js +364 -0
- package/scripts/publish-set-review.js +157 -0
- package/scripts/resolve-links.js +640 -0
- package/scripts/run-pagefind.js +125 -0
- package/scripts/scan-residue.js +1016 -0
- package/scripts/scan-secrets.js +268 -0
- package/scripts/seed-gitignore.js +410 -0
- package/scripts/snapshot-rows.js +205 -0
- package/scripts/validate-content.js +95 -0
- package/scripts/vendor-provenance.js +307 -0
- package/scripts/verify-output-inventory.js +307 -0
- package/scripts/write-report.js +351 -0
- package/scripts/write-snapshot.js +136 -0
- package/src/components/CollectionExplorer.astro +149 -0
- package/src/components/FacetIndex.astro +45 -0
- package/src/components/LinkedNotes.astro +94 -0
- package/src/components/NoteGraph.astro +434 -0
- package/src/components/NoteList.astro +54 -0
- package/src/components/SiteMetadata.astro +143 -0
- package/src/components/TableOfContents.astro +69 -0
- package/src/components/TagBrowser.astro +70 -0
- package/src/layouts/Layout.astro +305 -0
- package/src/lib/artifact-source.js +72 -0
- package/src/lib/build-bindings.js +11 -0
- package/src/lib/collection-navigation.js +145 -0
- package/src/lib/content.js +92 -0
- package/src/lib/diagram-mode.js +18 -0
- package/src/lib/graph-layout.js +219 -0
- package/src/lib/graph-selection.js +84 -0
- package/src/lib/graph.js +42 -0
- package/src/lib/link-resolution.js +389 -0
- package/src/lib/markdown.js +1469 -0
- package/src/lib/math-mode.js +31 -0
- package/src/lib/math-output.js +131 -0
- package/src/lib/math.js +165 -0
- package/src/lib/mermaid-environment.js +475 -0
- package/src/lib/mermaid-render.js +769 -0
- package/src/lib/preview-model.js +96 -0
- package/src/lib/relations.js +242 -0
- package/src/lib/rendered-marker.js +55 -0
- package/src/lib/route-path.js +192 -0
- package/src/lib/routes.js +342 -0
- package/src/lib/schema.js +578 -0
- package/src/lib/site.js +578 -0
- package/src/lib/snapshot-contract.js +216 -0
- package/src/lib/snapshot-operations.js +147 -0
- package/src/lib/snapshot-queries.js +216 -0
- package/src/lib/snapshot-reader.js +210 -0
- package/src/lib/snapshot.js +233 -0
- package/src/lib/sqlite-warning.js +22 -0
- package/src/lib/sqlite.js +14 -0
- package/src/lib/tag-browser-model.js +18 -0
- package/src/lib/translations.js +533 -0
- package/src/lib/wasm-asset.js +47 -0
- package/src/lib/worker-protocol.js +93 -0
- package/src/pages/404.astro +35 -0
- package/src/pages/about.astro +100 -0
- package/src/pages/collections/[slug].astro +36 -0
- package/src/pages/collections/index.astro +33 -0
- package/src/pages/graph.astro +64 -0
- package/src/pages/index.astro +33 -0
- package/src/pages/notes/[slug].astro +509 -0
- package/src/pages/privacy.astro +121 -0
- package/src/pages/private.astro +59 -0
- package/src/pages/recent.astro +33 -0
- package/src/pages/robots.txt.js +15 -0
- package/src/pages/rss.xml.js +21 -0
- package/src/pages/sitemap.xml.js +20 -0
- package/src/pages/tags/[tag].astro +56 -0
- package/src/pages/tags/index.astro +37 -0
- package/src/scripts/code-copy.js +45 -0
- package/src/scripts/diagram-disabled.js +16 -0
- package/src/scripts/diagram.js +322 -0
- package/src/scripts/graph-client.js +222 -0
- package/src/scripts/graph-draw.js +166 -0
- package/src/scripts/link-preview.js +334 -0
- package/src/scripts/math-disabled.js +18 -0
- package/src/scripts/math.js +133 -0
- package/src/scripts/preferences.js +123 -0
- package/src/scripts/search-dialog.js +651 -0
- package/src/scripts/snapshot-client.js +157 -0
- package/src/scripts/snapshot-worker.js +341 -0
- package/src/scripts/tag-browser.js +153 -0
- package/src/scripts/theme-init.js +35 -0
- package/src/scripts/toc-active.js +38 -0
- package/src/styles/code.css +152 -0
- package/src/styles/diagram.css +149 -0
- package/src/styles/global.css +1739 -0
- package/src/styles/graph.css +317 -0
- package/src/styles/math.css +376 -0
- package/src/styles/tokens.css +171 -0
- package/tsconfig.json +25 -0
|
@@ -0,0 +1,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
|
+
}
|