@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
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
|
+
}
|