create-website-build-kit 0.1.14 → 0.1.16
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/index.mjs +34 -2
- package/package.json +1 -1
- package/template/CLAUDE.md +29 -0
- package/template/docs/handover.md +30 -0
- package/template/package-lock.json +2 -27
- package/template/package.json +27 -23
- package/template/scripts/build.mjs +10 -0
- package/template/scripts/check-cms.mjs +509 -0
- package/template/scripts/check-contrast.mjs +218 -0
- package/template/scripts/check-drift.mjs +291 -0
- package/template/scripts/lib/binary-files.mjs +79 -0
- package/template/scripts/lib/literal-images.mjs +95 -0
- package/template/scripts/md-to-pdf.mjs +28 -1
- package/template/scripts/optimize-media.mjs +94 -7
- package/template/src/components/Img.astro +19 -2
- package/template/src/lib/image-key.ts +47 -0
|
@@ -27,7 +27,34 @@
|
|
|
27
27
|
import { readFileSync, writeFileSync, mkdirSync, rmSync } from 'node:fs';
|
|
28
28
|
import { dirname, basename, resolve } from 'node:path';
|
|
29
29
|
|
|
30
|
-
|
|
30
|
+
/*
|
|
31
|
+
* ⚠ puppeteer IS A TRANSITIVE DEPENDENCY, NOT A DECLARED ONE. The comment above
|
|
32
|
+
* is true only while `pa11y-ci` is a devDependency of this project. Run pa11y
|
|
33
|
+
* as `npx --yes pa11y-ci` instead — which a project reasonably might — and
|
|
34
|
+
* puppeteer is never installed here at all.
|
|
35
|
+
*
|
|
36
|
+
* A fork that did exactly that compensated by hunting for a puppeteer inside
|
|
37
|
+
* `~/.npm/_npx`, found a stale one whose bundled Chrome would not launch, and
|
|
38
|
+
* timed out after thirty seconds with nothing pointing at the cause.
|
|
39
|
+
*
|
|
40
|
+
* So say it plainly rather than let a bare import throw ERR_MODULE_NOT_FOUND:
|
|
41
|
+
* a script whose dependency is a side effect of how you happened to run a
|
|
42
|
+
* different script is a script that breaks later, on someone else's machine,
|
|
43
|
+
* for reasons that look unrelated.
|
|
44
|
+
*/
|
|
45
|
+
let puppeteer;
|
|
46
|
+
try {
|
|
47
|
+
puppeteer = (await import('puppeteer')).default;
|
|
48
|
+
} catch {
|
|
49
|
+
console.error(
|
|
50
|
+
`\nmd-to-pdf needs puppeteer, which is not installed.\n\n` +
|
|
51
|
+
` It normally arrives with pa11y-ci, so this usually means pa11y-ci was\n` +
|
|
52
|
+
` removed from devDependencies, or is being run as \`npx --yes pa11y-ci\`\n` +
|
|
53
|
+
` rather than installed.\n\n` +
|
|
54
|
+
` Fix: npm install --save-dev puppeteer\n`,
|
|
55
|
+
);
|
|
56
|
+
process.exit(1);
|
|
57
|
+
}
|
|
31
58
|
|
|
32
59
|
const [, , input, outputArg] = process.argv;
|
|
33
60
|
if (!input) {
|
|
@@ -16,9 +16,18 @@
|
|
|
16
16
|
import sharp from 'sharp';
|
|
17
17
|
import fs from 'node:fs/promises';
|
|
18
18
|
import path from 'node:path';
|
|
19
|
-
import { fileURLToPath } from 'node:url';
|
|
20
19
|
|
|
21
|
-
|
|
20
|
+
/*
|
|
21
|
+
* ⚠ THE PROJECT ROOT IS THE CWD, as it is in every other script here.
|
|
22
|
+
*
|
|
23
|
+
* This resolved from the SCRIPT's own location. In every supported flow the
|
|
24
|
+
* two are the same directory — `npm run media` runs with the cwd at the
|
|
25
|
+
* package root — so the change is a no-op in use. What it was not was
|
|
26
|
+
* testable: the script read its own media/source/ no matter where it was
|
|
27
|
+
* pointed, so a fixture could not exercise it at all, and the run that added
|
|
28
|
+
* a non-zero exit had nothing proving the exit fires.
|
|
29
|
+
*/
|
|
30
|
+
const ROOT = process.cwd();
|
|
22
31
|
const SRC = path.join(ROOT, 'media/source');
|
|
23
32
|
const OUT = path.join(ROOT, 'public/img');
|
|
24
33
|
const MANIFEST = path.join(ROOT, 'src/data/image-manifest.json');
|
|
@@ -56,7 +65,25 @@ const WEBP = { quality: 78, effort: 6 };
|
|
|
56
65
|
const AVIF = { quality: 55, effort: 4 };
|
|
57
66
|
const FORMATS = ['avif', 'webp'];
|
|
58
67
|
|
|
59
|
-
const
|
|
68
|
+
const RESET = '\x1b[0m';
|
|
69
|
+
const RED = '\x1b[31m';
|
|
70
|
+
const YELLOW = '\x1b[33m';
|
|
71
|
+
const DIM = '\x1b[2m';
|
|
72
|
+
|
|
73
|
+
/*
|
|
74
|
+
* ⚠ heic/heif ARE HERE ON PURPOSE. libvips in the sharp this kit already ships
|
|
75
|
+
* reads them — check with `node -e "console.log(require('sharp').format.heif)"`.
|
|
76
|
+
* Leaving them out meant the single likeliest wrong format, a photo straight
|
|
77
|
+
* off an iPhone, was discarded by our own regex while the library underneath
|
|
78
|
+
* handled it fine. It produced no output, no warning and no manifest entry.
|
|
79
|
+
*/
|
|
80
|
+
const RASTER = /\.(jpe?g|png|webp|tiff?|heic|heif)$/i;
|
|
81
|
+
|
|
82
|
+
/* Sources are committed forever. Output is capped by the width ladder, so a
|
|
83
|
+
huge original costs visitors nothing and costs the REPO permanently — say so
|
|
84
|
+
in those words, or someone "fixes" a page-speed problem that does not exist. */
|
|
85
|
+
const BIG_PIXELS = 24_000_000;
|
|
86
|
+
const BIG_BYTES = 8 * 1024 * 1024;
|
|
60
87
|
|
|
61
88
|
/*
|
|
62
89
|
* ── DO NOT RESET THIS TO {} ────────────────────────────────────────────────
|
|
@@ -362,11 +389,45 @@ const before = await bytes(SRC);
|
|
|
362
389
|
console.log('optimizing media…');
|
|
363
390
|
await copyBrand(files.filter((f) => f.includes(`${path.sep}brand${path.sep}`)));
|
|
364
391
|
|
|
365
|
-
|
|
392
|
+
/*
|
|
393
|
+
* ⚠ THE RUN MUST NAME EVERYTHING IT DID NOT PRODUCE.
|
|
394
|
+
*
|
|
395
|
+
* This loop used to be `files.filter(RASTER)`. Anything else was dropped with
|
|
396
|
+
* no output, no warning and no manifest entry — so a `.heic` in media/source/
|
|
397
|
+
* simply was not on the site, and the failure surfaced much later as <Img>
|
|
398
|
+
* throwing "no manifest entry for …" against a file plainly sitting in the
|
|
399
|
+
* repo. That reads as a bug in the kit rather than a rejected upload.
|
|
400
|
+
*
|
|
401
|
+
* And there was no try/catch, so one corrupt file aborted the run midway —
|
|
402
|
+
* after outputs were written and the manifest was partly updated, leaving the
|
|
403
|
+
* manifest describing a state on disk that no longer matched it.
|
|
404
|
+
*/
|
|
405
|
+
const skipped = [];
|
|
406
|
+
const failed = [];
|
|
407
|
+
const oversized = [];
|
|
408
|
+
|
|
409
|
+
for (const file of files) {
|
|
366
410
|
if (file.includes(`${path.sep}brand${path.sep}`)) continue;
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
411
|
+
const rel = path.relative(SRC, file);
|
|
412
|
+
|
|
413
|
+
if (!RASTER.test(file)) {
|
|
414
|
+
skipped.push(rel);
|
|
415
|
+
continue;
|
|
416
|
+
}
|
|
417
|
+
|
|
418
|
+
try {
|
|
419
|
+
const { size } = await fs.stat(file);
|
|
420
|
+
const meta = await sharp(file).metadata();
|
|
421
|
+
if (size > BIG_BYTES || (meta.width ?? 0) * (meta.height ?? 0) > BIG_PIXELS) {
|
|
422
|
+
oversized.push(`${rel} ${meta.width}x${meta.height}, ${mb(size)}`);
|
|
423
|
+
}
|
|
424
|
+
|
|
425
|
+
if (file.includes(`${path.sep}certifications${path.sep}`)) await emitSingle(file);
|
|
426
|
+
else if (file.includes(`${path.sep}blog${path.sep}`)) await emitResponsive(file, BLOG_WIDTHS);
|
|
427
|
+
else await emitResponsive(file, PHOTO_WIDTHS);
|
|
428
|
+
} catch (err) {
|
|
429
|
+
failed.push(`${rel} ← ${String(err.message).split('\n')[0]}`);
|
|
430
|
+
}
|
|
370
431
|
}
|
|
371
432
|
|
|
372
433
|
await emitFavicons();
|
|
@@ -378,3 +439,29 @@ await fs.writeFile(MANIFEST, JSON.stringify(manifest, null, 2) + '\n');
|
|
|
378
439
|
const after = await bytes(OUT);
|
|
379
440
|
console.log(`\nsource ${mb(before)} → dist ${mb(after)} (${Math.round((1 - after / before) * 100)}% smaller)`);
|
|
380
441
|
console.log(`${Object.keys(manifest).length} images in manifest`);
|
|
442
|
+
|
|
443
|
+
/* A report, not an error. A PDF or a .txt in media/source/ is legitimate — the
|
|
444
|
+
kit's own comp lives there — so this says what produced no image and lets the
|
|
445
|
+
reader judge. Only a genuine processing failure exits non-zero. */
|
|
446
|
+
if (skipped.length) {
|
|
447
|
+
console.log(`\n${YELLOW}!${RESET} ${skipped.length} file(s) produced no image (not a raster format):`);
|
|
448
|
+
for (const f of skipped) console.log(` ${DIM}${f}${RESET}`);
|
|
449
|
+
}
|
|
450
|
+
|
|
451
|
+
if (oversized.length) {
|
|
452
|
+
console.log(`\n${YELLOW}!${RESET} ${oversized.length} oversized source(s) — the SITE is unaffected:`);
|
|
453
|
+
for (const f of oversized) console.log(` ${DIM}${f}${RESET}`);
|
|
454
|
+
console.log(
|
|
455
|
+
` ${DIM}Output is capped by the width ladder, so visitors never download these.\n` +
|
|
456
|
+
` The cost is the repository, which carries them forever.${RESET}`,
|
|
457
|
+
);
|
|
458
|
+
}
|
|
459
|
+
|
|
460
|
+
if (failed.length) {
|
|
461
|
+
console.error(`\n${RED}✗ ${failed.length} file(s) failed to process:${RESET}`);
|
|
462
|
+
for (const f of failed) console.error(` ${f}`);
|
|
463
|
+
console.error(
|
|
464
|
+
`\n ${DIM}Every other file was still written and the manifest is complete for them.${RESET}\n`,
|
|
465
|
+
);
|
|
466
|
+
process.exit(1);
|
|
467
|
+
}
|
|
@@ -9,6 +9,7 @@
|
|
|
9
9
|
* content migration.
|
|
10
10
|
*/
|
|
11
11
|
import manifest from '../data/image-manifest.json';
|
|
12
|
+
import { isPickerPath, toImageKey } from '../lib/image-key';
|
|
12
13
|
|
|
13
14
|
interface Props {
|
|
14
15
|
/** Manifest key, e.g. "photos/hero-home". */
|
|
@@ -36,13 +37,29 @@ const {
|
|
|
36
37
|
style,
|
|
37
38
|
} = Astro.props;
|
|
38
39
|
|
|
39
|
-
|
|
40
|
+
/* A CMS picker returns `/img/photos/hero-1200.webp`, never `photos/hero`.
|
|
41
|
+
Normalising here is what lets an image field be a real picker instead of a
|
|
42
|
+
free-text box asking the client to type a manifest key from memory. */
|
|
43
|
+
const key = toImageKey(name);
|
|
44
|
+
const entry = (manifest as Record<string, { src: string; srcset: string; width: number; height: number; widths?: number[]; avifSrcset?: string | null }>)[key];
|
|
40
45
|
|
|
41
46
|
if (!entry) {
|
|
42
47
|
// Fail the build, not the page. A missing image is a content bug and should
|
|
43
48
|
// never reach a deploy as a silently broken <img>.
|
|
49
|
+
//
|
|
50
|
+
// ⚠ THE THROWN TEXT IS PURE ASCII ON PURPOSE. The Cloudflare adapter puts a
|
|
51
|
+
// prerender failure into the `x-astro-prerender-error` HTTP HEADER, and a
|
|
52
|
+
// header carrying non-ASCII warns loudly and arrives mangled: an em dash
|
|
53
|
+
// came back as `â`. Comments may use the house style; anything inside a
|
|
54
|
+
// thrown string may not. See docs/traps.md.
|
|
44
55
|
throw new Error(
|
|
45
|
-
|
|
56
|
+
isPickerPath(name)
|
|
57
|
+
? `Img: "${name}" was uploaded straight into public/img/, which is where the media pipeline ` +
|
|
58
|
+
`WRITES, so it has no responsive variants, no width/height and no manifest entry.\n\n` +
|
|
59
|
+
`Point the CMS media source at media/source/ (what the pipeline READS), re-upload, ` +
|
|
60
|
+
`then run \`npm run media\`.\n\n` +
|
|
61
|
+
`The last good deploy is still live; this change just will not appear.`
|
|
62
|
+
: `Img: no manifest entry for "${name}" (resolved to "${key}"). Run \`npm run media\` after adding it to media/source/.`,
|
|
46
63
|
);
|
|
47
64
|
}
|
|
48
65
|
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Turn a public image PATH back into the manifest KEY it came from.
|
|
3
|
+
*
|
|
4
|
+
* ── WHY THIS EXISTS ────────────────────────────────────────────────────────
|
|
5
|
+
* `<Img>` resolves a manifest key — `photos/hero`. A CMS image picker cannot
|
|
6
|
+
* return one: it browses files and returns the public path of what it found,
|
|
7
|
+
* `/img/photos/hero-1200.webp`, because it has no idea a manifest exists.
|
|
8
|
+
*
|
|
9
|
+
* Without this, every image field in a CMS-managed site has to be a plain
|
|
10
|
+
* string with a description asking a non-technical editor to type a key from
|
|
11
|
+
* memory. That is not an editable field, it is a quiz — and it is why image
|
|
12
|
+
* editing was the part of the CMS that clients could never actually use.
|
|
13
|
+
*
|
|
14
|
+
* The two can meet because `optimize-media.mjs` writes exactly one shape:
|
|
15
|
+
*
|
|
16
|
+
* /img/ + <key> -<width>.<ext>
|
|
17
|
+
*
|
|
18
|
+
* so the mapping back is exact rather than a guess.
|
|
19
|
+
*
|
|
20
|
+
* ── THREE PROPERTIES THAT MAKE IT SAFE ─────────────────────────────────────
|
|
21
|
+
* 1. **Which variant the editor clicks does not matter.** `-480` and `-1800`
|
|
22
|
+
* normalise to the same key and render the identical full srcset.
|
|
23
|
+
* 2. **A key ending in a digit survives.** The width strip is anchored to the
|
|
24
|
+
* extension, so `photos/gift-card-slider-1`, reached via `…-1-480.webp`,
|
|
25
|
+
* comes back whole. An unanchored `-\d+` would eat the `-1`.
|
|
26
|
+
* 3. ⚠ **It must not assume `.webp`.** The kit emits AVIF alongside WebP and
|
|
27
|
+
* social cards are `.jpg`, so the extension strip stays generic.
|
|
28
|
+
*/
|
|
29
|
+
|
|
30
|
+
/** `/img/photos/hero-1200.webp` → `photos/hero`. Anything else is returned as-is. */
|
|
31
|
+
export const toImageKey = (nameOrPath: string): string =>
|
|
32
|
+
isPickerPath(nameOrPath)
|
|
33
|
+
? nameOrPath
|
|
34
|
+
.replace(/^\/img\//, '')
|
|
35
|
+
.replace(/-\d+(?=\.[a-z0-9]+$)/i, '')
|
|
36
|
+
.replace(/\.[a-z0-9]+$/i, '')
|
|
37
|
+
: nameOrPath;
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* Did this come from a media picker rather than being a manifest key?
|
|
41
|
+
*
|
|
42
|
+
* Used to tell an editor what actually went wrong. `/img/` is where the
|
|
43
|
+
* pipeline WRITES, so a value pointing there is either a processed variant
|
|
44
|
+
* (fine — `toImageKey` handles it) or a file uploaded straight into the output
|
|
45
|
+
* directory, which has no variants and no manifest entry at all.
|
|
46
|
+
*/
|
|
47
|
+
export const isPickerPath = (nameOrPath: string): boolean => nameOrPath.startsWith('/img/');
|