@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,364 @@
1
+ /**
2
+ * `anc preview` — serve a built site on loopback.
3
+ *
4
+ * A user who has run `build` has a directory of HTML and no way to look at it.
5
+ * Opening `dist/index.html` with `file://` does not work: every page links
6
+ * `/notes/x/`, a root-relative path that under `file://` resolves to the
7
+ * filesystem root, and Pagefind fetches its index over HTTP. A server is the
8
+ * only way to read one's own site before publishing it.
9
+ *
10
+ * ## Why a subcommand at all, when `astro preview` exists
11
+ *
12
+ * Because it is not there. `package.json` has an `astro preview` script and that
13
+ * serves *this repository's* build; a stranger has this package in their
14
+ * `node_modules` and their own `dist/`. Measured, with the real tarball from
15
+ * `scripts/compile-package.ts` installed three ways:
16
+ *
17
+ * | Install | `node_modules/.bin/` | `astro preview` |
18
+ * | --- | --- | --- |
19
+ * | `npm install <tarball>` | `astro` present, hoisted | works |
20
+ * | `pnpm add <tarball>` | `anc`, `esbuild`, `vite`, `yaml` | **`node_modules/astro` does not exist** |
21
+ * | `npx <tarball> build` | nothing installed locally | nothing to run |
22
+ *
23
+ * So "run `astro preview`" is advice that holds for one of three install paths,
24
+ * and the one it holds for holds by accident — npm's flat hoisting, not a
25
+ * promise this package makes. The user always has the binary they just typed;
26
+ * that is the only entry point this tool can rely on.
27
+ *
28
+ * ## Astro's `preview()` rather than a hand-written `node:http`
29
+ *
30
+ * `import('astro')` already resolves from the installed package — measured from
31
+ * inside `node_modules/anc` under the pnpm layout, where the
32
+ * *binary* is absent but the *module* resolves fine, because it is this
33
+ * package's own dependency. It is an existing dependency doing a job it already
34
+ * does, which beats forty lines of MIME table and traversal guard that would
35
+ * duplicate what `tests/rendered-page.test.ts` already has for its own purposes.
36
+ *
37
+ * What it gives, all executed against a `dist/` outside the package:
38
+ *
39
+ * - `outDir` is served and `root` is not. `package.json`, `astro.config.mjs`,
40
+ * `tsconfig.json`, and `public/_headers` all 404 while sitting under the
41
+ * `root` that was passed in. So pointing the server at the user's build cannot
42
+ * serve this package's files.
43
+ * - Traversal is refused: `/../secret.md`, `/%2e%2e/secret.md`, `/....//secret.md`,
44
+ * `/..\secret.md`, and `/../../<sibling>/secret.md` are 404 with a canary file
45
+ * present at each target, and `/..%2fsecret.md` is a 500 — Node's
46
+ * `fileURLToPath` throws on the encoded separator inside Astro's preview
47
+ * middleware before any file is read. No path escapes in any of the six, but
48
+ * the 500's body carries the throwing frame, which is a path inside this
49
+ * package's `node_modules`. That reaches an HTTP client rather than a stream,
50
+ * so this repository's disclosure rule does not govern it, and the server is
51
+ * bound to loopback with Vite refusing a foreign `Host:` — so the audience for
52
+ * it is a process already running as the user. Recorded rather than fixed:
53
+ * suppressing it means owning Astro's error middleware.
54
+ * - `trailingSlash: 'always'` is honoured — `/notes/x` 404s, `/notes/x/` serves.
55
+ * - A `Host:` header of `evil.example.com` is refused with 403 while
56
+ * `localhost`, `127.0.0.1`, and any `*.localhost` are served. That is Vite's
57
+ * DNS-rebinding guard, and it is a real protection a hand-written server would
58
+ * not have had unless someone remembered to write it.
59
+ * - A taken port moves to the next free one rather than throwing.
60
+ * - It does **not** daemonize when called as a function. The `astro preview` CLI
61
+ * does — it printed `Preview server already running (pid 26248)` and outlived
62
+ * its parent — but the API's `stop()` closes the port in this process, so the
63
+ * subcommand runs in the foreground and ends with Ctrl-C, which is what a
64
+ * preview should do.
65
+ *
66
+ * One thing it does not give, recorded so nobody reads more into a passing
67
+ * preview than it says: **no `_headers`.** The built site ships a CSP and five
68
+ * other security headers in that file, Cloudflare Pages applies them, and this
69
+ * server sends none of them. So a preview cannot prove the deployed site's CSP
70
+ * admits its own scripts — `tests/rendered-page.test.ts` covers that by serving
71
+ * `dist/` itself with `public/_headers` parsed and applied, which is why that
72
+ * file has its own server and this one does not reuse it. A preview shows the
73
+ * content; the gate proves the headers.
74
+ *
75
+ * ## The refusal, which is the part that is not convenience
76
+ *
77
+ * `preview()` serves whatever directory it is given, and a directory of notes is
78
+ * a directory. Measured, pointed at one holding `.env` and `private/salary.md`:
79
+ *
80
+ * /.env -> 200 SECRET=…
81
+ * /private/salary.md -> 200 the note's contents
82
+ * /index.html -> 404
83
+ *
84
+ * Note the third line, which is the sharp part: the directory is *obviously* not
85
+ * a build — it has no index — and every file in it is served anyway. So `--dist .`
86
+ * in a notes repository publishes the notes **and** the dotfiles on a port, which
87
+ * for a tool whose entire premise is that excluded notes never ship is the worst
88
+ * thing it could do.
89
+ *
90
+ * So the directory must carry exactly one regular `data/site.<sha256>.sqlite`,
91
+ * matching SQLite magic, application ID, the reader's exact version, and the
92
+ * digest in its own filename.
93
+ *
94
+ * **That file and not `index.html`, because the marker has to be something only
95
+ * this tool writes.** An `index.html` is the most ordinary file on earth — a
96
+ * user may plausibly have one lying around in a notes directory, a downloaded
97
+ * page, or any other static site's output, and every one of those would satisfy
98
+ * a guard written against it while being no build of ours. A digest-named
99
+ * snapshot with ANC's own `application_id` is this tool's own projection of the
100
+ * corpus and nothing else produces it. The guard is only as good as the marker's
101
+ * exclusivity.
102
+ *
103
+ * **A matching filename is not enough; the bytes are checked.** A hand-made
104
+ * `data/site.<64hex>.sqlite` still has to carry the SQLite header, the format
105
+ * constants, and a SHA-256 equal to the digest in its name. That refuses the
106
+ * ordinary accident (`--dist .`, a typo, a stale path, a truncated copy) without
107
+ * turning recognition into a full correctness proof.
108
+ *
109
+ * It is a marker of "this tool built this", not a validity check — a
110
+ * schema-valid snapshot with wrong content still previews. What it excludes is
111
+ * the *category* error of aiming the server at a directory that was never a
112
+ * build output.
113
+ *
114
+ * **Checked before anything binds.** `resolveArtifactDirectory` throws and
115
+ * `startPreview` is the only thing that opens a socket; the binary calls them in
116
+ * that order. A server that starts and then refuses has already opened the port.
117
+ */
118
+ var __rewriteRelativeImportExtension = (this && this.__rewriteRelativeImportExtension) || function (path, preserveJsx) {
119
+ if (typeof path === "string" && /^\.\.?\//.test(path)) {
120
+ return path.replace(/\.(tsx)$|((?:\.d)?)((?:\.[^./]+?)?)\.([cm]?)ts$/i, function (m, tsx, d, ext, cm) {
121
+ return tsx ? preserveJsx ? ".jsx" : ".js" : d && (!ext || !cm) ? m : (d + ext + "." + cm.toLowerCase() + "js");
122
+ });
123
+ }
124
+ return path;
125
+ };
126
+ import { createHash } from 'node:crypto';
127
+ import { existsSync, readFileSync, readdirSync, statSync } from 'node:fs';
128
+ import { resolve } from 'node:path';
129
+ import { DatabaseSync } from "../src/lib/sqlite.js";
130
+ import { SNAPSHOT_APPLICATION_ID, SNAPSHOT_DIRECTORY, SNAPSHOT_FILE_PATTERN, SNAPSHOT_USER_VERSION, } from "../src/lib/snapshot.js";
131
+ import { BuildFailure } from "./write-report.js";
132
+ import { declaresWal, journalSidecar } from "./snapshot-rows.js";
133
+ /** The SQLite file header every valid snapshot starts with. */
134
+ const SQLITE_MAGIC = Buffer.from('SQLite format 3\0', 'latin1');
135
+ /**
136
+ * Astro's default preview port, and deliberately the same number.
137
+ *
138
+ * A user who has previewed an Astro site knows this port; a user who has not
139
+ * reads it from the line the command prints. Choosing a different one would buy
140
+ * nothing and cost the recognition.
141
+ */
142
+ export const DEFAULT_PREVIEW_PORT = 4321;
143
+ /**
144
+ * Find and validate the one snapshot in a built output.
145
+ *
146
+ * @throws {BuildFailure} when the directory has no snapshot, more than one, a
147
+ * member whose digest does not match its bytes, or a file that is not ANC's
148
+ * SQLite format.
149
+ */
150
+ function resolveSnapshot(directory) {
151
+ const dataDirectory = resolve(directory, SNAPSHOT_DIRECTORY);
152
+ let candidates = [];
153
+ try {
154
+ candidates = existsSync(dataDirectory)
155
+ ? readdirSync(dataDirectory).filter((name) => SNAPSHOT_FILE_PATTERN.test(name))
156
+ : [];
157
+ }
158
+ catch {
159
+ candidates = [];
160
+ }
161
+ if (candidates.length !== 1) {
162
+ throw new BuildFailure('preview-directory-not-an-artifact', 'not a built site: the directory named by --dist does not carry exactly one ' +
163
+ 'data/site.<sha256>.sqlite snapshot, so it was not produced by this tool. ' +
164
+ 'Refusing to serve it — a directory of notes served on a port publishes every ' +
165
+ 'file in it. Name the build output directory instead; the default is `dist`.', `${dataDirectory}: ${candidates.length} snapshot candidate(s)`);
166
+ }
167
+ const name = candidates[0];
168
+ const path = resolve(dataDirectory, name);
169
+ if (!statSync(path).isFile()) {
170
+ throw new BuildFailure('preview-directory-not-an-artifact', 'not a built site: the snapshot candidate is not a regular file.', path);
171
+ }
172
+ const match = SNAPSHOT_FILE_PATTERN.exec(name);
173
+ const expected = match[1];
174
+ const bytes = readFileSync(path);
175
+ if (bytes.length < 100 || !bytes.subarray(0, SQLITE_MAGIC.length).equals(SQLITE_MAGIC)) {
176
+ throw new BuildFailure('preview-snapshot-invalid', 'the preview snapshot is not a SQLite database.', `${path}: missing SQLite header`);
177
+ }
178
+ if (createHash('sha256').update(bytes).digest('hex') !== expected) {
179
+ throw new BuildFailure('preview-snapshot-digest', 'the preview snapshot bytes do not match the digest in their filename.', path);
180
+ }
181
+ // Opening is not free of side effects when SQLite sees journal work to do:
182
+ // measured, a `-wal` sibling makes the read-only open recover and write a
183
+ // `-shm` file into the directory being served. A built output carries none of
184
+ // these, so a WAL header or a sidecar is a wrong candidate and is refused
185
+ // before the driver touches it.
186
+ if (declaresWal(bytes)) {
187
+ throw new BuildFailure('preview-snapshot-format', 'the preview snapshot is not a snapshot this reader accepts.', `${path}: WAL format`);
188
+ }
189
+ const sidecar = journalSidecar(path);
190
+ if (sidecar !== undefined) {
191
+ throw new BuildFailure('preview-snapshot-format', 'the preview snapshot is not a snapshot this reader accepts.', `${path}: journal sidecar ${sidecar}`);
192
+ }
193
+ let database;
194
+ try {
195
+ database = new DatabaseSync(path, { readOnly: true });
196
+ }
197
+ catch (error) {
198
+ throw new BuildFailure('preview-snapshot-unreadable', 'the preview snapshot could not be opened.', `${path}: ${error instanceof Error ? error.message : String(error)}`);
199
+ }
200
+ try {
201
+ const applicationId = database.prepare('PRAGMA application_id').get()
202
+ .application_id;
203
+ const userVersion = database.prepare('PRAGMA user_version').get()
204
+ .user_version;
205
+ if (applicationId !== SNAPSHOT_APPLICATION_ID || userVersion !== SNAPSHOT_USER_VERSION) {
206
+ throw new BuildFailure('preview-snapshot-format', 'the preview snapshot is not a snapshot this reader accepts.', `${path}: application_id=${applicationId}, user_version=${userVersion}`);
207
+ }
208
+ }
209
+ catch (error) {
210
+ // `node:sqlite` opens a file whose header is valid and whose pages are not
211
+ // lazily, so a corrupt snapshot fails here rather than at the constructor.
212
+ // A `BuildFailure` is the format refusal above and must pass through.
213
+ if (error instanceof BuildFailure)
214
+ throw error;
215
+ throw new BuildFailure('preview-snapshot-unreadable', 'the preview snapshot could not be read.', `${path}: ${error instanceof Error ? error.message : String(error)}`);
216
+ }
217
+ finally {
218
+ database.close();
219
+ }
220
+ return path;
221
+ }
222
+ /**
223
+ * Check a directory is one this tool built, and return its absolute path.
224
+ *
225
+ * Exported for the gates, which need to assert the refusal without standing up
226
+ * a server. The path is returned rather than the caller re-resolving it, so the
227
+ * thing checked and the thing served cannot drift apart.
228
+ */
229
+ export function resolveArtifactDirectory(directory, from) {
230
+ const resolved = resolve(from, directory);
231
+ if (!existsSync(resolved)) {
232
+ throw new BuildFailure('preview-directory-not-found', 'preview directory not found: the directory named by --dist does not exist. ' +
233
+ 'Run the build first — its default output directory is `dist`.', resolved);
234
+ }
235
+ resolveSnapshot(resolved);
236
+ if (!existsSync(resolve(resolved, 'index.html'))) {
237
+ // The path is not in the message. A user who typed `--dist clients/acme` is
238
+ // looking at the command they typed, and the stream this reaches is one a
239
+ // workflow log inherits — the same rule `bin/anc.mjs`
240
+ // applies to every argv token it declines to echo.
241
+ throw new BuildFailure('preview-directory-not-an-artifact', 'not a built site: the directory named by --dist has no entry page and no ' +
242
+ 'data/site.<sha256>.sqlite snapshot, so it was not produced by this tool. ' +
243
+ 'Refusing to serve it. Name the build output directory instead; the default is `dist`.', resolved);
244
+ }
245
+ return resolved;
246
+ }
247
+ /**
248
+ * Serve a built site until the process is interrupted.
249
+ *
250
+ * Resolves when the server is listening, and the returned handle is what a gate
251
+ * uses to make requests and shut down. The binary never calls `stop()` — it
252
+ * hands the process to the server and the user ends it with Ctrl-C.
253
+ *
254
+ * `host` is `127.0.0.1` and is not configurable. Every other choice publishes a
255
+ * user's unpublished-by-default site to their network, and the flag that would
256
+ * do it is a flag someone eventually passes. Astro's own default binds
257
+ * `localhost`, which on this host resolves to `::1` — measured — and a literal
258
+ * `127.0.0.1` is the same protection without depending on what a name resolves
259
+ * to.
260
+ */
261
+ export async function startPreview(artifactDirectory, port, packageRoot) {
262
+ // Dynamically imported, matching how `bin/anc.mjs` loads
263
+ // every build step: a `preview` run must not pay for Astro's module graph
264
+ // being resolved when the user typed `build`, and vice versa.
265
+ //
266
+ // **Through `import.meta.resolve` rather than as a bare specifier**, and the
267
+ // reason is measured rather than stylistic. Astro's own Vite config aliases
268
+ // `/^astro$/` to `astro/types/public/index.js` — `create-vite.js:216-220`,
269
+ // read at astro 7.1.6 — so that `import { Type } from 'astro'` works in user
270
+ // code. `vitest.config.ts` reuses that exact config through `getViteConfig`,
271
+ // so under the test suite a bare `import('astro')` resolves to the *types*
272
+ // module: measured, `Object.keys()` is empty and `preview` is `undefined`.
273
+ // The shipped binary is unaffected, which is the trap — the module works in
274
+ // production and cannot be tested, which is how a gate ends up asserting
275
+ // something other than what ships.
276
+ //
277
+ // `import.meta.resolve` is Node's own resolver and no bundler rewrites its
278
+ // result: measured under vitest, it returns the real `astro/dist/index.js`
279
+ // and `preview` is a function. `tests/packaging.test.ts:549` already reaches
280
+ // an Astro internal the same way for the same class of reason.
281
+ const { preview } = await import(__rewriteRelativeImportExtension(/* @vite-ignore */ import.meta.resolve('astro'), true));
282
+ const server = await preview({
283
+ // `root` is where `astro.config.mjs` lives — this package, never the user's
284
+ // directory, for the reason `bin/anc.mjs` documents at
285
+ // length for `build`. It contributes only the config; nothing under it is
286
+ // served, which is measured rather than assumed.
287
+ root: packageRoot,
288
+ outDir: artifactDirectory,
289
+ server: { port, host: '127.0.0.1' },
290
+ // Astro's own listening line names a port that may not be the one it got,
291
+ // and prints it before this function can compare. Silenced, and the caller
292
+ // prints the real one from `server.port`.
293
+ logLevel: 'silent',
294
+ vite: { logLevel: 'silent' },
295
+ // **Both loggers, because they are two loggers.** `logLevel` reaches
296
+ // Astro's; the preview server is a Vite server and Vite logs through its
297
+ // own, which `httpServerStart` uses for the port-in-use line. Measured with
298
+ // only the Astro one set: `anc preview --port 47411` on a
299
+ // held port printed `Port 47411 is in use, trying another one...` to stdout
300
+ // ahead of this module's line — and that number is an argv token, which is
301
+ // the class `parsePreviewArguments` refuses to echo forty lines below, on
302
+ // the same stream a workflow log inherits. With this line the identical run
303
+ // prints only the two lines the binary composes.
304
+ });
305
+ return { port: server.port, stop: () => server.stop() };
306
+ }
307
+ /**
308
+ * Parse `preview`'s options.
309
+ *
310
+ * Deliberately its own parser rather than an extension of `build`'s: the two
311
+ * share no flags, and a combined table would accept `--content` for a preview
312
+ * and `--dist` for a build. Unknown flags are refused for the reason
313
+ * `parseArguments` gives in `bin/anc.mjs` — a mistyped flag
314
+ * that is silently ignored produces a plausible result the user did not ask for
315
+ * — and, as there, the rejected token is never echoed.
316
+ */
317
+ export function parsePreviewArguments(argv) {
318
+ let dist = 'dist';
319
+ let port = DEFAULT_PREVIEW_PORT;
320
+ for (let index = 0; index < argv.length; index += 1) {
321
+ const argument = argv[index];
322
+ const key = argument === '--dist' ? 'dist' : argument === '--port' ? 'port' : undefined;
323
+ if (key === undefined) {
324
+ throw new BuildFailure('unknown-option', 'unrecognised option for preview', argument);
325
+ }
326
+ const value = argv[index + 1];
327
+ if (value === undefined || value.startsWith('--')) {
328
+ // `argument` is one of this file's own two literals here, so printing it
329
+ // is printing a string this source declares.
330
+ throw new BuildFailure('missing-value', `${argument} needs a value`);
331
+ }
332
+ index += 1;
333
+ if (key === 'dist') {
334
+ dist = value;
335
+ continue;
336
+ }
337
+ // **The decimal spelling is required before any coercion.** `parseInt` was
338
+ // rejected first because it reads `4321abc` as 4321 — and `Number` alone has
339
+ // the same defect in other spellings, measured: `''`, `' '`, and `'\n'` all
340
+ // become 0, `'0x10'` becomes 16, `'1e3'` becomes 1000, and `' 4321 '` becomes
341
+ // 4321. The empty string is the one that is not a curiosity: `--port "$PORT"`
342
+ // with `PORT` unset reaches here as `''`, survives the `startsWith('--')`
343
+ // guard above, and would bind an ephemeral port while the user believes they
344
+ // named one — a silently wrong result presented as success, which is the
345
+ // failure class this whole parser exists to prevent.
346
+ //
347
+ // So the shape is checked as text first, and only a string of digits is
348
+ // coerced. `Number.isInteger` then cannot fail, and is kept because a
349
+ // 20-digit input is integral text and not a port.
350
+ if (!/^\d+$/.test(value)) {
351
+ // The value is not echoed. A port is not itself sensitive, but this is the
352
+ // same stream, and the rule that a printed token must be one of this
353
+ // tool's own spellings does not admit exceptions for values that look
354
+ // harmless — `--port` is followed by whatever the shell expanded.
355
+ throw new BuildFailure('invalid-port', '--port must be a whole number between 0 and 65535', value);
356
+ }
357
+ const parsed = Number(value);
358
+ if (!Number.isInteger(parsed) || parsed > 65535) {
359
+ throw new BuildFailure('invalid-port', '--port must be a whole number between 0 and 65535', value);
360
+ }
361
+ port = parsed;
362
+ }
363
+ return { dist, port };
364
+ }
@@ -0,0 +1,157 @@
1
+ /**
2
+ * A committed review of the exact public note set used by release builds.
3
+ *
4
+ * The ledger contains public slugs, never source paths or withheld names. Exact
5
+ * equality is deliberate: a note that is published, excluded, then re-included
6
+ * must become an addition again instead of inheriting stale authorization.
7
+ */
8
+ import { spawnSync } from 'node:child_process';
9
+ import { lstatSync, readFileSync, writeFileSync } from 'node:fs';
10
+ import { join } from 'node:path';
11
+ import { compareSlugs, isNoteSlug } from "../src/lib/route-path.js";
12
+ import { BuildFailure } from "./write-report.js";
13
+ export const PUBLISH_SET_REVIEW_FILE = '.publish-set.json';
14
+ const REVIEW_VERSION = 1;
15
+ const MAX_REVIEW_BYTES = 1_048_576;
16
+ function canonicalSlugs(slugs) {
17
+ const unique = [...new Set(slugs)];
18
+ for (const slug of unique) {
19
+ if (!isNoteSlug(slug)) {
20
+ throw new BuildFailure('publish-set-review-invalid', 'publish-set review is invalid; run anc review again', 'computed publish set contains an invalid slug: ' + JSON.stringify(slug));
21
+ }
22
+ }
23
+ // Canonical slug order (code points), the same order the snapshot uses.
24
+ return unique.sort(compareSlugs);
25
+ }
26
+ function invalidReview(detail) {
27
+ throw new BuildFailure('publish-set-review-invalid', 'release blocked: .publish-set.json is invalid; run anc review again', detail);
28
+ }
29
+ function parseReview(text) {
30
+ if (Buffer.byteLength(text, 'utf8') > MAX_REVIEW_BYTES)
31
+ invalidReview('publish-set review exceeds 1 MiB');
32
+ let value;
33
+ try {
34
+ value = JSON.parse(text);
35
+ }
36
+ catch (error) {
37
+ invalidReview('publish-set review is not JSON: ' + String(error));
38
+ }
39
+ if (value === null || typeof value !== 'object' || Array.isArray(value)) {
40
+ invalidReview('publish-set review must be an object');
41
+ }
42
+ const table = value;
43
+ const keys = Object.keys(table).sort();
44
+ if (keys.length !== 2 || keys[0] !== 'slugs' || keys[1] !== 'version') {
45
+ invalidReview('publish-set review must contain exactly version and slugs');
46
+ }
47
+ if (table['version'] !== REVIEW_VERSION)
48
+ invalidReview('publish-set review has an unsupported version');
49
+ if (!Array.isArray(table['slugs']))
50
+ invalidReview('publish-set review slugs must be an array');
51
+ const slugs = table['slugs'];
52
+ if (!slugs.every((slug) => typeof slug === 'string' && isNoteSlug(slug))) {
53
+ invalidReview('publish-set review contains an invalid slug');
54
+ }
55
+ const canonical = canonicalSlugs(slugs);
56
+ if (canonical.length !== slugs.length || canonical.some((slug, index) => slug !== slugs[index])) {
57
+ invalidReview('publish-set review slugs must be sorted and unique');
58
+ }
59
+ return { version: REVIEW_VERSION, slugs };
60
+ }
61
+ function git(directory, args) {
62
+ return spawnSync('git', args, { cwd: directory, encoding: 'utf8', windowsHide: true });
63
+ }
64
+ function gitDetail(command, result) {
65
+ return [
66
+ command,
67
+ 'status=' + String(result.status),
68
+ result.error?.stack ?? '',
69
+ result.stdout,
70
+ result.stderr,
71
+ ].filter(Boolean).join('\n');
72
+ }
73
+ function assertGitRepository(directory) {
74
+ const result = git(directory, ['rev-parse', '--is-inside-work-tree']);
75
+ if (result.status !== 0 || result.stdout.trim() !== 'true') {
76
+ throw new BuildFailure('publish-set-review-needs-git', 'publish-set review requires a git repository so approval can be committed', gitDetail('git rev-parse --is-inside-work-tree', result));
77
+ }
78
+ }
79
+ function assertReviewNotIgnored(directory) {
80
+ const result = git(directory, ['check-ignore', '--quiet', '--', PUBLISH_SET_REVIEW_FILE]);
81
+ if (result.status === 0) {
82
+ throw new BuildFailure('publish-set-review-ignored', 'publish-set review is ignored by git; unignore .publish-set.json before reviewing', gitDetail('git check-ignore', result));
83
+ }
84
+ if (result.status !== 1) {
85
+ throw new BuildFailure('publish-set-review-needs-git', 'publish-set review could not verify git ignore rules', gitDetail('git check-ignore', result));
86
+ }
87
+ }
88
+ function reviewFileIsRegular(directory) {
89
+ try {
90
+ return lstatSync(join(directory, PUBLISH_SET_REVIEW_FILE)).isFile();
91
+ }
92
+ catch {
93
+ return false;
94
+ }
95
+ }
96
+ function assertCommitted(directory) {
97
+ const tracked = git(directory, ['ls-files', '--error-unmatch', '--', PUBLISH_SET_REVIEW_FILE]);
98
+ const unstaged = git(directory, ['diff', '--quiet', '--', PUBLISH_SET_REVIEW_FILE]);
99
+ const staged = git(directory, ['diff', '--cached', '--quiet', '--', PUBLISH_SET_REVIEW_FILE]);
100
+ if (tracked.status !== 0 || unstaged.status !== 0 || staged.status !== 0) {
101
+ throw new BuildFailure('publish-set-review-uncommitted', 'release blocked: .publish-set.json must be committed and unchanged', [
102
+ gitDetail('git ls-files', tracked),
103
+ gitDetail('git diff', unstaged),
104
+ gitDetail('git diff --cached', staged),
105
+ ].join('\n'));
106
+ }
107
+ }
108
+ /** Write the current candidate set. A release still refuses it until committed. */
109
+ export function writePublishSetReview(directory, slugs) {
110
+ assertGitRepository(directory);
111
+ assertReviewNotIgnored(directory);
112
+ const path = join(directory, PUBLISH_SET_REVIEW_FILE);
113
+ try {
114
+ const state = lstatSync(path);
115
+ if (!state.isFile())
116
+ invalidReview('publish-set review path is not a regular file');
117
+ }
118
+ catch (error) {
119
+ if (error.code !== 'ENOENT')
120
+ throw error;
121
+ }
122
+ const review = { version: REVIEW_VERSION, slugs: canonicalSlugs(slugs) };
123
+ writeFileSync(join(directory, PUBLISH_SET_REVIEW_FILE), JSON.stringify(review, null, 2) + '\n', 'utf8');
124
+ return review.slugs.length;
125
+ }
126
+ /** Require one committed ledger exactly equal to the computed public note set. */
127
+ export function assertPublishSetReviewed(directory, slugs) {
128
+ assertGitRepository(directory);
129
+ if (!reviewFileIsRegular(directory)) {
130
+ try {
131
+ lstatSync(join(directory, PUBLISH_SET_REVIEW_FILE));
132
+ invalidReview('publish-set review path is not a regular file');
133
+ }
134
+ catch (error) {
135
+ if (error.code !== 'ENOENT')
136
+ throw error;
137
+ }
138
+ }
139
+ let reviewText;
140
+ try {
141
+ reviewText = readFileSync(join(directory, PUBLISH_SET_REVIEW_FILE), 'utf8');
142
+ }
143
+ catch (error) {
144
+ throw new BuildFailure('publish-set-review-missing', 'release blocked: no reviewed publish set; run anc review and commit it', 'could not read ' + PUBLISH_SET_REVIEW_FILE + ': ' + String(error));
145
+ }
146
+ const reviewed = parseReview(reviewText).slugs;
147
+ const current = canonicalSlugs(slugs);
148
+ const reviewedSet = new Set(reviewed);
149
+ const currentSet = new Set(current);
150
+ const added = current.filter((slug) => !reviewedSet.has(slug));
151
+ const removed = reviewed.filter((slug) => !currentSet.has(slug));
152
+ if (added.length > 0 || removed.length > 0) {
153
+ throw new BuildFailure('publish-set-review-changed', 'release blocked: publish set changed (' + added.length + ' added, ' + removed.length +
154
+ ' removed); run anc review, inspect the diff, and commit it', JSON.stringify({ added, removed }));
155
+ }
156
+ assertCommitted(directory);
157
+ }