@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,157 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Main-thread client for the lazy snapshot Worker.
|
|
3
|
+
*
|
|
4
|
+
* Ordinary reading must download zero SQLite assets, so this module creates the
|
|
5
|
+
* Worker only on first explicit intent and shares one instance for previews,
|
|
6
|
+
* tag browsing, and graph exploration. It owns request ids, deadlines, and the
|
|
7
|
+
* pending bound: a reply from a torn-down Worker is discarded rather than
|
|
8
|
+
* attached to the current UI.
|
|
9
|
+
*/
|
|
10
|
+
import { WORKER_LIMITS, } from "../lib/worker-protocol.js";
|
|
11
|
+
export class SnapshotClientError extends Error {
|
|
12
|
+
code;
|
|
13
|
+
constructor(code) {
|
|
14
|
+
super(code);
|
|
15
|
+
this.name = 'SnapshotClientError';
|
|
16
|
+
this.code = code;
|
|
17
|
+
}
|
|
18
|
+
}
|
|
19
|
+
let worker;
|
|
20
|
+
let nextId = 1;
|
|
21
|
+
let initialized = false;
|
|
22
|
+
const pending = new Map();
|
|
23
|
+
function failAll(code) {
|
|
24
|
+
for (const entry of pending.values()) {
|
|
25
|
+
clearTimeout(entry.timer);
|
|
26
|
+
entry.reject(new SnapshotClientError(code));
|
|
27
|
+
}
|
|
28
|
+
pending.clear();
|
|
29
|
+
}
|
|
30
|
+
function reset(code) {
|
|
31
|
+
worker?.terminate();
|
|
32
|
+
worker = undefined;
|
|
33
|
+
initialized = false;
|
|
34
|
+
failAll(code);
|
|
35
|
+
}
|
|
36
|
+
function ensureWorker() {
|
|
37
|
+
if (worker !== undefined)
|
|
38
|
+
return worker;
|
|
39
|
+
const created = new Worker(new URL('./snapshot-worker.js', import.meta.url), { type: 'module' });
|
|
40
|
+
created.addEventListener('message', (event) => {
|
|
41
|
+
const reply = event.data;
|
|
42
|
+
const entry = pending.get(reply.id);
|
|
43
|
+
if (entry === undefined)
|
|
44
|
+
return;
|
|
45
|
+
const elapsed = performance.now() - entry.started;
|
|
46
|
+
pending.delete(reply.id);
|
|
47
|
+
clearTimeout(entry.timer);
|
|
48
|
+
if (reply.ok) {
|
|
49
|
+
initialized = true;
|
|
50
|
+
// Observability seam for the benchmark harness, armed explicitly by the
|
|
51
|
+
// measurer: the operation, its dispatch-to-validated-result time, and the
|
|
52
|
+
// Worker's own operation time (its queries plus selection). Goal 0005
|
|
53
|
+
// requires the two recorded separately for goal 0008. No corpus data is
|
|
54
|
+
// carried, and an ordinary reader dispatches nothing. An armed request
|
|
55
|
+
// also gets the reply's measurement-only fields when a measured Worker
|
|
56
|
+
// sent them; they are added to the detail only when present, so a reply
|
|
57
|
+
// without them keeps the shape this seam always had.
|
|
58
|
+
if (typeof window !== 'undefined' && window.__snapshotMeasurement === true) {
|
|
59
|
+
const detail = {
|
|
60
|
+
type: reply.result.type,
|
|
61
|
+
ms: elapsed,
|
|
62
|
+
operationMs: reply.operationMs,
|
|
63
|
+
};
|
|
64
|
+
if (reply.sqlMs !== undefined)
|
|
65
|
+
detail.sqlMs = reply.sqlMs;
|
|
66
|
+
if (reply.phases !== undefined)
|
|
67
|
+
detail.phases = reply.phases;
|
|
68
|
+
document.dispatchEvent(new CustomEvent('snapshot-result', { detail }));
|
|
69
|
+
}
|
|
70
|
+
entry.resolve(reply.result);
|
|
71
|
+
}
|
|
72
|
+
else {
|
|
73
|
+
entry.reject(new SnapshotClientError(reply.code));
|
|
74
|
+
}
|
|
75
|
+
});
|
|
76
|
+
const crashed = () => reset('terminated');
|
|
77
|
+
created.addEventListener('error', crashed);
|
|
78
|
+
created.addEventListener('messageerror', crashed);
|
|
79
|
+
worker = created;
|
|
80
|
+
return created;
|
|
81
|
+
}
|
|
82
|
+
/**
|
|
83
|
+
* Release the Worker and settle what it owed.
|
|
84
|
+
*
|
|
85
|
+
* A document's snapshot cannot change in place — its binding is compiled into
|
|
86
|
+
* the page — so the lifecycle contract's "snapshot change" case arrives as a
|
|
87
|
+
* new document with a new binding, and teardown is the only in-place
|
|
88
|
+
* transition. Registering it on `pagehide` releases the Worker (and its
|
|
89
|
+
* deserialized database) with the document, without an idle timer that would
|
|
90
|
+
* make a later preview cold.
|
|
91
|
+
*/
|
|
92
|
+
export function dispose() {
|
|
93
|
+
reset('cancelled');
|
|
94
|
+
}
|
|
95
|
+
// `typeof window` keeps this module loadable in Node for the client gate; a
|
|
96
|
+
// browser is the only environment where the listener has anything to release.
|
|
97
|
+
if (typeof window !== 'undefined')
|
|
98
|
+
window.addEventListener('pagehide', () => dispose());
|
|
99
|
+
/**
|
|
100
|
+
* Send one named operation.
|
|
101
|
+
*
|
|
102
|
+
* @throws {SnapshotClientError} with a small code on failure, a bounded reject
|
|
103
|
+
* when too many requests are already pending, and on a deadline.
|
|
104
|
+
*/
|
|
105
|
+
export function request(message) {
|
|
106
|
+
// The bound applies at every stage: a ready Worker must not accumulate an
|
|
107
|
+
// unbounded queue of closures and timers just because initialization finished.
|
|
108
|
+
if (pending.size >= WORKER_LIMITS.maxPendingRequests) {
|
|
109
|
+
return Promise.reject(new SnapshotClientError('busy'));
|
|
110
|
+
}
|
|
111
|
+
let instance;
|
|
112
|
+
try {
|
|
113
|
+
instance = ensureWorker();
|
|
114
|
+
}
|
|
115
|
+
catch {
|
|
116
|
+
// A Worker the document may not construct (CSP refusal, unsupported engine)
|
|
117
|
+
// rejects this request instead of throwing past an awaiting caller; the
|
|
118
|
+
// client's init state stays clean, so the next intent tries again.
|
|
119
|
+
return Promise.reject(new SnapshotClientError('terminated'));
|
|
120
|
+
}
|
|
121
|
+
const deadline = initialized ? WORKER_LIMITS.requestDeadlineMs : WORKER_LIMITS.startupDeadlineMs;
|
|
122
|
+
return new Promise((resolve, reject) => {
|
|
123
|
+
const id = message.id;
|
|
124
|
+
const timer = setTimeout(() => {
|
|
125
|
+
// A cancel message cannot interrupt synchronous SQL already running in the
|
|
126
|
+
// Worker, so the only bounded stop is terminating it.
|
|
127
|
+
reset('timeout');
|
|
128
|
+
}, deadline);
|
|
129
|
+
pending.set(id, { resolve, reject, timer, started: performance.now() });
|
|
130
|
+
// Arming is read at dispatch, and the added flag is written on a copy: the
|
|
131
|
+
// function's callers construct their message inline, but a caller that
|
|
132
|
+
// holds its object must get it back unmutated. Unarmed, the identical
|
|
133
|
+
// object posted before this seam existed is what goes out.
|
|
134
|
+
const armed = typeof window !== 'undefined' && window.__snapshotMeasurement === true;
|
|
135
|
+
instance.postMessage(armed ? { ...message, measure: true } : message);
|
|
136
|
+
});
|
|
137
|
+
}
|
|
138
|
+
/** Mint the next request id. */
|
|
139
|
+
function nextRequestId() {
|
|
140
|
+
return nextId++;
|
|
141
|
+
}
|
|
142
|
+
/** Preview one published note. */
|
|
143
|
+
export function requestPreview(slug) {
|
|
144
|
+
return request({ id: nextRequestId(), type: 'preview', slug });
|
|
145
|
+
}
|
|
146
|
+
/** One page of a tag's members. */
|
|
147
|
+
export function requestByTag(tagKey, cursor, pageSize) {
|
|
148
|
+
return request({ id: nextRequestId(), type: 'byTag', tagKey, cursor, pageSize });
|
|
149
|
+
}
|
|
150
|
+
/** The local neighbourhood of one note. */
|
|
151
|
+
export function requestLocalGraph(slug) {
|
|
152
|
+
return request({ id: nextRequestId(), type: 'localGraph', slug });
|
|
153
|
+
}
|
|
154
|
+
/** The global graph, optionally filtered to one tag. */
|
|
155
|
+
export function requestGlobalGraph(tagKey) {
|
|
156
|
+
return request({ id: nextRequestId(), type: 'globalGraph', tagKey });
|
|
157
|
+
}
|
|
@@ -0,0 +1,341 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The lazy, read-only snapshot Worker.
|
|
3
|
+
*
|
|
4
|
+
* Owns the snapshot download, digest verification, WASM initialization,
|
|
5
|
+
* read-only import, schema validation, and query execution. The main thread
|
|
6
|
+
* never learns the database URL and never sends SQL: the binding is compiled
|
|
7
|
+
* into this module, and requests are one of the named operations.
|
|
8
|
+
*
|
|
9
|
+
* Lifecycle (`docs/core-design/build-and-runtime.md`): one init promise per
|
|
10
|
+
* document/snapshot; a failure clears it so a later explicit intent can retry;
|
|
11
|
+
* every failure falls back to the already-rendered static page.
|
|
12
|
+
*/
|
|
13
|
+
var __rewriteRelativeImportExtension = (this && this.__rewriteRelativeImportExtension) || function (path, preserveJsx) {
|
|
14
|
+
if (typeof path === "string" && /^\.\.?\//.test(path)) {
|
|
15
|
+
return path.replace(/\.(tsx)$|((?:\.d)?)((?:\.[^./]+?)?)\.([cm]?)ts$/i, function (m, tsx, d, ext, cm) {
|
|
16
|
+
return tsx ? preserveJsx ? ".jsx" : ".js" : d && (!ext || !cm) ? m : (d + ext + "." + cm.toLowerCase() + "js");
|
|
17
|
+
});
|
|
18
|
+
}
|
|
19
|
+
return path;
|
|
20
|
+
};
|
|
21
|
+
import "../lib/build-bindings.js";
|
|
22
|
+
import { assertSnapshotRows } from "../lib/snapshot-contract.js";
|
|
23
|
+
import { edgePage, globalGraph, localGraph, preview, tagPage } from "../lib/snapshot-operations.js";
|
|
24
|
+
import { isSnapshotMessage, requestPageSize, WORKER_LIMITS, } from "../lib/worker-protocol.js";
|
|
25
|
+
/**
|
|
26
|
+
* The instant this module begins evaluating, before any load work.
|
|
27
|
+
*
|
|
28
|
+
* `load`'s `totalMs` is measured from here, so a measured reply reports the
|
|
29
|
+
* Worker's whole cold path rather than only the piece after `load` is called.
|
|
30
|
+
* That matters for goal 0008: the module's own evaluation and the shared
|
|
31
|
+
* initialization promise both precede the first reply, and a number that began
|
|
32
|
+
* at the fetch would silently omit them.
|
|
33
|
+
*/
|
|
34
|
+
const moduleStarted = performance.now();
|
|
35
|
+
/** Minimal Worker global, avoiding a webworker/DOM lib declaration clash. */
|
|
36
|
+
const scope = self;
|
|
37
|
+
const SQLITE_MAGIC = [0x53, 0x51, 0x4c, 0x69, 0x74, 0x65, 0x20, 0x66, 0x6f, 0x72, 0x6d, 0x61, 0x74, 0x20, 0x33, 0x00];
|
|
38
|
+
/** A typed failure the reply maps back to a small error code. */
|
|
39
|
+
function fault(code) {
|
|
40
|
+
const error = new Error(code);
|
|
41
|
+
error.code = code;
|
|
42
|
+
throw error;
|
|
43
|
+
}
|
|
44
|
+
function codeOf(error) {
|
|
45
|
+
const code = error?.code;
|
|
46
|
+
return typeof code === 'string' ? code : 'sql';
|
|
47
|
+
}
|
|
48
|
+
/**
|
|
49
|
+
* Fetch with a running decoded-byte cap, cancelled before the cap is exceeded.
|
|
50
|
+
*
|
|
51
|
+
* The optional `signal` lets `load` abort the sibling download when the other
|
|
52
|
+
* one fails, so a failed initialization cannot leave a response draining toward
|
|
53
|
+
* its cap behind the failure. Exported for
|
|
54
|
+
* `tests/snapshot-fetch-bounded.test.ts`, which drives it against a server whose
|
|
55
|
+
* `Content-Length` disagrees with the body; `load` is the only production
|
|
56
|
+
* caller.
|
|
57
|
+
*/
|
|
58
|
+
export async function fetchBounded(url, limit, signal) {
|
|
59
|
+
let response;
|
|
60
|
+
try {
|
|
61
|
+
response = await fetch(url, { credentials: 'same-origin', redirect: 'error', signal });
|
|
62
|
+
}
|
|
63
|
+
catch {
|
|
64
|
+
return fault('fetch');
|
|
65
|
+
}
|
|
66
|
+
if (!response.ok) {
|
|
67
|
+
// Release the connection rather than leaving the body undrained behind the
|
|
68
|
+
// failure the caller is about to see.
|
|
69
|
+
if (response.body !== null)
|
|
70
|
+
await response.body.cancel().catch(() => { });
|
|
71
|
+
fault('fetch');
|
|
72
|
+
}
|
|
73
|
+
const reader = response.body?.getReader();
|
|
74
|
+
if (reader === undefined) {
|
|
75
|
+
const buffer = new Uint8Array(await response.arrayBuffer());
|
|
76
|
+
if (buffer.byteLength > limit)
|
|
77
|
+
fault('integrity');
|
|
78
|
+
return buffer;
|
|
79
|
+
}
|
|
80
|
+
const chunks = [];
|
|
81
|
+
let total = 0;
|
|
82
|
+
for (;;) {
|
|
83
|
+
const { done, value } = await reader.read();
|
|
84
|
+
if (done)
|
|
85
|
+
break;
|
|
86
|
+
if (value === undefined)
|
|
87
|
+
continue;
|
|
88
|
+
total += value.byteLength;
|
|
89
|
+
if (total > limit) {
|
|
90
|
+
await reader.cancel();
|
|
91
|
+
fault('integrity');
|
|
92
|
+
}
|
|
93
|
+
chunks.push(value);
|
|
94
|
+
}
|
|
95
|
+
const bytes = new Uint8Array(total);
|
|
96
|
+
let offset = 0;
|
|
97
|
+
for (const chunk of chunks) {
|
|
98
|
+
bytes.set(chunk, offset);
|
|
99
|
+
offset += chunk.byteLength;
|
|
100
|
+
}
|
|
101
|
+
return bytes;
|
|
102
|
+
}
|
|
103
|
+
async function sha256(bytes) {
|
|
104
|
+
const digest = await crypto.subtle.digest('SHA-256', bytes);
|
|
105
|
+
return [...new Uint8Array(digest)].map((byte) => byte.toString(16).padStart(2, '0')).join('');
|
|
106
|
+
}
|
|
107
|
+
/**
|
|
108
|
+
* The WASM linear memory's capacity, or `null` when it is not reachable.
|
|
109
|
+
*
|
|
110
|
+
* The pinned package types `sqlite3.config.memory` as a `WebAssembly.Memory`
|
|
111
|
+
* (the Emscripten heap, the same object as the untyped `wasm.memory`), so the
|
|
112
|
+
* size is its `buffer.byteLength`: capacity after import, not bytes in use and
|
|
113
|
+
* not the decoded DB size. A build that stops exposing either location yields
|
|
114
|
+
* `null` — the phase field is documented as unknown — rather than a fabricated
|
|
115
|
+
* number, and the `instanceof` keeps a foreign stand-in from being read as one.
|
|
116
|
+
*/
|
|
117
|
+
function wasmMemorySize(sqlite3) {
|
|
118
|
+
const memory = sqlite3.config?.memory ?? sqlite3.wasm.memory;
|
|
119
|
+
return memory instanceof WebAssembly.Memory ? memory.buffer.byteLength : null;
|
|
120
|
+
}
|
|
121
|
+
/**
|
|
122
|
+
* Load the pinned runtime from its same-origin entry.
|
|
123
|
+
*
|
|
124
|
+
* The URL is read from a variable so Vite leaves the import for the browser
|
|
125
|
+
* (`@vite-ignore`): bundling the package inlined its Emscripten glue into this
|
|
126
|
+
* chunk and made the residue scan read the library's strings as ANC findings.
|
|
127
|
+
*/
|
|
128
|
+
async function initSqlite(bytes, wasmUrl) {
|
|
129
|
+
const moduleUrl = __ANC_WASM_MODULE_URL__;
|
|
130
|
+
const module = (await import(__rewriteRelativeImportExtension(/* @vite-ignore */ moduleUrl, true)));
|
|
131
|
+
const init = module.default;
|
|
132
|
+
return init({ wasmBinary: bytes.buffer, locateFile: () => wasmUrl });
|
|
133
|
+
}
|
|
134
|
+
/**
|
|
135
|
+
* Deserialize the snapshot bytes and validate the imported schema.
|
|
136
|
+
*
|
|
137
|
+
* Exported for `tests/snapshot-worker-init.test.ts`, which drives it with a
|
|
138
|
+
* fake `sqlite3` and counts open handles; `load` is the only production caller.
|
|
139
|
+
*
|
|
140
|
+
* The `oo1.DB` handle owns a FREEONCLOSE copy of the bytes — up to
|
|
141
|
+
* `WORKER_LIMITS.maxSnapshotBytes` — and `database()` clears a failed loading
|
|
142
|
+
* promise so a later intent retries here, so every failure after the handle
|
|
143
|
+
* exists closes it before rethrowing. Without the close each retry would hold
|
|
144
|
+
* another handle and another copy; `docs/core-design/build-and-runtime.md`
|
|
145
|
+
* requires the release on failing initialization.
|
|
146
|
+
*/
|
|
147
|
+
export function importSnapshot(sqlite3, databaseBytes) {
|
|
148
|
+
const database = new sqlite3.oo1.DB();
|
|
149
|
+
try {
|
|
150
|
+
const pointer = sqlite3.wasm.allocFromTypedArray(databaseBytes);
|
|
151
|
+
const result = sqlite3.capi.sqlite3_deserialize(database.pointer, 'main', pointer, databaseBytes.byteLength, databaseBytes.byteLength, sqlite3.capi.SQLITE_DESERIALIZE_READONLY | sqlite3.capi.SQLITE_DESERIALIZE_FREEONCLOSE);
|
|
152
|
+
// On failure with FREEONCLOSE, SQLite has already freed the buffer, so only
|
|
153
|
+
// the handle is left for the close below.
|
|
154
|
+
if (result !== 0)
|
|
155
|
+
fault('format');
|
|
156
|
+
database.exec('PRAGMA query_only = ON');
|
|
157
|
+
if (database.selectValue('PRAGMA query_only') !== 1)
|
|
158
|
+
fault('format');
|
|
159
|
+
// A deserialize sizes its page list from the buffer length, so a file whose
|
|
160
|
+
// last page is partial still imports — and `PRAGMA integrity_check` calls it
|
|
161
|
+
// `ok`. The producer's snapshot is exactly its pages; any other byte length
|
|
162
|
+
// is truncated or padded, and the import refuses it. The page size comes
|
|
163
|
+
// from the file's own header (offset 16, big-endian; 1 means 65536) rather
|
|
164
|
+
// than `PRAGMA page_size`, which on the pinned WASM build reports the
|
|
165
|
+
// connection's compiled-in default for a freshly deserialized database.
|
|
166
|
+
// This is the second line: `load` already fails truncated data on the
|
|
167
|
+
// digest before import.
|
|
168
|
+
const headerPageSize = (databaseBytes[16] << 8) | databaseBytes[17];
|
|
169
|
+
const pageSize = headerPageSize === 1 ? 65536 : headerPageSize;
|
|
170
|
+
let pageCount;
|
|
171
|
+
try {
|
|
172
|
+
pageCount = Number(database.selectValue('PRAGMA page_count'));
|
|
173
|
+
}
|
|
174
|
+
catch {
|
|
175
|
+
// A buffer too corrupt to answer `page_count` is refused as a format
|
|
176
|
+
// fault rather than escaping as a raw driver error.
|
|
177
|
+
fault('format');
|
|
178
|
+
}
|
|
179
|
+
if (!Number.isInteger(pageCount) || pageSize * pageCount !== databaseBytes.byteLength)
|
|
180
|
+
fault('format');
|
|
181
|
+
try {
|
|
182
|
+
assertSnapshotRows((sql) => database.selectObjects(sql));
|
|
183
|
+
}
|
|
184
|
+
catch {
|
|
185
|
+
fault('schema');
|
|
186
|
+
}
|
|
187
|
+
return database;
|
|
188
|
+
}
|
|
189
|
+
catch (error) {
|
|
190
|
+
// Best effort: a close error must not replace the failure being diagnosed.
|
|
191
|
+
try {
|
|
192
|
+
database.close();
|
|
193
|
+
}
|
|
194
|
+
catch {
|
|
195
|
+
// The original error is the diagnosis.
|
|
196
|
+
}
|
|
197
|
+
throw error;
|
|
198
|
+
}
|
|
199
|
+
}
|
|
200
|
+
async function load() {
|
|
201
|
+
const snapshotBinding = __ANC_SNAPSHOT_BINDING__;
|
|
202
|
+
const wasmBinding = __ANC_WASM_BINDING__;
|
|
203
|
+
if (snapshotBinding === null || wasmBinding === null)
|
|
204
|
+
fault('not-ready');
|
|
205
|
+
// Neither download depends on the other, and both are on the cold path. One
|
|
206
|
+
// controller bounds the pair: whichever fails first settles the `Promise.all`
|
|
207
|
+
// and aborts the sibling, so a failed load cannot leave a response draining
|
|
208
|
+
// toward its cap behind the failure.
|
|
209
|
+
const controller = new AbortController();
|
|
210
|
+
const fetchStarted = performance.now();
|
|
211
|
+
const [databaseBytes, wasmBytes] = await Promise.all([
|
|
212
|
+
fetchBounded(snapshotBinding.url, WORKER_LIMITS.maxSnapshotBytes, controller.signal),
|
|
213
|
+
fetchBounded(wasmBinding.url, WORKER_LIMITS.maxWasmBytes, controller.signal),
|
|
214
|
+
]).finally(() => controller.abort());
|
|
215
|
+
const fetchMs = performance.now() - fetchStarted;
|
|
216
|
+
const digestStarted = performance.now();
|
|
217
|
+
const [databaseDigest, wasmDigest] = await Promise.all([sha256(databaseBytes), sha256(wasmBytes)]);
|
|
218
|
+
const digestMs = performance.now() - digestStarted;
|
|
219
|
+
if (databaseDigest !== snapshotBinding.digest)
|
|
220
|
+
fault('integrity');
|
|
221
|
+
if (databaseBytes.length < 100)
|
|
222
|
+
fault('header');
|
|
223
|
+
for (const [index, byte] of SQLITE_MAGIC.entries()) {
|
|
224
|
+
if (databaseBytes[index] !== byte)
|
|
225
|
+
fault('header');
|
|
226
|
+
}
|
|
227
|
+
// Bytes 18/19 are the file-format write/read versions: 1 is rollback journal,
|
|
228
|
+
// 2 is WAL, which cannot be deserialized as a standalone snapshot.
|
|
229
|
+
if (databaseBytes[18] !== 1 || databaseBytes[19] !== 1)
|
|
230
|
+
fault('format');
|
|
231
|
+
if (wasmDigest !== wasmBinding.digest)
|
|
232
|
+
fault('integrity');
|
|
233
|
+
let sqlite3;
|
|
234
|
+
const wasmInitStarted = performance.now();
|
|
235
|
+
try {
|
|
236
|
+
sqlite3 = await initSqlite(wasmBytes, wasmBinding.url);
|
|
237
|
+
}
|
|
238
|
+
catch {
|
|
239
|
+
return fault('wasm');
|
|
240
|
+
}
|
|
241
|
+
const wasmInitMs = performance.now() - wasmInitStarted;
|
|
242
|
+
// The schema validation inside `importSnapshot` is part of this phase: goal
|
|
243
|
+
// 0008's startup decomposition is fetch/hash/init/import, and `totalMs` is
|
|
244
|
+
// the whole module-to-ready span that also covers the header checks above.
|
|
245
|
+
const importStarted = performance.now();
|
|
246
|
+
const database = importSnapshot(sqlite3, databaseBytes);
|
|
247
|
+
const importMs = performance.now() - importStarted;
|
|
248
|
+
return {
|
|
249
|
+
db: { select: (sql, params) => database.selectObjects(sql, params ? [...params] : undefined) },
|
|
250
|
+
phases: {
|
|
251
|
+
totalMs: performance.now() - moduleStarted,
|
|
252
|
+
fetchMs,
|
|
253
|
+
digestMs,
|
|
254
|
+
wasmInitMs,
|
|
255
|
+
importMs,
|
|
256
|
+
wasmMemoryBytes: wasmMemorySize(sqlite3),
|
|
257
|
+
},
|
|
258
|
+
};
|
|
259
|
+
}
|
|
260
|
+
let loading;
|
|
261
|
+
/** One shared initialization promise; a failure clears it so intent can retry. */
|
|
262
|
+
function database() {
|
|
263
|
+
loading ??= load().catch((error) => {
|
|
264
|
+
loading = undefined;
|
|
265
|
+
throw error;
|
|
266
|
+
});
|
|
267
|
+
return loading;
|
|
268
|
+
}
|
|
269
|
+
function run(db, message) {
|
|
270
|
+
switch (message.type) {
|
|
271
|
+
case 'preview':
|
|
272
|
+
return { type: 'preview', preview: preview(db, message.slug) };
|
|
273
|
+
case 'outgoing':
|
|
274
|
+
return { type: 'outgoing', ...edgePage(db, 'outgoing', message.slug, message.cursor, requestPageSize(message)) };
|
|
275
|
+
case 'backlinks':
|
|
276
|
+
return { type: 'backlinks', ...edgePage(db, 'backlinks', message.slug, message.cursor, requestPageSize(message)) };
|
|
277
|
+
case 'byTag':
|
|
278
|
+
return { type: 'byTag', page: tagPage(db, message.tagKey, message.cursor, requestPageSize(message)) };
|
|
279
|
+
case 'localGraph':
|
|
280
|
+
return { type: 'localGraph', graph: localGraph(db, message.slug) };
|
|
281
|
+
case 'globalGraph':
|
|
282
|
+
return { type: 'globalGraph', graph: globalGraph(db, message.tagKey ?? undefined) };
|
|
283
|
+
}
|
|
284
|
+
}
|
|
285
|
+
function reply(message) {
|
|
286
|
+
scope.postMessage(message);
|
|
287
|
+
}
|
|
288
|
+
async function handle(value) {
|
|
289
|
+
if (!isSnapshotMessage(value)) {
|
|
290
|
+
const id = value?.id;
|
|
291
|
+
if (typeof id === 'number')
|
|
292
|
+
reply({ id, ok: false, code: 'bad-request' });
|
|
293
|
+
return;
|
|
294
|
+
}
|
|
295
|
+
try {
|
|
296
|
+
const loaded = await database();
|
|
297
|
+
// The measured request gets a wrapping adapter that sums every `select`
|
|
298
|
+
// span; an unmeasured request runs against the same adapter as before, so
|
|
299
|
+
// its reply shape and behavior are unchanged. The wrapper never changes
|
|
300
|
+
// what is executed or returned: `select` delegates straight through.
|
|
301
|
+
let sqlMs = 0;
|
|
302
|
+
const db = value.measure === true
|
|
303
|
+
? {
|
|
304
|
+
select(sql, params) {
|
|
305
|
+
const selectStarted = performance.now();
|
|
306
|
+
try {
|
|
307
|
+
return loaded.db.select(sql, params);
|
|
308
|
+
}
|
|
309
|
+
finally {
|
|
310
|
+
sqlMs += performance.now() - selectStarted;
|
|
311
|
+
}
|
|
312
|
+
},
|
|
313
|
+
}
|
|
314
|
+
: loaded.db;
|
|
315
|
+
// The measurement goal needs the operation's SQL/Worker time separated from
|
|
316
|
+
// main-thread dispatch and rendering time, so a successful reply carries the
|
|
317
|
+
// span around `run()` alone: the shared initialization promise is awaited
|
|
318
|
+
// first so a cold start's download, hashing, WASM init, import, and schema
|
|
319
|
+
// validation can never land in a reply's duration. The span still covers
|
|
320
|
+
// the operation's selection and induced-edge work beside its queries; that
|
|
321
|
+
// is the worker-side half goal 0005 records for goal 0008. A request that
|
|
322
|
+
// asked to measure additionally gets `sqlMs`, the subset of that span spent
|
|
323
|
+
// inside `select` calls, and `phases`, the decomposition of the cold start
|
|
324
|
+
// that preceded it.
|
|
325
|
+
const started = performance.now();
|
|
326
|
+
const result = run(db, value);
|
|
327
|
+
const operationMs = performance.now() - started;
|
|
328
|
+
if (value.measure === true) {
|
|
329
|
+
reply({ id: value.id, ok: true, result, operationMs, sqlMs, phases: loaded.phases });
|
|
330
|
+
}
|
|
331
|
+
else {
|
|
332
|
+
reply({ id: value.id, ok: true, result, operationMs });
|
|
333
|
+
}
|
|
334
|
+
}
|
|
335
|
+
catch (error) {
|
|
336
|
+
reply({ id: value.id, ok: false, code: codeOf(error) });
|
|
337
|
+
}
|
|
338
|
+
}
|
|
339
|
+
scope.addEventListener('message', (event) => {
|
|
340
|
+
void handle(event.data);
|
|
341
|
+
});
|
|
@@ -0,0 +1,153 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Reader-facing tag browsing over the shared lazy snapshot runtime.
|
|
3
|
+
*
|
|
4
|
+
* `byTag` is a named operation with cursor pagination; this is its reader-facing
|
|
5
|
+
* consumer. It is mounted on `/tags/` (a tag chooser) and `/tags/<tag>/` (one
|
|
6
|
+
* fixed tag), and every chrome string arrives as a `data-` attribute the build
|
|
7
|
+
* wrote, so no locale table ships to the browser and no tag is normalized here —
|
|
8
|
+
* the key is sent exactly as the static route spells it.
|
|
9
|
+
*
|
|
10
|
+
* The static `NoteList`/`FacetIndex` above it remains the complete no-JS route;
|
|
11
|
+
* this region is hidden until the script runs and unhides the static list on any
|
|
12
|
+
* failure.
|
|
13
|
+
*/
|
|
14
|
+
import { noteRoute } from "../lib/route-path.js";
|
|
15
|
+
import { classifyTagPage, TAG_PAGE_SIZE } from "../lib/tag-browser-model.js";
|
|
16
|
+
import { partLanguage, TAG_BROWSE_DATASET } from "../lib/translations.js";
|
|
17
|
+
import { requestByTag } from "./snapshot-client.js";
|
|
18
|
+
const root = document.querySelector('#tag-browser');
|
|
19
|
+
if (root)
|
|
20
|
+
install(root);
|
|
21
|
+
function install(root) {
|
|
22
|
+
const mode = root.dataset['mode'] === 'switch' ? 'switch' : 'fixed';
|
|
23
|
+
const pageSize = Number(root.dataset['pageSize']) || TAG_PAGE_SIZE;
|
|
24
|
+
const staticList = document.querySelector('#tag-static-list');
|
|
25
|
+
const chooser = root.querySelector('#tag-browser-select');
|
|
26
|
+
const start = root.querySelector('#tag-browse-start');
|
|
27
|
+
const more = root.querySelector('#tag-browse-more');
|
|
28
|
+
const results = root.querySelector('#tag-browser-results');
|
|
29
|
+
const status = root.querySelector('#tag-browser-status');
|
|
30
|
+
const current = root.querySelector('#tag-browser-current');
|
|
31
|
+
const string = (key) => root.dataset[key] ?? '';
|
|
32
|
+
let generation = 0;
|
|
33
|
+
let tagKey = mode === 'fixed' ? (root.dataset['tagKey'] ?? '') : '';
|
|
34
|
+
let cursor = null;
|
|
35
|
+
root.hidden = false;
|
|
36
|
+
const reset = () => {
|
|
37
|
+
results.replaceChildren();
|
|
38
|
+
if (more !== null)
|
|
39
|
+
more.hidden = true;
|
|
40
|
+
current.hidden = true;
|
|
41
|
+
status.textContent = '';
|
|
42
|
+
};
|
|
43
|
+
async function load(after, forGeneration) {
|
|
44
|
+
if (tagKey === '') {
|
|
45
|
+
reset();
|
|
46
|
+
return;
|
|
47
|
+
}
|
|
48
|
+
// One continuation may be in flight at a time. `#tag-browse-more` is the only
|
|
49
|
+
// way to dispatch one, and a second click before the first reply lands would
|
|
50
|
+
// send the same cursor again — both replies pass the generation guard below
|
|
51
|
+
// and append the same page. Marked busy synchronously, before the first
|
|
52
|
+
// await, so a double click cannot get two handlers in; the cursor advances
|
|
53
|
+
// only when a reply lands, and the `finally` clears the mark for the next
|
|
54
|
+
// explicit continuation.
|
|
55
|
+
//
|
|
56
|
+
// `aria-disabled` plus the click handler's own check, **not** `disabled`.
|
|
57
|
+
// Disabling the button that holds focus drops focus to `<body>`, so a
|
|
58
|
+
// keyboard reader who pressed Enter on "Load more" was thrown back to the
|
|
59
|
+
// top of the tab order on every page. `tests/tag-browser.test.ts` asserts
|
|
60
|
+
// `document.activeElement` across a continuation.
|
|
61
|
+
if (more !== null)
|
|
62
|
+
more.setAttribute('aria-disabled', 'true');
|
|
63
|
+
status.textContent = string(TAG_BROWSE_DATASET.loading);
|
|
64
|
+
let page;
|
|
65
|
+
try {
|
|
66
|
+
page = (await requestByTag(tagKey, after, pageSize)).page;
|
|
67
|
+
}
|
|
68
|
+
catch {
|
|
69
|
+
if (forGeneration !== generation)
|
|
70
|
+
return;
|
|
71
|
+
// The static list is never removed before page 1 renders, so a failure
|
|
72
|
+
// leaves the complete no-JS list on screen.
|
|
73
|
+
if (staticList !== null)
|
|
74
|
+
staticList.hidden = false;
|
|
75
|
+
status.textContent = string(TAG_BROWSE_DATASET.failed);
|
|
76
|
+
return;
|
|
77
|
+
}
|
|
78
|
+
finally {
|
|
79
|
+
if (more !== null)
|
|
80
|
+
more.removeAttribute('aria-disabled');
|
|
81
|
+
}
|
|
82
|
+
if (forGeneration !== generation)
|
|
83
|
+
return; // a stale tag's reply cannot replace this one
|
|
84
|
+
const state = classifyTagPage(page);
|
|
85
|
+
if (state.kind === 'unknown') {
|
|
86
|
+
if (more !== null)
|
|
87
|
+
more.hidden = true;
|
|
88
|
+
status.textContent = string(TAG_BROWSE_DATASET.unknown);
|
|
89
|
+
return;
|
|
90
|
+
}
|
|
91
|
+
if (state.kind === 'empty') {
|
|
92
|
+
if (more !== null)
|
|
93
|
+
more.hidden = true;
|
|
94
|
+
status.textContent = string(TAG_BROWSE_DATASET.empty);
|
|
95
|
+
return;
|
|
96
|
+
}
|
|
97
|
+
// `partLanguage` compares case-insensitively, so the page's raw tag is fine.
|
|
98
|
+
const language = document.documentElement.lang || 'en';
|
|
99
|
+
let firstAppended;
|
|
100
|
+
for (const note of state.notes) {
|
|
101
|
+
const item = document.createElement('li');
|
|
102
|
+
const link = document.createElement('a');
|
|
103
|
+
link.href = noteRoute(note.slug);
|
|
104
|
+
link.textContent = note.title;
|
|
105
|
+
// The static tag list marks a foreign title through `partLanguage`;
|
|
106
|
+
// calling it here keeps the enhanced list from drifting from it.
|
|
107
|
+
const lang = partLanguage(note.language, language);
|
|
108
|
+
if (lang !== undefined)
|
|
109
|
+
link.lang = lang;
|
|
110
|
+
item.append(link);
|
|
111
|
+
results.append(item);
|
|
112
|
+
firstAppended ??= link;
|
|
113
|
+
}
|
|
114
|
+
if (page.known) {
|
|
115
|
+
current.textContent = page.tag.label;
|
|
116
|
+
current.hidden = false;
|
|
117
|
+
}
|
|
118
|
+
cursor = state.nextCursor;
|
|
119
|
+
if (cursor === null) {
|
|
120
|
+
// Hiding the focused button would drop focus to `<body>` just as above,
|
|
121
|
+
// so a reader who reached the last page from "Load more" continues at
|
|
122
|
+
// the first result that page added.
|
|
123
|
+
if (more !== null && document.activeElement === more)
|
|
124
|
+
firstAppended?.focus();
|
|
125
|
+
if (more !== null)
|
|
126
|
+
more.hidden = true;
|
|
127
|
+
status.textContent = string(TAG_BROWSE_DATASET.exhausted);
|
|
128
|
+
}
|
|
129
|
+
else {
|
|
130
|
+
if (more !== null)
|
|
131
|
+
more.hidden = false;
|
|
132
|
+
status.textContent = '';
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
/** Explicit intent: choosing a tag or pressing Start. */
|
|
136
|
+
function select(nextKey) {
|
|
137
|
+
const forGeneration = ++generation; // invalidates any in-flight reply
|
|
138
|
+
tagKey = nextKey;
|
|
139
|
+
cursor = null;
|
|
140
|
+
reset();
|
|
141
|
+
if (staticList !== null)
|
|
142
|
+
staticList.hidden = true; // only after a selection is made
|
|
143
|
+
void load(null, forGeneration);
|
|
144
|
+
}
|
|
145
|
+
start?.addEventListener('click', () => select(mode === 'fixed' ? (root.dataset['tagKey'] ?? '') : ''));
|
|
146
|
+
chooser?.addEventListener('change', () => select(chooser.value));
|
|
147
|
+
more?.addEventListener('click', () => {
|
|
148
|
+
if (more.getAttribute('aria-disabled') === 'true')
|
|
149
|
+
return; // a continuation is in flight
|
|
150
|
+
const forGeneration = generation;
|
|
151
|
+
void load(cursor, forGeneration);
|
|
152
|
+
});
|
|
153
|
+
}
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Applies the stored display preferences before first paint.
|
|
3
|
+
*
|
|
4
|
+
* Kept a classic script with no imports or exports so `Layout.astro` can load
|
|
5
|
+
* it render-blocking; see the comment there for why that matters. Ships to
|
|
6
|
+
* every visitor in the critical path, so it stays short.
|
|
7
|
+
*/
|
|
8
|
+
(function () {
|
|
9
|
+
var root = document.documentElement;
|
|
10
|
+
|
|
11
|
+
/** Blocked storage throws on access, not on read. */
|
|
12
|
+
function stored(key) {
|
|
13
|
+
try {
|
|
14
|
+
return localStorage.getItem(key);
|
|
15
|
+
} catch (error) {
|
|
16
|
+
return null;
|
|
17
|
+
}
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
// No attribute is the default: CSS then follows `prefers-color-scheme`
|
|
21
|
+
// through `light-dark()`. These express only an explicit override.
|
|
22
|
+
//
|
|
23
|
+
// The `publish:` prefix is duplicated from `preferences.ts`, which this file
|
|
24
|
+
// cannot import; `tests/design-tokens.test.ts` asserts the two agree. It names
|
|
25
|
+
// what wrote the value rather than this project, so a stranger's visitor does
|
|
26
|
+
// not carry the tool's name in their own browser storage.
|
|
27
|
+
var theme = stored('publish:theme');
|
|
28
|
+
if (theme === 'light' || theme === 'dark') root.dataset.theme = theme;
|
|
29
|
+
|
|
30
|
+
if (stored('publish:reader') === 'on') root.dataset.reader = 'on';
|
|
31
|
+
|
|
32
|
+
// Reveals the controls that only work with scripting; without this they stay
|
|
33
|
+
// hidden rather than rendering as buttons that do nothing.
|
|
34
|
+
root.dataset.js = 'on';
|
|
35
|
+
})();
|