@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
package/bin/anc.mjs ADDED
@@ -0,0 +1,798 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * The `anc` command: build a site from the directory the user
4
+ * ran it in.
5
+ *
6
+ * This is TK-24's whole deliverable. Everything downstream — discovery,
7
+ * exclusion, link resolution — is TK-26 onward; what this owns is the far
8
+ * narrower question the adoption path stops on first: **when the package is
9
+ * installed in somebody else's repository, where does the build think it is?**
10
+ *
11
+ * ## The root decision
12
+ *
13
+ * `astro build` needs three things from a project root: `astro.config.mjs`,
14
+ * `src/pages/`, and a `node_modules/` its imports resolve through. All three
15
+ * belong to *this package*, never to the user's notes repository — the user
16
+ * supplies content, not a site. So the root is always this package's own
17
+ * directory, and the user's directory contributes exactly two things: the
18
+ * content that becomes the artifact, and the place `dist/` lands.
19
+ *
20
+ * **Implemented by changing the process's working directory to the package
21
+ * root, then pointing `--outDir` back at the user's.** That reads as the blunt
22
+ * option and it is the correct one, because cwd is not incidental here — it is
23
+ * already the documented anchor three separate consumers agree on:
24
+ * `src/lib/artifact-source.ts:13-22` resolves the artifact against
25
+ * `process.cwd()` precisely because Astro bundles it into `dist/.prerender/`
26
+ * where `import.meta.url` points into the build output, and it records that the
27
+ * build "must be started from the repository root". Setting cwd satisfies that
28
+ * contract instead of fighting it.
29
+ *
30
+ * **`--root` was implemented first and rejected on measurement.** It is the
31
+ * option the plan names, it looks cleaner, and it cannot work here. Not on
32
+ * taste, and not on a hunch about Astro being cwd-sensitive in general: two
33
+ * constraints compose, and between them they leave exactly one shape available.
34
+ *
35
+ * 1. **cwd must be inside this package.** With cwd in the user's directory and
36
+ * `--root` at the package, Astro emits its prerender chunks relative to
37
+ * `--outDir` and Node then resolves their bare imports from *there*: `Cannot
38
+ * find package 'github-slugger' imported from
39
+ * …/rootprobe/dist/.prerender/chunks/site_*.mjs`. The build gets as far as
40
+ * "generating static routes" and dies. Node's resolution walks up from the
41
+ * importing file, and the user's directory has no `node_modules` containing
42
+ * this package's dependency tree — under pnpm it emphatically does not.
43
+ * 2. **`outDir` must be on the same device as cwd.** The mechanism is one
44
+ * function — `getOutDirWithinCwd`, four lines, in
45
+ * `astro/dist/core/build/common.js:76-82` (read at astro 7.1.6):
46
+ *
47
+ * if (fileURLToPath(outDir).startsWith(process.cwd())) return outDir;
48
+ * else return new URL('./.astro/', pathToFileURL(process.cwd() + sep));
49
+ *
50
+ * An `outDir` that fails that containment test is discarded *for the
51
+ * prerender staging directory*, which is placed at `<cwd>/.astro/` instead —
52
+ * reaching the build through `getServerOutputDirectory`
53
+ * (`astro/dist/prerender/utils.js:10`) → `getPrerenderOutputDirectory` →
54
+ * `static-build.js:114`. `ssrMoveAssets` (`static-build.js:249-285`) then
55
+ * `fs.promises.rename`s the assets from that staging directory to the real
56
+ * `outDir`, and **a rename cannot cross a device**.
57
+ *
58
+ * Be precise about what constraint 2 does and does not say, because the obvious
59
+ * reading is wrong and was measured to be wrong. The containment test governs
60
+ * only where prerender output is *staged*; the finished pages still reach
61
+ * `config.outDir`. So an out-of-cwd `outDir` is not fatal by itself — measured,
62
+ * `astro build --outDir Q:/probe-sibling/dist` from this repository produces a
63
+ * complete site, redirected staging and all, because the rename stays on one
64
+ * drive. The identical run to `C:/…` dies at `static-build.js` with
65
+ * `EXDEV: cross-device link not permitted`. **EXDEV is the operative blocker,
66
+ * and it is device-specific.** Configuring `build.client` does not reach it
67
+ * either — in static mode `clientRoot` is `config.outDir` directly
68
+ * (`static-build.js:254`).
69
+ *
70
+ * That is enough, because a publishing tool cannot know what device a user's
71
+ * repository is on: this package may sit on `Q:` while the notes it builds are
72
+ * on `C:`, which reproduces the EXDEV every run. Constraint 1 already forces cwd
73
+ * inside the package, so the only `outDir` guaranteed to share a device with cwd
74
+ * is one inside the package too — and the user's `dist/` can then only be
75
+ * reached by copying afterwards, which `cp` does across devices and `rename`
76
+ * does not. Staging then copying is not the blunt option chosen over a subtler
77
+ * one; it is the shape that holds wherever the user's directory happens to live.
78
+ *
79
+ * `tests/packaging.test.ts` imports `getOutDirWithinCwd` and asserts the
80
+ * redirect, so an Astro upgrade that changes this behaviour arrives as a failing
81
+ * test naming this decision rather than as an EXDEV nobody can place.
82
+ *
83
+ * Astro's JavaScript API, the plan's other candidate, is what runs the build
84
+ * (`import('astro')`, no subprocess) — but its `root` *option* is rejected for
85
+ * the same reason: passing `root` inline still leaves cwd elsewhere, which is
86
+ * constraint 1. It would also mean this file assembling an Astro config in code,
87
+ * giving the project two places a config lives. `astro.config.mjs` staying the
88
+ * only one is worth more.
89
+ *
90
+ * ## What it does not do
91
+ *
92
+ * **It does not produce an artifact from Markdown.** That is TK-26. Until it
93
+ * lands, `--content` is bridged by `scripts/markdown-to-artifact.ts`, which is
94
+ * deliberately the narrowest thing that turns `.md` files into the existing
95
+ * artifact shape so that this ticket's acceptance test can run end to end. It is
96
+ * temporary and marked as such at its own definition.
97
+ *
98
+ * **It gives the site the user's identity, and did not until TK-31.** A site
99
+ * built here used to carry *this repository's*: this project's own name under
100
+ * `.invalid` as its origin in every canonical link, feed id, sitemap `<loc>`,
101
+ * and `robots.txt`; that same name as its site name; this owner's social card; and an
102
+ * `/about/` page describing publication from "an explicit approval list", which
103
+ * is not how a build from a directory of Markdown works. All of it was measured
104
+ * in a real packaged build rather than inferred, and the failure was silent —
105
+ * the site was valid, rendered correctly, and was wrong about whose it was.
106
+ *
107
+ * The title and origin now come from the user's `publish.config.yaml`, and the
108
+ * defaults a user who configures nothing gets belong to nobody: a neutral site
109
+ * name and an RFC 6761 `.localhost` preview origin. The line that makes it true
110
+ * from *this* file is the `PUBLISH_CONFIG_DIR` assignment in `buildInto`, which
111
+ * documents at its own site why it is an environment variable.
112
+ *
113
+ * Version 1 deliberately omits `og:image`: a package-owned default brands every
114
+ * user's site, while a user-owned image requires a non-Markdown asset pipeline
115
+ * and threat model that this Markdown-only release does not claim.
116
+ */
117
+
118
+ import { cp, mkdir, mkdtemp, rm } from 'node:fs/promises';
119
+ import { existsSync, readFileSync, realpathSync } from 'node:fs';
120
+ import { fileURLToPath } from 'node:url';
121
+ import { isAbsolute, join, relative, resolve } from 'node:path';
122
+ import { pruneStaleBuildWorkspaces, removeBuildWorkspace } from '../scripts/build-workspace.js';
123
+ import { failureFor, isDisclosureChecked, openReport, BuildFailure } from '../scripts/write-report.js';
124
+
125
+ /** This package's own root — the directory holding `astro.config.mjs`. */
126
+ const PACKAGE_ROOT = fileURLToPath(new URL('../', import.meta.url));
127
+
128
+ const USAGE = `anc — build a static site from a directory of Markdown
129
+
130
+ Usage
131
+ anc init [options]
132
+ anc review [options]
133
+ anc build [options]
134
+ anc preview [options]
135
+
136
+ Options for init and review
137
+ --content <dir> directory holding the Markdown (default: the working directory)
138
+
139
+ Options for build
140
+ --content <dir> directory holding the Markdown (default: the working directory)
141
+ --out <dir> where to write the site (default: <working directory>/dist)
142
+ --release require a non-loopback origin and committed exact publish-set review
143
+
144
+ Options for preview
145
+ --dist <dir> the built site to serve (default: <working directory>/dist)
146
+ --port <number> the port to listen on (default: 4321)
147
+
148
+ --help, -h show this message
149
+ --version, -v print the version and exit
150
+ `;
151
+
152
+ /**
153
+ * The version a user reports in an issue. Read from this package's own
154
+ * `package.json`, which sits at the same relative path in a checkout and in the
155
+ * installed tarball, so there is no second spelling of it to drift.
156
+ */
157
+ function packageVersion() {
158
+ const manifest = JSON.parse(readFileSync(new URL('../package.json', import.meta.url), 'utf8'));
159
+ return String(manifest.version);
160
+ }
161
+
162
+ /**
163
+ * Parse `--flag value` pairs, rejecting anything unrecognised.
164
+ *
165
+ * Unknown flags fail rather than being ignored. A user who mistypes `--outdir`
166
+ * would otherwise get a successful build writing to a place they did not ask
167
+ * for, which for a publishing tool is the failure that matters: silently correct
168
+ * output in the wrong location reads as success.
169
+ *
170
+ * **The rejected token is never echoed.** "The user typed it" is not a safety
171
+ * argument on a surface a workflow log inherits — the user typed their withheld
172
+ * filenames too, and measured before this changed, `build
173
+ * clients/acme/2026-renewal` printed that path back verbatim. The rule is that a
174
+ * printed argv token must be byte-equal to a spelling this tool's own table
175
+ * declares, and an *unrecognised* token is by definition not one of those, so
176
+ * there is nothing left that may be printed.
177
+ *
178
+ * A shape test was tried first and rejected on measurement: echoing whatever
179
+ * matches `/^--?[A-Za-z][A-Za-z0-9-]*$/` still prints `--clients-acme-renewal`,
180
+ * which is a withheld note's stem wearing two dashes. A rule that admits a class
181
+ * of tokens has to be right about the whole class; this one admits none.
182
+ *
183
+ * The token is carried on the error's private half, which on this path reaches
184
+ * nobody: parsing happens before `build` opens a report, so an argument refusal
185
+ * has no file to write it to. That is accepted rather than repaired — the user is
186
+ * looking at the command they just typed, so the one string they do not need
187
+ * repeated back is the one they can see. It is recorded here so a later reader
188
+ * does not take the third argument as a promise that it is stored somewhere.
189
+ */
190
+ function parseArguments(argv) {
191
+ const options = { content: undefined, out: undefined, release: false };
192
+ for (let index = 0; index < argv.length; index += 1) {
193
+ const argument = argv[index];
194
+ if (argument === '--help' || argument === '-h') return 'help';
195
+ if (argument === '--release') {
196
+ options.release = true;
197
+ continue;
198
+ }
199
+ const key = argument === '--content' ? 'content' : argument === '--out' ? 'out' : undefined;
200
+ if (key === undefined) {
201
+ throw new BuildFailure('unknown-option', `unrecognised option\n\n${USAGE}`, argument);
202
+ }
203
+ const value = argv[index + 1];
204
+ // `argument` is `--content` or `--out` by construction here — the ternary
205
+ // above has already rejected everything else — so it is a literal of this
206
+ // tool's own source rather than a user-supplied string.
207
+ if (value === undefined || value.startsWith('--')) {
208
+ throw new BuildFailure('missing-value', `${argument} needs a directory\n\n${USAGE}`);
209
+ }
210
+ options[key] = value;
211
+ index += 1;
212
+ }
213
+ return options;
214
+ }
215
+
216
+ function parseReviewArguments(argv) {
217
+ const options = parseArguments(argv);
218
+ if (options === 'help') return options;
219
+ if (options.out !== undefined || options.release) {
220
+ const option = options.out !== undefined ? '--out' : '--release';
221
+ throw new BuildFailure('unknown-option', `review does not accept ${option}\n\n${USAGE}`, option);
222
+ }
223
+ return { content: options.content };
224
+ }
225
+
226
+ /**
227
+ * Refuse an output directory whose deletion would take the user's notes with it.
228
+ *
229
+ * The build replaces `--out` wholesale, because a stale file from a previous
230
+ * build left in a published directory is a page that still serves after the note
231
+ * behind it was deleted. That is right for a directory the tool owns and
232
+ * catastrophic for one it does not: `--out .` in a notes repository resolves to
233
+ * the repository, and the notes are what gets deleted.
234
+ *
235
+ * Measured, not theorised — a probe with `--out` pointing at a directory holding
236
+ * an unrelated file lost the file. It survived in the working directory only
237
+ * because Windows holds a lock on cwd, which is luck rather than a guarantee and
238
+ * is absent on every other platform.
239
+ *
240
+ * So three refusals, each a containment relation rather than a name: the output
241
+ * may not *be* the content directory, may not *contain* it, and may not be the
242
+ * directory the command was run from. A user who genuinely wants to publish into
243
+ * one of those can name a subdirectory, which is what `dist` already is.
244
+ */
245
+ function assertSafeOutput(outDirectory, contentDirectory, userDirectory) {
246
+ // Resolved through the filesystem before comparison, because `path.relative`
247
+ // is string arithmetic and a link is not a string relationship. Measured: with
248
+ // `alias` a junction to `notes/`, `--content ./alias --out ./notes` passed a
249
+ // purely textual guard and deleted the notes. `realpath` collapses the link,
250
+ // the two paths become the same string, and the containment test then sees
251
+ // what the filesystem sees.
252
+ //
253
+ // A directory that does not exist yet is its own resolved path — `--out dist`
254
+ // on a first run is the ordinary case, and it cannot alias anything, since
255
+ // there is nothing there to be a link.
256
+ const real = (directory) => {
257
+ try {
258
+ return realpathSync(directory);
259
+ } catch {
260
+ return directory;
261
+ }
262
+ };
263
+
264
+ const output = real(outDirectory);
265
+
266
+ // `relative`, not `startsWith`: `/notes-backup` starts with the string
267
+ // `/notes` while being no relation to it, and the string test would refuse a
268
+ // legitimate directory while a case-different or trailing-separator spelling
269
+ // of a real ancestor slipped past. `relative(a, b)` returning a path that
270
+ // neither escapes upward nor is absolute is the actual containment test.
271
+ const contains = (parent, child) => {
272
+ const step = relative(parent, child);
273
+ return step === '' || (!step.startsWith('..') && !isAbsolute(step));
274
+ };
275
+
276
+ for (const [directory, what] of [
277
+ [contentDirectory, 'the content directory'],
278
+ [userDirectory, 'the working directory'],
279
+ ]) {
280
+ if (contains(output, real(directory))) {
281
+ // Three host paths used to be in this message and now none is: the public
282
+ // half is the error code and which containment relation tripped, and the
283
+ // paths go to the report, where the person who can act on them reads them.
284
+ throw new BuildFailure(
285
+ 'unsafe-output-directory',
286
+ `unsafe-output-directory: refusing to build into the directory named by --out, ` +
287
+ `because it is, or contains, ${what}, and the build replaces its output ` +
288
+ 'directory wholesale. Name a subdirectory instead — the default is `dist`.',
289
+ `--out ${outDirectory} is, or contains, ${what} (${directory}); ` +
290
+ `the command was run in ${userDirectory}`,
291
+ );
292
+ }
293
+ }
294
+ }
295
+
296
+ /**
297
+ * The report this run opened, so the boundary below can print its two lines
298
+ * however the run ended.
299
+ *
300
+ * A module-level binding because the boundary is a top-level `try`, and there is
301
+ * exactly one `build` per process — `main` returns after it, and the binary has
302
+ * no path that calls it twice. It is cleared at the top of `build` regardless,
303
+ * so that a hypothetical second call which failed to open a report could not
304
+ * announce the first call's.
305
+ */
306
+ let openedReport;
307
+
308
+ /**
309
+ * Run the build.
310
+ *
311
+ * The ordering mirrors `package.json`'s `build` script link for link —
312
+ * validate, build, emit redirects, index, scan — because that chain is the
313
+ * product. A link dropped here would be a gate this repository enforces on
314
+ * itself and not on the users it ships to, which is the wrong way round.
315
+ * `tests/packaging.test.ts` asserts the two chains name the same steps, so the
316
+ * mirror is a property rather than a promise.
317
+ *
318
+ * Every path is resolved to an absolute one *before* cwd changes, since a
319
+ * relative `--out` means "relative to where the user typed it". The report's
320
+ * destination is resolved here for a sharper reason: `git rev-parse --git-path`
321
+ * answers **relative to cwd**, so resolving it after the `chdir` below would
322
+ * land it inside this package under `node_modules`.
323
+ */
324
+ async function build(options) {
325
+ openedReport = undefined;
326
+ const userDirectory = process.cwd();
327
+ const contentDirectory = resolve(userDirectory, options.content ?? '.');
328
+ const outDirectory = resolve(userDirectory, options.out ?? 'dist');
329
+
330
+ // Opened before anything can fail, and it writes its stub immediately. The
331
+ // diagnostic is worth most on the run that failed, and a report written where
332
+ // the counts are convenient — after validation — cannot exist on the run that
333
+ // failed validation, which is the likeliest throw in this function.
334
+ const report = openReport(userDirectory);
335
+ openedReport = report;
336
+ try {
337
+ if (!existsSync(contentDirectory)) {
338
+ throw new BuildFailure(
339
+ 'content-directory-not-found',
340
+ 'content directory not found: the directory named by --content does not exist',
341
+ contentDirectory,
342
+ );
343
+ }
344
+ assertSafeOutput(outDirectory, contentDirectory, userDirectory);
345
+
346
+ await buildInto(contentDirectory, outDirectory, report, options.release);
347
+ return report;
348
+ } catch (error) {
349
+ // Recording the failure must not *replace* it. If this second write throws —
350
+ // a full disk, a `.git` that turned read-only mid-run — the build's own
351
+ // error is what the user needs, and a report nobody can write is the lesser
352
+ // loss. The same argument governs the workspace cleanup below; stating it
353
+ // twice is cheaper than a reader finding the two treated differently and
354
+ // wondering which was deliberate.
355
+ try {
356
+ report.failed(failureFor(error));
357
+ } catch {
358
+ // Deliberately empty: see above. The original error is rethrown intact.
359
+ }
360
+ throw error;
361
+ }
362
+ }
363
+
364
+ async function buildInto(contentDirectory, outDirectory, report, release) {
365
+ // Build inside the package, then copy out. Not an ad-hoc workaround but the
366
+ // shape the two constraints in this file's header force: cwd must be inside
367
+ // the package for prerender chunks to resolve their imports, Astro stages
368
+ // prerender output under cwd and *renames* it to `outDir`, and a rename cannot
369
+ // cross a device. A user's notes may be on any drive, so the only `outDir`
370
+ // guaranteed to share one with cwd is one inside the package. `cp` then
371
+ // performs the cross-device step as a real copy, which `rename` cannot.
372
+ //
373
+ // Inside the package rather than in the OS temp directory for the same
374
+ // reason: `mkdtemp` in `os.tmpdir()` is on `C:` while a checkout may be on
375
+ // `Q:`, which reproduces the very EXDEV this staging exists to avoid.
376
+ //
377
+ // Per-run rather than a fixed name, so two builds cannot delete each other's
378
+ // intermediate output. The `finally` removes the workspace on an ordinary
379
+ // exit; a later run prunes interrupted or deferred workspaces after 24 hours.
380
+ // The age bound keeps concurrent workspaces out of that sweep.
381
+ // Under a normal install the package root is inside `node_modules`, so this is
382
+ // already ignored by anything ignoring that; it matters when the binary is run
383
+ // from a checkout of this repository, where scratch state in the working tree
384
+ // is one `git add -A` from being committed. `.gitignore` names the prefix for
385
+ // that case.
386
+ await pruneStaleBuildWorkspaces(PACKAGE_ROOT);
387
+ const workspace = await mkdtemp(join(PACKAGE_ROOT, '.anc-build-'));
388
+
389
+ // The `try` opens on the line after the directory exists, and deliberately
390
+ // *before* the artifact is written. `writeArtifact` reads every note and runs
391
+ // the whole content contract over them, so it is the likeliest throw in this
392
+ // function — and with the boundary any later, each failed run left a workspace
393
+ // behind for ever. Measured: three failing runs, three directories.
394
+ try {
395
+ const staging = join(workspace, 'dist');
396
+
397
+ const { discover, resolveCorpusLinks, writeArtifact } = await import('../scripts/markdown-to-artifact.js');
398
+ const { exclusionOptions, isLoopbackOrigin, loadConfig, CONFIG_DIRECTORY_VARIABLE } = await import('../scripts/load-config.js');
399
+ const artifact = join(workspace, 'content.json');
400
+
401
+ // Read from the *content* directory, not from cwd: `--content` may name a
402
+ // subdirectory, and the configuration belongs with the notes it governs.
403
+ // Absent is the ordinary case and yields the documented defaults.
404
+ const config = loadConfig(contentDirectory);
405
+ if (release && (config.origin === undefined || isLoopbackOrigin(config.origin))) {
406
+ throw new BuildFailure(
407
+ 'release-origin-missing',
408
+ 'release blocked: publish.config.yaml must declare a non-loopback public origin',
409
+ );
410
+ }
411
+
412
+ // Tell `astro.config.mjs` where to read the same file from, and this line is
413
+ // the difference between a configured site and a silently ignored
414
+ // configuration. Measured without it: a `publish.config.yaml` naming
415
+ // `title` and `origin` built a site carrying neither — the placeholder
416
+ // origin in every canonical link and feed id, the default name in every
417
+ // browser tab — and the build reported success. A stranger configures their
418
+ // site, gets this package's defaults, and is told nothing.
419
+ //
420
+ // **An environment variable rather than an argument, and there is no
421
+ // argument available to pass.** Astro loads `astro.config.mjs` itself, from
422
+ // inside `astroBuild()` below; nothing here calls it, so there is no
423
+ // parameter to thread a directory through. The config's scope then reads
424
+ // `process.env` because that is the only channel that crosses into it.
425
+ // `CONTENT_ARTIFACT` is set a few lines down for exactly the same reason and
426
+ // has the same shape. Anyone tempted to "clean this up" into a function
427
+ // argument will find there is no function.
428
+ //
429
+ // Set *before* the `chdir` below rather than after, though either works
430
+ // today: `contentDirectory` is already absolute. Keeping it beside the
431
+ // `loadConfig` call means the two readers of this directory sit together,
432
+ // and a future relative path cannot silently start resolving against the
433
+ // package root.
434
+ process.env[CONFIG_DIRECTORY_VARIABLE] = contentDirectory;
435
+
436
+ // Discovery, then the report, then validation — in that order and not the
437
+ // convenient one. The report stops saying `aborted` the moment discovery
438
+ // finishes, which is *before* the content contract runs over what was
439
+ // discovered and before an empty corpus is refused. That is what puts the
440
+ // names of the dropped files in a readable file on the two runs that need
441
+ // them most: the one the contract rejects, and the one where every file was
442
+ // dropped and there is nothing left to publish.
443
+ // The configured exclusions reach discovery here, and this argument is the
444
+ // whole of what makes them real. Without it `discover` walks with its
445
+ // structural ignores alone and a user who excluded `drafts/**` publishes
446
+ // their drafts — measured, and the wrong direction for a privacy boundary to
447
+ // fail in: a fail-open exclusion looks exactly like a working one until
448
+ // somebody reads the site.
449
+ //
450
+ // `exclusionOptions(config)` rather than `{ exclude: config.exclude }`, and
451
+ // the difference is one a hand test does not show: it also passes
452
+ // `excludeSource`, which is what lets a pattern matching nothing name the
453
+ // file to edit. Measured with the bare object — a config whose `draft/**`
454
+ // was a typo for `drafts/**` failed with `the exclude list — exclude[0]
455
+ // matched 0 files`, sending the user to look for a list the tool would not
456
+ // name. `markdown-to-artifact.ts` validates the label against its own
457
+ // filename allowlist before printing it, so this cannot become a disclosure.
458
+ const discovery = await discover(contentDirectory, exclusionOptions(config));
459
+
460
+ // Between discovery and the artifact, because this is where the link
461
+ // findings come from and the report has to carry them: `discover` holds no
462
+ // report handle and `writeArtifact` holds none either, so this call site is
463
+ // the only place a link finding can reach the report.
464
+ //
465
+ // The report is named by `scripts/write-report.ts` and never here — not
466
+ // even in a comment. `tests/packaging.test.ts` scans every module the build
467
+ // loads for the report's filename and exempts only its writer, on the
468
+ // reasoning that a module which *names* the report is one layer from a
469
+ // module that prints it. That gate fired on an earlier draft of this
470
+ // comment, which is the gate working rather than a rule being pedantic.
471
+ //
472
+ // It also rewrites the bodies. Without it `outgoing` is empty for every
473
+ // note, and every wikilink survives into the artifact unresolved — so on
474
+ // the shipped binary a repository containing one did not build at all.
475
+ // Measured; the feature was reachable only from the test suite.
476
+ const links = await resolveCorpusLinks(discovery);
477
+ report.discovered(discovery.counts, discovery.dropped, links);
478
+ if (release) {
479
+ const { assertPublishSetReviewed } = await import('../scripts/publish-set-review.js');
480
+ assertPublishSetReviewed(contentDirectory, discovery.entries.map((entry) => entry.slug));
481
+ }
482
+ await writeArtifact(discovery, artifact, { includeEdges: false });
483
+
484
+ // From here on the process runs as if it had been started in the package, so
485
+ // every consumer that resolves against cwd — `artifact-source.ts` first among
486
+ // them — sees the root it was written for.
487
+ process.chdir(PACKAGE_ROOT);
488
+ process.env['CONTENT_ARTIFACT'] = artifact;
489
+
490
+ const { validateBuildInputs } = await import('../scripts/validate-content.js');
491
+ let validated;
492
+ try {
493
+ // `validate-content.ts` remains the sole owner of schema, tag, collection,
494
+ // and redirect checks. This boundary only adapts its user-derived detail
495
+ // to the public-stream/private-report split.
496
+ validated = validateBuildInputs(artifact);
497
+ } catch (error) {
498
+ if (isDisclosureChecked(error)) throw error;
499
+ throw new BuildFailure(
500
+ 'invalid-generated-content',
501
+ 'generated content cannot pass schema and route validation',
502
+ error instanceof Error ? error.message : String(error),
503
+ );
504
+ }
505
+
506
+ const { build: astroBuild } = await import('astro');
507
+
508
+ // The snapshot is built before the render, because the render reads its
509
+ // relationship surfaces from this exact file. It lives in the private
510
+ // workspace, never in `staging`: only the digest-named copy below is public.
511
+ const snapshotDirectory = join(workspace, 'snapshot');
512
+ process.env['SNAPSHOT_WORKSPACE'] = snapshotDirectory;
513
+ const { buildSnapshotFromEntries } = await import('../scripts/build-snapshot.js');
514
+ buildSnapshotFromEntries(discovery.entries, snapshotDirectory);
515
+ const { buildWasm } = await import('../scripts/build-wasm.js');
516
+ buildWasm(snapshotDirectory);
517
+
518
+ await astroBuild({ outDir: staging, logLevel: 'error' });
519
+
520
+ const { copySnapshotToOutput } = await import('../scripts/copy-snapshot.js');
521
+ copySnapshotToOutput(staging, snapshotDirectory);
522
+ const { copyWasmToOutput } = await import('../scripts/copy-wasm.js');
523
+ copyWasmToOutput(staging, snapshotDirectory);
524
+
525
+ const { emitRedirects } = await import('../scripts/emit-redirects.js');
526
+ emitRedirects(staging);
527
+
528
+ const { indexWithPagefind } = await import('../scripts/run-pagefind.js');
529
+ await indexWithPagefind(staging);
530
+
531
+ const { assertOutputInventory } = await import('../scripts/verify-output-inventory.js');
532
+ assertOutputInventory(staging, validated, snapshotDirectory);
533
+
534
+ if (release) {
535
+ const { scanSecrets } = await import('../scripts/scan-secrets.js');
536
+ console.log(`secret scan ok: ${scanSecrets(staging)} files, 0 findings`);
537
+ }
538
+
539
+ const { assertNoResidue } = await import('../scripts/scan-residue.js');
540
+ console.log(`residue scan ok: ${assertNoResidue(staging)} files, 0 findings`);
541
+
542
+ // Only now is the output fit to hand over. Copying after the scan rather than
543
+ // before means a build that fails a gate leaves the user's `dist/` untouched,
544
+ // holding whatever last passed — the same property `validate-content.ts`
545
+ // argues for at the front of the chain.
546
+ await rm(outDirectory, { recursive: true, force: true });
547
+ await mkdir(outDirectory, { recursive: true });
548
+ await cp(staging, outDirectory, { recursive: true });
549
+ // No path. `site written to <outDirectory>` put a host path on a surface a
550
+ // workflow log inherits, and restoring it as `options.out` would not help —
551
+ // a user-typed argv value is not a safety class.
552
+ console.log('site written');
553
+ } finally {
554
+ // `maxRetries` because this is Windows: measured, removing the staging
555
+ // directory intermittently fails with `EBUSY: resource busy or locked` on a
556
+ // file the just-finished build wrote, which is a scanner or indexer still
557
+ // holding it rather than anything this process did.
558
+ //
559
+ // And caught, because a cleanup failure must not *replace* the build's own
560
+ // failure. It did: on the run that reproduced the EBUSY, the residue scan's
561
+ // five findings were thrown away and the user got the unlink error instead —
562
+ // so the same corpus printed two different things on two runs, and the one
563
+ // it printed on the bad run said nothing about what was actually wrong. A
564
+ // left-behind workspace is a wasted directory; a swallowed diagnostic is a
565
+ // build nobody can debug.
566
+ if (!(await removeBuildWorkspace(workspace))) {
567
+ console.error('staging cleanup deferred: 1 directory');
568
+ }
569
+ }
570
+ }
571
+
572
+ /**
573
+ * Prepare a notes repository for its first build.
574
+ *
575
+ * `scripts/init-repository.ts` documents what is written and what deliberately
576
+ * is not. What belongs here is the reporting, which is the half a user judges
577
+ * the command by: `init` writes into a repository the user owns, so every line
578
+ * it prints is an account of a file it touched.
579
+ *
580
+ * **Every string on this stream is a literal of this tool's own source.** The
581
+ * seeded entries, the config filename, and the tracked-case advice are all
582
+ * constants declared in `scripts/seed-gitignore.ts` and `scripts/load-config.ts`
583
+ * — a closed set, byte-equal to a spelling this tool declares, which is the rule
584
+ * `parseArguments` states. No path the user supplied and no name discovered on
585
+ * their filesystem reaches it. That is what makes `init` printable at all where
586
+ * `build` prints counts: `build` speaks about the user's corpus and `init`
587
+ * speaks only about its own two files.
588
+ *
589
+ * It opens no report, for the reason `preview` gives: a report describes a
590
+ * corpus, and this command reads none.
591
+ *
592
+ * The four outcomes are each reported, including the two that wrote nothing.
593
+ * "Already covered" is the answer a user running this a second time needs, and
594
+ * `tracked` is the one case where the tool cannot fix the problem itself and
595
+ * hands back the command that can.
596
+ */
597
+ async function init(argv) {
598
+ const { initialise, parseInitArguments } = await import('../scripts/init-repository.js');
599
+ const { CONFIG_FILENAME } = await import('../scripts/load-config.js');
600
+
601
+ const options = parseInitArguments(argv);
602
+ const result = initialise(resolve(process.cwd(), options.content));
603
+
604
+ for (const outcome of result.ignore.outcomes) {
605
+ if (outcome.state === 'tracked') {
606
+ // The rule was written; what it cannot do is take effect on a path git is
607
+ // already tracking, because `git add -A` stages a modification to a
608
+ // tracked file whatever the ignore rules say. So the line and the command
609
+ // are both needed, and the message says which does what — an earlier
610
+ // version said an ignore rule "cannot help" and wrote none, which left the
611
+ // user's `git rm --cached` undone by their next `git add -A`.
612
+ console.log(`.gitignore: ${outcome.entry} — added, but this path is already tracked, so it stays staged until:`);
613
+ console.log(` ${outcome.advice}`);
614
+ continue;
615
+ }
616
+ const said = {
617
+ seeded: 'added to .gitignore',
618
+ covered: 'already ignored',
619
+ negated: 'left alone — this repository un-ignores it deliberately',
620
+ }[outcome.state];
621
+ console.log(`.gitignore: ${outcome.entry} — ${said}`);
622
+ }
623
+
624
+ if (result.ignore.unprobed) {
625
+ // The state words above mean something weaker on this branch and the line
626
+ // says so: with no repository there is nothing to ask, so `already ignored`
627
+ // is "this exact line is in the file" rather than git's answer. A user who
628
+ // has a global ignore rule, or who later writes a `!dist/`, gets the probed
629
+ // reading from the next run after `git init`.
630
+ console.log('.gitignore: not a git repository yet, so the entries were matched as text rather than checked');
631
+ }
632
+
633
+ console.log(
634
+ result.config === 'written'
635
+ ? `${CONFIG_FILENAME}: written, with every key commented out — edit it, or leave it`
636
+ : `${CONFIG_FILENAME}: already present, left unchanged`,
637
+ );
638
+ }
639
+
640
+ /** Compute the public note set and write the candidate review ledger. */
641
+ async function review(argv) {
642
+ const options = parseReviewArguments(argv);
643
+ const contentDirectory = resolve(process.cwd(), options.content ?? '.');
644
+ if (!existsSync(contentDirectory)) {
645
+ throw new BuildFailure(
646
+ 'content-directory-not-found',
647
+ 'content directory not found: the directory named by --content does not exist',
648
+ contentDirectory,
649
+ );
650
+ }
651
+ const { discover } = await import('../scripts/markdown-to-artifact.js');
652
+ const { exclusionOptions, loadConfig } = await import('../scripts/load-config.js');
653
+ const { PUBLISH_SET_REVIEW_FILE, writePublishSetReview } = await import('../scripts/publish-set-review.js');
654
+ const config = loadConfig(contentDirectory);
655
+ const discovery = await discover(contentDirectory, exclusionOptions(config));
656
+ const count = writePublishSetReview(contentDirectory, discovery.entries.map((entry) => entry.slug));
657
+ console.log(`publish set review written: ${count} notes`);
658
+ console.log(`inspect and commit ${PUBLISH_SET_REVIEW_FILE} before a release build`);
659
+ }
660
+
661
+ /**
662
+ * Serve a built site, and stay in the foreground until interrupted.
663
+ *
664
+ * The command that makes the tool's own output readable to the person who ran
665
+ * it — `scripts/preview-site.ts` documents why a subcommand exists at all when
666
+ * `astro preview` does, and it comes down to `astro` not being on a pnpm
667
+ * install's `.bin` at all.
668
+ *
669
+ * It opens no report. A report describes a corpus, and this command reads no
670
+ * corpus — it reads a directory of HTML the build already accounted for. Opening
671
+ * one would announce a `no such thing was dropped` summary for a run that
672
+ * dropped nothing because it discovered nothing, which is a line that says
673
+ * something false about a build that already happened.
674
+ *
675
+ * **Nothing holds the process open, because the listening socket already does.**
676
+ * An `await new Promise(() => {})` was written here first, on the reasoning that
677
+ * `main` returning would let Node exit. Measured with that line removed: the
678
+ * command served normally past 12 s and answered 200. Node's event loop is held
679
+ * by the listening handle, which is what a server handle is for. The line was
680
+ * not merely redundant — an unsettled top-level await makes Node print `Warning:
681
+ * Detected unsettled top-level await` and exit **13** when the loop does drain,
682
+ * so it turned a clean shutdown into a warning and a failure code.
683
+ */
684
+ async function preview(argv) {
685
+ const { parsePreviewArguments, resolveArtifactDirectory, startPreview } = await import(
686
+ '../scripts/preview-site.js'
687
+ );
688
+
689
+ const options = parsePreviewArguments(argv);
690
+ const artifact = resolveArtifactDirectory(options.dist, process.cwd());
691
+ const server = await startPreview(artifact, options.port, PACKAGE_ROOT);
692
+
693
+ // The port comes from the server rather than from `options`, because a taken
694
+ // port moves: measured, `preview()` on a held 4580 returned 4581. Printing
695
+ // what was asked for would send the user to a port nothing is listening on.
696
+ //
697
+ // No path in the line, for the reason every other stream write in this file
698
+ // gives — `--dist` may name a withheld directory, and this stream is one a
699
+ // workflow log inherits.
700
+ console.log(`preview: http://localhost:${server.port}/`);
701
+ console.log('press Ctrl-C to stop');
702
+ }
703
+
704
+ async function main(argv) {
705
+ const command = argv[0];
706
+ if (command === undefined || command === '--help' || command === '-h') {
707
+ console.log(USAGE);
708
+ return command === undefined ? 1 : 0;
709
+ }
710
+ if (command === '--version' || command === '-v') {
711
+ console.log(packageVersion());
712
+ return 0;
713
+ }
714
+ if (command === 'init' || command === 'review' || command === 'preview') {
715
+ const rest = argv.slice(1);
716
+ if (rest.includes('--help') || rest.includes('-h')) {
717
+ console.log(USAGE);
718
+ return 0;
719
+ }
720
+ if (command === 'init') await init(rest);
721
+ else if (command === 'review') await review(rest);
722
+ else await preview(rest);
723
+ return 0;
724
+ }
725
+ if (command !== 'build') {
726
+ // Same treatment as an unrecognised option, for the reason
727
+ // `parseArguments` gives at length: an unrecognised token is by definition
728
+ // not one of this tool's own spellings, and measured before this changed,
729
+ // `deploy-to-clients-acme` was printed back verbatim. A shape test does not
730
+ // help — that token matches every plausible one.
731
+ console.error(`unrecognised command\n\n${USAGE}`);
732
+ return 1;
733
+ }
734
+
735
+ const options = parseArguments(argv.slice(1));
736
+ if (options === 'help') {
737
+ console.log(USAGE);
738
+ return 0;
739
+ }
740
+
741
+ await build(options);
742
+ return 0;
743
+ }
744
+
745
+ /**
746
+ * The two report lines, emitted exactly once per `build` however it ended.
747
+ *
748
+ * Unconditional is the decision, and the alternative is worth naming so it is
749
+ * not re-proposed: a line emitted only when something was dropped reaches
750
+ * exactly the user who already had a signal, while the user who needs the report
751
+ * is by definition the one who did not know they would. The file's existence is
752
+ * not self-announcing.
753
+ *
754
+ * Both are rename-invariant by construction — three integers and two source
755
+ * literals — so requiring them strengthens the disclosure differential rather
756
+ * than fighting it.
757
+ */
758
+ function announce(report) {
759
+ if (report === undefined) return;
760
+ console.log(report.summary);
761
+ console.log(report.pointer);
762
+ }
763
+
764
+ /**
765
+ * The boundary, and the one place a string nobody here composed could reach a
766
+ * world-readable log.
767
+ *
768
+ * `console.error(error.message)` used to print every throw in the process
769
+ * verbatim, which is how a `readdir` `ENOTDIR` reached stderr with an absolute
770
+ * path already inside a message this project never wrote — and how the residue
771
+ * scanner, whose whole job is keeping an absolute path out of `dist/`, announced
772
+ * that path on the one run where it existed. So only an error constructed under
773
+ * the disclosure rule prints its own message; everything else prints its code,
774
+ * and its real message is in the report's `failure.detail`.
775
+ */
776
+ try {
777
+ process.exitCode = await main(process.argv.slice(2));
778
+ } catch (error) {
779
+ // Three cases, not two, and the third is why this is not one ternary.
780
+ //
781
+ // An error composed under the disclosure rule prints its own message. Anything
782
+ // else prints a fixed literal — including `error.name`, which reads like a
783
+ // harmless literal of this tool's own source and is not one: it is whatever a
784
+ // library or the runtime chose, so it is a third-party string of opaque
785
+ // provenance on the same surface as everything else this rule governs.
786
+ //
787
+ // And an error thrown *before a report was opened* must not point at one. That
788
+ // is not hypothetical: `parseArguments` runs before `build`, so every argument
789
+ // refusal takes this path, as does a failure to open the report itself. A
790
+ // message reading "see the report for the detail" when no report exists sends
791
+ // the user to a file they will not find, which is worse than saying less.
792
+ if (isDisclosureChecked(error)) console.error(error.message);
793
+ else if (openedReport === undefined) console.error('build failed');
794
+ else console.error('build failed — see the report for the detail');
795
+ process.exitCode = 1;
796
+ } finally {
797
+ announce(openedReport);
798
+ }