@wxxb789/anc 0.0.2 → 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 +63 -34
- package/bin/anc.mjs +120 -11
- package/package.json +8 -1
- package/scripts/copy-assets.js +141 -0
- package/scripts/init-repository.js +18 -0
- package/scripts/load-config.js +258 -5
- package/scripts/markdown-to-artifact.js +310 -6
- package/scripts/publish-media.js +468 -0
- package/scripts/resolve-links.js +318 -42
- package/scripts/scan-residue.js +17 -6
- package/scripts/verify-output-inventory.js +53 -0
- package/src/lib/content.js +22 -0
- package/src/lib/font-coverage.js +845 -0
- package/src/lib/markdown.js +257 -11
- package/src/lib/media-codec.js +355 -0
- package/src/lib/media-delivery.js +76 -0
- package/src/lib/media.js +1426 -0
- package/src/lib/schema.js +83 -8
- package/src/lib/svg-css.js +685 -0
- package/src/lib/svg-policy.js +1699 -0
- package/src/lib/svg-refusal.js +1 -0
- package/src/lib/svg-render.js +343 -0
- package/src/pages/notes/[slug].astro +10 -1
- package/src/pages/privacy.astro +9 -5
- package/src/styles/global.css +29 -0
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)
|
|
4
|
+
[](https://www.npmjs.com/package/@wxxb789/anc)
|
|
4
5
|
[](#working-on-the-tool)
|
|
5
6
|
[](https://github.com/wxxb789/anc/actions/workflows/verify.yml)
|
|
6
7
|
[](LICENSE)
|
|
@@ -22,7 +23,8 @@ Ordinary reading needs no JavaScript, and nothing is sent to a third party.
|
|
|
22
23
|

|
|
23
24
|
|
|
24
25
|
*anc* is short for **A**ctive **N**oise **C**ancelling. The command it installs is
|
|
25
|
-
`anc`; the package is
|
|
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,
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
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
|
-
|
|
111
|
+
Install it in the notes repository you want to publish:
|
|
107
112
|
|
|
108
113
|
```bash
|
|
109
|
-
|
|
110
|
-
|
|
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
|
-
|
|
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
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
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
|
-
-
|
|
194
|
-
|
|
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/`.
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
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 =
|
|
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
|
|
205
|
-
// above has already rejected everything else — so it is a
|
|
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(
|
|
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 =
|
|
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
|
-
//
|
|
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.
|
|
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
|
# ---
|