@wxxb789/anc 0.0.1 → 0.0.3

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/README.md CHANGED
@@ -1,6 +1,7 @@
1
1
  # anc — a privacy-preserving static site generator for Markdown notes and digital gardens
2
2
 
3
3
  [![status: pre-release](https://img.shields.io/badge/status-pre--release-orange)](#status)
4
+ [![npm](https://img.shields.io/npm/v/@wxxb789/anc?label=npm)](https://www.npmjs.com/package/@wxxb789/anc)
4
5
  [![node: >=22.18](https://img.shields.io/badge/node-%E2%89%A522.18-brightgreen)](#working-on-the-tool)
5
6
  [![verify](https://github.com/wxxb789/anc/actions/workflows/verify.yml/badge.svg)](https://github.com/wxxb789/anc/actions/workflows/verify.yml)
6
7
  [![license: MIT](https://img.shields.io/badge/license-MIT-blue)](LICENSE)
@@ -22,7 +23,8 @@ Ordinary reading needs no JavaScript, and nothing is sent to a third party.
22
23
  ![A note page built by anc: breadcrumbs, published and updated dates from git, tags, aliases, a table of contents, and a callout, in the dark theme](docs/assets/screenshot-note.webp)
23
24
 
24
25
  *anc* is short for **A**ctive **N**oise **C**ancelling. The command it installs is
25
- `anc`; the package is named `@wxxb789/anc` and is not yet published to npm.
26
+ `anc`; the package is [`@wxxb789/anc`](https://www.npmjs.com/package/@wxxb789/anc) on
27
+ npm.
26
28
 
27
29
  ## Contents
28
30
 
@@ -43,12 +45,15 @@ Ordinary reading needs no JavaScript, and nothing is sent to a third party.
43
45
 
44
46
  ## Status
45
47
 
46
- **Pre-release.** The tool builds, previews, and ships the GitHub Action and the `init`
47
- command. It is named `@wxxb789/anc`, because the unscoped `anc` is already taken on
48
- npm, and it is not on the registry yet; install from a checkout or from the tarball the
49
- repository builds. [`docs/adoption.md`](docs/adoption.md) gives both, along with
50
- configuration, exclusion, links, and hosting. ANC is not yet 0.1.0-ready or
51
- 1.0.0-ready and makes no backward-compatibility promise.
48
+ **Pre-release.** The tool builds, previews, ships the GitHub Action, and is on the
49
+ registry. [`@wxxb789/anc`](https://www.npmjs.com/package/@wxxb789/anc) publishes from a
50
+ `vX.Y.Z` tag through the `release` workflow, and each release carries SLSA provenance
51
+ generated by npm's trusted publishing. `0.0.1` was published by hand for one reason: a
52
+ trusted publisher is configured on a package page that has to exist first. The name is
53
+ scoped because the unscoped `anc` already belongs to an unrelated package.
54
+ [`docs/adoption.md`](docs/adoption.md) covers configuration, exclusion, links, and
55
+ hosting. ANC is not yet 0.1.0-ready or 1.0.0-ready and makes no backward-compatibility
56
+ promise.
52
57
 
53
58
  ## Why anc
54
59
 
@@ -103,23 +108,15 @@ Every image below is a real page built by anc from a small synthetic garden;
103
108
 
104
109
  ## Quick start
105
110
 
106
- From a checkout of this repository, against your own notes:
111
+ Install it in the notes repository you want to publish:
107
112
 
108
113
  ```bash
109
- node /path/to/anc/bin/anc.mjs build --content ~/notes --out ~/notes/dist
110
- node /path/to/anc/bin/anc.mjs preview --dist ~/notes/dist
114
+ npm install -D @wxxb789/anc # or: pnpm add -D @wxxb789/anc
115
+ npx @wxxb789/anc init # seed .gitignore and write publish.config.yaml
111
116
  ```
112
117
 
113
- Or install the tarball the repository builds:
114
-
115
- ```bash
116
- cd /path/to/anc && pnpm run pack:tarball
117
- cd ~/notes
118
- pnpm add -D /path/to/anc/wxxb789-anc-*.tgz # or: npm install -D /path/to/anc/wxxb789-anc-*.tgz
119
- ```
120
-
121
- Then name the commands in your notes repository's `package.json`; the same scripts
122
- work under pnpm and npm:
118
+ Then name the commands in that repository's `package.json`; the same scripts work
119
+ under pnpm and npm:
123
120
 
124
121
  ```json
125
122
  {
@@ -139,12 +136,19 @@ pnpm run review # write .publish-set.json for inspection
139
136
  pnpm run release # exact publish set + pinned Gitleaks on PATH
140
137
  ```
141
138
 
142
- To call the binary directly, use `pnpm exec anc build`, or `npx --no anc build` under
143
- npm: `--no` makes `npx` refuse rather than fetch from the registry when the local
144
- install is missing. After publication the registry form will be
145
- `npx @wxxb789/anc build`. **Do not type bare `npx anc`:** outside a project with the
146
- tarball installed it falls back to the registry, where the unscoped `anc` is an
147
- unrelated third-party package that `npx` would offer to download and run.
139
+ Without installing, `npx @wxxb789/anc build --content ~/notes --out ~/notes/dist` runs
140
+ the published release, and `npx @wxxb789/anc preview --dist ~/notes/dist` serves the
141
+ result. **Do not type bare `npx anc`:** the unscoped `anc` on npm is an unrelated
142
+ third-party package, and `npx` would offer to download and run it.
143
+
144
+ To try an unreleased commit instead, build the tarball from a checkout and install that;
145
+ the scripts above apply to it too:
146
+
147
+ ```bash
148
+ cd /path/to/anc && pnpm run pack:tarball
149
+ cd ~/notes
150
+ pnpm add -D /path/to/anc/wxxb789-anc-*.tgz # or: npm install -D …
151
+ ```
148
152
 
149
153
  ## How publication is decided
150
154
 
@@ -190,8 +194,31 @@ a disclosure there; that is why the names and the counts are split.
190
194
  - Frontmatter can set `slug`, `language`, `description`, `tags`, and `aliases`.
191
195
  Aliases are public, searchable metadata with no route of their own — deliberately
192
196
  not alternate link targets.
193
- - Non-Markdown files are never published. There is no asset pipeline, so an embedded
194
- image degrades to text and is reported.
197
+ - Local images referenced by a published note are published under
198
+ `/_assets/<sha256>-<name>`, identified by the **emitted** bytes and deduplicated by content,
199
+ so one picture is one file however many notes embed it. A JPEG photograph becomes lossy
200
+ WebP only when the candidate is smaller; a PNG screenshot, diagram, or alpha graphic
201
+ becomes lossless WebP only when that is smaller. Existing WebP/AVIF and every animation
202
+ are published unchanged, with every frame and delay. A local, self-contained **static SVG**
203
+ is build-only input: it is validated against a fail-closed XML/CSS subset, rasterized from
204
+ its bytes to lossless WebP at twice its logical size, and its source is never served. Every
205
+ external reference, `data:` payload, script, animation, foreign namespace, DTD and CSS
206
+ at-rule is refused; text no installed font covers fails the build rather than publishing
207
+ tofu. No resize, crop, responsive matrix, image CDN, browser script, per-image setting, or
208
+ bundled font exists. A reference resolves relative to the note, then the content root; a
209
+ missing, excluded, escaping, corrupt, over-limit, or unsupported one fails the build rather
210
+ than shipping a broken picture. Remote images are left as written and never fetched. A
211
+ standalone image's explicit Markdown title becomes a visible `<figcaption>` while `alt`
212
+ stays the accessible alternative; dimensions come from the emitted bytes — half the emitted
213
+ raster for a 2× SVG (the logical size unless the output budget clamped it, in which case the
214
+ layout shrinks with the raster) — so layout space is reserved, and the first image loads
215
+ eagerly while later ones defer.
216
+ - The encoder and SVG rasterizer are the pinned `sharp` package, installed as an ordinary
217
+ dependency with its prebuilt platform binary: no system CLI, `cwebp`, `librsvg`, `resvg`,
218
+ or `libvips` install step. SVG text uses the build host's fonts, and missing coverage fails
219
+ rather than adding a bundled font. Linux x64 is the platform exercised by this repository's
220
+ gates; Windows and macOS use sharp's prebuilt packages but were not exercised here, and
221
+ cross-platform byte equality is not promised.
195
222
 
196
223
  ## Architecture
197
224
 
@@ -239,12 +266,14 @@ notes, both exclusion mechanisms, links and backlinks, rich Markdown, math, and
239
266
  ## Adoption and hosting
240
267
 
241
268
  The GitHub Action at the repository root runs a release build on a supported Linux
242
- runner, installs the checksum-pinned secret scanner, and writes a static `dist/`. Any
243
- static host serves that directory, at the root of a domain: a site under a path such
244
- as `https://<user>.github.io/<repo>/` is not supported. `dist/_headers` carries a
245
- Content-Security-Policy and three other security headers in Cloudflare Pages' format;
246
- a host that ignores that file still gets the policy from each page's `<meta>` tag,
247
- except `frame-ancestors`, and serves the other headers only if configured to.
269
+ runner, installs the checksum-pinned secret scanner, and writes a static `dist/`. Pin it
270
+ to `@v0.0.2` for one exact release, `@v0` to follow the latest 0.x, or a full commit SHA
271
+ for the strongest pin. Any static host serves that directory, at the root of a domain:
272
+ a site under a path such as `https://<user>.github.io/<repo>/` is not supported.
273
+ `dist/_headers` carries a Content-Security-Policy and three other security headers in
274
+ Cloudflare Pages' format; a host that ignores that file still gets the policy from each
275
+ page's `<meta>` tag, except `frame-ancestors`, and serves the other headers only if
276
+ configured to.
248
277
 
249
278
  Publication is an explicit external side effect. Building does not deploy, and nothing
250
279
  in this repository can. [`docs/adoption.md`](docs/adoption.md) covers both the Action
package/bin/anc.mjs CHANGED
@@ -125,6 +125,15 @@ import { failureFor, isDisclosureChecked, openReport, BuildFailure } from '../sc
125
125
  /** This package's own root — the directory holding `astro.config.mjs`. */
126
126
  const PACKAGE_ROOT = fileURLToPath(new URL('../', import.meta.url));
127
127
 
128
+ // **No command in this tool reports telemetry.** Astro reports anonymous usage
129
+ // over the network unless this variable is set, and both `build` and `preview`
130
+ // reach Astro. Every workflow in this repository already set it, so the shipped
131
+ // binary was the one path still making an outbound request the user did not ask
132
+ // for — and `docs/adoption.md` now says plainly that ordinary builds and
133
+ // previews are offline. Set before any command imports Astro, because the
134
+ // telemetry module reads it when it loads.
135
+ process.env['ASTRO_TELEMETRY_DISABLED'] = '1';
136
+
128
137
  const USAGE = `anc — build a static site from a directory of Markdown
129
138
 
130
139
  Usage
@@ -140,6 +149,9 @@ Options for build
140
149
  --content <dir> directory holding the Markdown (default: the working directory)
141
150
  --out <dir> where to write the site (default: <working directory>/dist)
142
151
  --release require a non-loopback origin and committed exact publish-set review
152
+ --media r2 publish referenced images to the Cloudflare R2 bucket configured
153
+ under the media: table in publish.config.yaml, and point the site
154
+ at its public custom domain. Requires --release.
143
155
 
144
156
  Options for preview
145
157
  --dist <dir> the built site to serve (default: <working directory>/dist)
@@ -188,7 +200,7 @@ function packageVersion() {
188
200
  * does not take the third argument as a promise that it is stored somewhere.
189
201
  */
190
202
  function parseArguments(argv) {
191
- const options = { content: undefined, out: undefined, release: false };
203
+ const options = { content: undefined, out: undefined, release: false, media: undefined };
192
204
  for (let index = 0; index < argv.length; index += 1) {
193
205
  const argument = argv[index];
194
206
  if (argument === '--help' || argument === '-h') return 'help';
@@ -196,16 +208,38 @@ function parseArguments(argv) {
196
208
  options.release = true;
197
209
  continue;
198
210
  }
199
- const key = argument === '--content' ? 'content' : argument === '--out' ? 'out' : undefined;
211
+ const key =
212
+ argument === '--content'
213
+ ? 'content'
214
+ : argument === '--out'
215
+ ? 'out'
216
+ : argument === '--media'
217
+ ? 'media'
218
+ : undefined;
200
219
  if (key === undefined) {
201
220
  throw new BuildFailure('unknown-option', `unrecognised option\n\n${USAGE}`, argument);
202
221
  }
203
222
  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.
223
+ // `argument` is `--content`, `--out`, or `--media` by construction here —
224
+ // the ternary above has already rejected everything else — so it is a
225
+ // literal of this tool's own source rather than a user-supplied string.
207
226
  if (value === undefined || value.startsWith('--')) {
208
- throw new BuildFailure('missing-value', `${argument} needs a directory\n\n${USAGE}`);
227
+ throw new BuildFailure(
228
+ 'missing-value',
229
+ `${argument} needs ${key === 'media' ? 'a provider name' : 'a directory'}\n\n${USAGE}`,
230
+ );
231
+ }
232
+ // **The value is not echoed, even though it looks like a closed set.** A
233
+ // user who mistypes `--media` and then pastes a path is showing this tool a
234
+ // withheld name, and the refusal's job is to tell them the one supported
235
+ // value, not to repeat what they typed. Anything other than the literal
236
+ // `r2` is refused here so a misspelling cannot silently fall back to a
237
+ // local build.
238
+ if (key === 'media' && value !== 'r2') {
239
+ throw new BuildFailure(
240
+ 'unknown-media-provider',
241
+ `unrecognised media provider; the only supported value is r2\n\n${USAGE}`,
242
+ );
209
243
  }
210
244
  options[key] = value;
211
245
  index += 1;
@@ -216,8 +250,9 @@ function parseArguments(argv) {
216
250
  function parseReviewArguments(argv) {
217
251
  const options = parseArguments(argv);
218
252
  if (options === 'help') return options;
219
- if (options.out !== undefined || options.release) {
220
- const option = options.out !== undefined ? '--out' : '--release';
253
+ if (options.out !== undefined || options.release || options.media !== undefined) {
254
+ const option =
255
+ options.out !== undefined ? '--out' : options.release ? '--release' : '--media';
221
256
  throw new BuildFailure('unknown-option', `review does not accept ${option}\n\n${USAGE}`, option);
222
257
  }
223
258
  return { content: options.content };
@@ -327,6 +362,19 @@ async function build(options) {
327
362
  const contentDirectory = resolve(userDirectory, options.content ?? '.');
328
363
  const outDirectory = resolve(userDirectory, options.out ?? 'dist');
329
364
 
365
+ // **Remote writes require both halves of the opt-in.** `--media r2` alone is
366
+ // refused before a report exists or a note is read: the release gate (public
367
+ // origin, committed exact publish set) is what makes the run deployable, and a
368
+ // media publication whose HTML was never released is the shape that produces
369
+ // remote objects nobody references. The reverse is fine — `--release` without
370
+ // `--media` is the ordinary offline release.
371
+ if (options.media !== undefined && !options.release) {
372
+ throw new BuildFailure(
373
+ 'media-requires-release',
374
+ `--media r2 is only available with --release; an ordinary build cannot write remotely`,
375
+ );
376
+ }
377
+
330
378
  // Opened before anything can fail, and it writes its stub immediately. The
331
379
  // diagnostic is worth most on the run that failed, and a report written where
332
380
  // the counts are convenient — after validation — cannot exist on the run that
@@ -343,7 +391,7 @@ async function build(options) {
343
391
  }
344
392
  assertSafeOutput(outDirectory, contentDirectory, userDirectory);
345
393
 
346
- await buildInto(contentDirectory, outDirectory, report, options.release);
394
+ await buildInto(contentDirectory, outDirectory, report, options.release, options.media);
347
395
  return report;
348
396
  } catch (error) {
349
397
  // Recording the failure must not *replace* it. If this second write throws —
@@ -361,7 +409,7 @@ async function build(options) {
361
409
  }
362
410
  }
363
411
 
364
- async function buildInto(contentDirectory, outDirectory, report, release) {
412
+ async function buildInto(contentDirectory, outDirectory, report, release, media) {
365
413
  // Build inside the package, then copy out. Not an ad-hoc workaround but the
366
414
  // shape the two constraints in this file's header force: cwd must be inside
367
415
  // the package for prerender chunks to resolve their imports, Astro stages
@@ -409,6 +457,28 @@ async function buildInto(contentDirectory, outDirectory, report, release) {
409
457
  );
410
458
  }
411
459
 
460
+ // **The delivery location crosses into the renderer here, and only here.**
461
+ // The pages are evaluated by Astro after the `chdir` below; this environment
462
+ // variable is the same seam `CONFIG_DIRECTORY_VARIABLE` uses, and it is set
463
+ // before `astroBuild()` so every note renders the final absolute URL.
464
+ //
465
+ // Clearing it on an ordinary build is deliberate rather than defensive
466
+ // tidiness: the variable is this tool's internal channel, and a stale value
467
+ // left in a shell must not silently turn a same-origin build into a page
468
+ // that points at somebody's CDN without any object being uploaded.
469
+ const { MEDIA_BASE_URL_VARIABLE } = await import('../src/lib/media-delivery.js');
470
+ if (media === undefined) {
471
+ delete process.env[MEDIA_BASE_URL_VARIABLE];
472
+ } else {
473
+ if (config.media === undefined) {
474
+ throw new BuildFailure(
475
+ 'media-config-missing',
476
+ '--media r2 requires a `media:` table in publish.config.yaml',
477
+ );
478
+ }
479
+ process.env[MEDIA_BASE_URL_VARIABLE] = config.media.publicBaseUrl;
480
+ }
481
+
412
482
  // Tell `astro.config.mjs` where to read the same file from, and this line is
413
483
  // the difference between a configured site and a silently ignored
414
484
  // configuration. Measured without it: a `publish.config.yaml` naming
@@ -522,6 +592,13 @@ async function buildInto(contentDirectory, outDirectory, report, release) {
522
592
  const { copyWasmToOutput } = await import('../scripts/copy-wasm.js');
523
593
  copyWasmToOutput(staging, snapshotDirectory);
524
594
 
595
+ // The image assets the producer authorized and staged beside the artifact,
596
+ // moved into the output after Astro emptied it. No count on the stream: a
597
+ // build's reading surface is fixed, and the report already carries what was
598
+ // published.
599
+ const { copyAssetsToOutput, assetSourceDirectory } = await import('../scripts/copy-assets.js');
600
+ copyAssetsToOutput(staging, validated, assetSourceDirectory(artifact));
601
+
525
602
  const { emitRedirects } = await import('../scripts/emit-redirects.js');
526
603
  emitRedirects(staging);
527
604
 
@@ -539,7 +616,39 @@ async function buildInto(contentDirectory, outDirectory, report, release) {
539
616
  const { assertNoResidue } = await import('../scripts/scan-residue.js');
540
617
  console.log(`residue scan ok: ${assertNoResidue(staging)} files, 0 findings`);
541
618
 
542
- // Only now is the output fit to hand over. Copying after the scan rather than
619
+ // **Ordered publication, not a transaction.** Everything above qualified a
620
+ // private candidate: the publish set, the schema and route contract, the
621
+ // exact output inventory, the secret scan and the residue scan all ran over
622
+ // `staging`, and the user's `--out` has not been touched. Only now, with the
623
+ // gates passed and the HTML already carrying the final custom-domain URLs,
624
+ // may an object be written remotely. `publishMedia` uploads or reuses every
625
+ // declared asset and then reads each one back through the public domain and
626
+ // hashes it; a failure here throws before the promotion below, so the last
627
+ // good local output survives and no candidate with broken links is handed
628
+ // over.
629
+ //
630
+ // An ablation property, not a comment: `publish-media.ts` is imported
631
+ // dynamically only on this branch, and it imports `aws4fetch` only inside
632
+ // its publish function. `tests/media-r2-build.test.ts` records a
633
+ // child-process module trace proving an ordinary build resolves no signer.
634
+ if (media !== undefined) {
635
+ const { publishMedia, resolveMediaCredentials } = await import('../scripts/publish-media.js');
636
+ const credentials = resolveMediaCredentials(config.media);
637
+ const result = await publishMedia({
638
+ assets: validated.assets ?? [],
639
+ assetDirectory: assetSourceDirectory(artifact),
640
+ config: config.media,
641
+ credentials,
642
+ });
643
+ // Integers and literals only. The object keys are public asset paths, but
644
+ // a stream is not the place to enumerate them — the site's own HTML is.
645
+ console.log(
646
+ `media: ${result.referenced} referenced, ${result.uploaded} uploaded, ` +
647
+ `${result.reused} already present, ${result.verified} verified`,
648
+ );
649
+ }
650
+
651
+ // Only now is the output fit to hand over. Copying after the scans rather than
543
652
  // before means a build that fails a gate leaves the user's `dist/` untouched,
544
653
  // holding whatever last passed — the same property `validate-content.ts`
545
654
  // argues for at the front of the chain.
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@wxxb789/anc",
3
3
  "type": "module",
4
- "version": "0.0.1",
4
+ "version": "0.0.3",
5
5
  "description": "Build a static, privacy-preserving site from a directory of Markdown",
6
6
  "keywords": [
7
7
  "static-site-generator",
@@ -74,12 +74,17 @@
74
74
  "!scripts/benchmark-workload-ui.ts",
75
75
  "!scripts/benchmark-workload.ts",
76
76
  "!scripts/release-changelog.ts",
77
+ "!scripts/media-corpus.ts",
78
+ "!scripts/measure-media.ts",
79
+ "!scripts/measure-svg.ts",
77
80
  "!scripts/smoke-tarball.ts"
78
81
  ],
79
82
  "dependencies": {
80
83
  "@astrojs/prism": "4.1.2",
81
84
  "@sqlite.org/sqlite-wasm": "3.53.4-build1",
82
85
  "astro": "7.4.5",
86
+ "aws4fetch": "1.0.20",
87
+ "css-tree": "3.2.1",
83
88
  "github-slugger": "2.0.0",
84
89
  "happy-dom": "20.14.5",
85
90
  "mermaid": "12.2.0",
@@ -87,6 +92,8 @@
87
92
  "prismjs": "1.30.0",
88
93
  "sanitize-html": "2.18.1",
89
94
  "satteri": "0.11.6",
95
+ "saxes": "6.0.0",
96
+ "sharp": "0.35.5",
90
97
  "temml": "0.14.0",
91
98
  "yaml": "2.9.1"
92
99
  }
@@ -0,0 +1,141 @@
1
+ /**
2
+ * Copy the artifact's image assets into the built output.
3
+ *
4
+ * Goal 0009's emission step, and deliberately the only one: the producer decides
5
+ * *which* images publish (it reads, authorizes, hashes and rewrites them), and
6
+ * this step asks the artifact for the resulting URL list and moves those bytes
7
+ * into `dist/_assets/`. No second decision lives here — no image is inferred from
8
+ * a directory, no reference is re-parsed out of Markdown, and nothing is
9
+ * transformed. Under Goal 0011 the staged bytes may already be a WebP candidate;
10
+ * this step still only verifies and copies them, because identity is the emitted
11
+ * bytes the producer hashed.
12
+ *
13
+ * ## Why the bytes come from beside the artifact
14
+ *
15
+ * The artifact carries the public URL plus the header-declared pixel size; never
16
+ * bytes or source paths. Its bytes live in
17
+ * `<artifact directory>/assets/<digest>-<name>` — the published filename — so this
18
+ * step runs identically for the packaged build (a temporary workspace) and for
19
+ * this repository's fixture build (a committed directory) without either being
20
+ * told where the author's notes are. That is also what keeps the content root out
21
+ * of every gate downstream of the producer: the output inventory never learns a
22
+ * source path.
23
+ *
24
+ * ## What it refuses
25
+ *
26
+ * - a declared asset whose bytes are absent, non-regular (a symlink), of a
27
+ * different content digest, or of a format its extension does not name — a
28
+ * content-addressed URL that no longer describes its bytes is the one property
29
+ * this scheme cannot tolerate;
30
+ * - a source directory holding files the artifact does not declare, because that
31
+ * is a stale asset from another build sitting where a publication step reads;
32
+ * - silently copying fewer files than declared. The returned count is the
33
+ * assertion the tests and the inventory both use.
34
+ *
35
+ * The public half of every failure is a count and a literal; the file is the
36
+ * report's. A staged asset name is already public — it is the URL a reader
37
+ * fetches — but the rule is kept uniform rather than reasoned per message.
38
+ */
39
+ import { createHash } from 'node:crypto';
40
+ import { lstatSync, mkdirSync, readFileSync, readdirSync, rmSync, writeFileSync } from 'node:fs';
41
+ import { dirname, join } from 'node:path';
42
+ import { fileURLToPath } from 'node:url';
43
+ import { ASSET_DIRECTORY, imageExtension, parseAssetUrl, sniffImageFormat } from "../src/lib/media.js";
44
+ import { artifactPath, loadArtifact } from "../src/lib/artifact-source.js";
45
+ import { BuildFailure } from "./write-report.js";
46
+ const DIST = fileURLToPath(new URL('../dist', import.meta.url));
47
+ /**
48
+ * The directory the artifact's bytes are staged beside, under the published name.
49
+ *
50
+ * A fixed sibling name rather than a field in the artifact: the pair
51
+ * `<artifact>.json` plus `assets/` is the contract, and one name in one place is
52
+ * what lets the producer, the emission step, and every test agree without a
53
+ * lookup table.
54
+ */
55
+ export function assetSourceDirectory(artifactFile) {
56
+ return join(dirname(artifactFile), 'assets');
57
+ }
58
+ /** The published filename one declared asset URL names. */
59
+ function publishedName(url) {
60
+ const parsed = parseAssetUrl(url);
61
+ if (parsed === undefined) {
62
+ throw new Error(`the artifact declares an asset URL this build cannot parse: ${url}`);
63
+ }
64
+ return { ...parsed, file: `${parsed.digest}-${parsed.fileName}` };
65
+ }
66
+ /**
67
+ * Copy every declared asset into `<root>/_assets/` and return how many were written.
68
+ *
69
+ * @param root The built output directory.
70
+ * @param artifact The validated artifact, whose `assets` list is the exact set.
71
+ * @param sourceDirectory Where the producer staged the bytes; see
72
+ * {@link assetSourceDirectory}.
73
+ */
74
+ export function copyAssetsToOutput(root, artifact, sourceDirectory) {
75
+ const assets = artifact.assets ?? [];
76
+ if (assets.length === 0)
77
+ return 0;
78
+ const declared = assets.map((asset) => ({ url: asset.url, ...publishedName(asset.url) }));
79
+ const accepted = new Set(declared.map((asset) => asset.file));
80
+ let present;
81
+ try {
82
+ present = readdirSync(sourceDirectory);
83
+ }
84
+ catch (error) {
85
+ throw new BuildFailure('asset-source-missing', `the build declares ${assets.length} image asset${assets.length === 1 ? '' : 's'} but staged none`, `could not read the asset staging directory: ${error instanceof Error ? error.message : String(error)}`);
86
+ }
87
+ // A file the artifact does not declare is a stale asset from an earlier build
88
+ // sitting in the one directory a publication step reads. Refused rather than
89
+ // ignored: the next reader would be the output inventory, and the finding would
90
+ // name the output instead of the stale input.
91
+ const extra = present.filter((name) => !accepted.has(name));
92
+ if (extra.length > 0) {
93
+ throw new BuildFailure('asset-source-mismatch', `${extra.length} staged image file${extra.length === 1 ? '' : 's'} match no asset this build publishes`, extra.sort().join('\n'));
94
+ }
95
+ const directory = join(root, ASSET_DIRECTORY);
96
+ rmSync(directory, { recursive: true, force: true });
97
+ mkdirSync(directory, { recursive: true });
98
+ let written = 0;
99
+ for (const asset of declared) {
100
+ const path = join(sourceDirectory, asset.file);
101
+ let bytes;
102
+ try {
103
+ if (!lstatSync(path).isFile()) {
104
+ throw new BuildFailure('asset-source-missing', 'a declared image asset is not a regular file in the staging directory', `${asset.file}: not a regular file`);
105
+ }
106
+ bytes = readFileSync(path);
107
+ }
108
+ catch (error) {
109
+ if (error instanceof BuildFailure)
110
+ throw error;
111
+ throw new BuildFailure('asset-source-missing', 'the build declares an image asset its staging directory does not contain', asset.file);
112
+ }
113
+ const digest = createHash('sha256').update(bytes).digest('hex');
114
+ if (digest !== asset.digest) {
115
+ throw new BuildFailure('asset-source-digest', 'a staged image does not hash to the digest its published URL names', `${asset.file}: expected ${asset.digest}, found ${digest}`);
116
+ }
117
+ const format = sniffImageFormat(bytes);
118
+ const extension = asset.fileName.slice(asset.fileName.lastIndexOf('.'));
119
+ if (format === undefined || imageExtension(format) !== extension) {
120
+ throw new BuildFailure('asset-source-format', 'a staged image is not the format its published URL names', `${asset.file}: detected ${format ?? 'no supported image format'}, URL extension ${extension}`);
121
+ }
122
+ writeFileSync(join(directory, asset.file), bytes);
123
+ written += 1;
124
+ }
125
+ return written;
126
+ }
127
+ /** This repository's own build may print a count; see `verify-output-inventory.ts`. */
128
+ function main() {
129
+ try {
130
+ const artifact = loadArtifact();
131
+ const count = copyAssetsToOutput(DIST, artifact, assetSourceDirectory(artifactPath()));
132
+ console.log(`image assets ok: ${count} files`);
133
+ return 0;
134
+ }
135
+ catch (error) {
136
+ console.error(error instanceof BuildFailure ? error.detail : error instanceof Error ? error.message : String(error));
137
+ return 1;
138
+ }
139
+ }
140
+ if (process.argv[1] === fileURLToPath(import.meta.url))
141
+ process.exit(main());
@@ -122,6 +122,24 @@ export const CONFIG_TEMPLATE = `# ${CONFIG_FILENAME} — every key is optional,
122
122
  # sentences. Without it, the default is English.
123
123
  # language: zh-CN
124
124
 
125
+ # Optional managed image hosting (Cloudflare R2). This table is read only by
126
+ # the installed command's "build --release --media r2"; every ordinary build
127
+ # and preview stays offline even when this table and the credentials exist. Images publish under
128
+ # their content-addressed /_assets/ path, the same path a same-origin build
129
+ # uses, and the site then points at the public custom domain below. r2.dev is
130
+ # refused: it is development-only, rate-limited, and not normally cached.
131
+ #
132
+ # Create an R2 API token with Object Read & Write for this bucket, connect a
133
+ # custom domain to the bucket, and export the two variables in the build
134
+ # environment. Never put their values in this file.
135
+ # media:
136
+ # provider: r2
137
+ # bucket: my-images
138
+ # endpoint: https://<account-id>.r2.cloudflarestorage.com
139
+ # public_base_url: https://media.example.org
140
+ # access_key_id_env: R2_ACCESS_KEY_ID
141
+ # secret_access_key_env: R2_SECRET_ACCESS_KEY
142
+
125
143
  # A single note can also withhold itself, which no glob can re-include:
126
144
  #
127
145
  # ---