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