create-website-build-kit 0.1.15 → 0.1.17

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 CHANGED
@@ -108,9 +108,41 @@ if (!existsSync(join(dest, '.gitignore'))) {
108
108
  const pkgPath = join(dest, 'package.json');
109
109
  const pkg = JSON.parse(readFileSync(pkgPath, 'utf8'));
110
110
  pkg.name = name.replace(/[^a-z0-9-]+/gi, '-').replace(/^-+|-+$/g, '').toLowerCase() || 'site';
111
- writeFileSync(pkgPath, `${JSON.stringify(pkg, null, 2)}\n`);
112
111
 
113
- console.log(` ${GREEN}✓${RESET} template → ${dir}/`);
112
+ /*
113
+ * ── WHICH KIT THIS SITE CAME FROM ──────────────────────────────────────────
114
+ * ⚠ THE TEMPLATE IS COPIED, NOT LINKED. Nothing the kit fixes afterwards ever
115
+ * reaches a site already built — not a trap, not a gate, not a pipeline
116
+ * change. A shipped site sat 19% behind on every image for weeks after AVIF
117
+ * landed, and it surfaced only because somebody happened to read both trees
118
+ * for an unrelated reason.
119
+ *
120
+ * Without a stamp, "is this site current?" is archaeology: compare files by
121
+ * eye against a repo whose history you have to guess at. With one it is
122
+ * reading a line.
123
+ *
124
+ * The VERSION, not the commit: every release is tagged, so this resolves to a
125
+ * commit in one lookup, and embedding a commit would mean the packer's git
126
+ * state deciding what ships. It goes in package.json because that is the file
127
+ * a developer opens first and nobody deletes.
128
+ */
129
+ const kitPkg = JSON.parse(
130
+ readFileSync(join(dirname(fileURLToPath(import.meta.url)), 'package.json'), 'utf8'),
131
+ );
132
+ const stamp = { version: kitPkg.version, scaffolded: new Date().toISOString().slice(0, 10) };
133
+
134
+ /* Placed straight after `version` rather than appended, so it is visible
135
+ without scrolling past the dependency list. */
136
+ const ordered = {};
137
+ for (const [key, value] of Object.entries(pkg)) {
138
+ ordered[key] = value;
139
+ if (key === 'version') ordered.websiteBuildKit = stamp;
140
+ }
141
+ if (!ordered.websiteBuildKit) ordered.websiteBuildKit = stamp;
142
+
143
+ writeFileSync(pkgPath, `${JSON.stringify(ordered, null, 2)}\n`);
144
+
145
+ console.log(` ${GREEN}✓${RESET} template → ${dir}/${DIM} (kit ${stamp.version})${RESET}`);
114
146
 
115
147
  const run = (cmd, args, label) => {
116
148
  const r = spawnSync(cmd, args, { cwd: dest, stdio: 'ignore', shell: process.platform === 'win32' });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "create-website-build-kit",
3
- "version": "0.1.15",
3
+ "version": "0.1.17",
4
4
  "description": "Scaffold a production marketing site \u2014 Astro on Cloudflare Workers, with the gates, the migration playbook and the accessibility work already wired.",
5
5
  "keywords": [
6
6
  "astro",
@@ -95,6 +95,27 @@ The rules whose failure looks like success — double-counted pageviews, a conve
95
95
  two pipes, a trigger on `sent=1` that catches almost nothing — are in `docs/analytics.md`.
96
96
  Read it before adding any tag.
97
97
 
98
+ **`package.json` records which kit this site came from.** `websiteBuildKit.version` is stamped at
99
+ scaffold time and is never updated afterwards, because ⚠ **the template is copied, not linked** —
100
+ nothing the kit fixes later reaches this site. A trap closed upstream, a gate added, a pipeline
101
+ improved: none of it arrives. One site sat 19% behind on every image for weeks after AVIF landed.
102
+ Compare that version against the kit's releases when something here looks older than it should.
103
+
104
+ **`npm run check:drift` says what this site is behind on.** The kit is copied, not linked, so
105
+ nothing fixed upstream arrives here. It reports and changes nothing.
106
+
107
+ **Text over a photograph is measured, not forbidden.** Declare the region in
108
+ `src/data/contrast.json` — image, box, scrim strength, text colour — and `npm run check:contrast`
109
+ composites it and fails production below 4.5:1. ⚠ **The danger is never the photograph, it is a
110
+ weakened scrim.** On a real site two of three regions could not fail at any photograph; the one
111
+ exposure was a scrim lightened from 92% to 62% so a client's photography could show its colour.
112
+ This check is what makes weakening one safe.
113
+
114
+ **Navigation may be CMS-managed; redirects may not.** `check:cms` resolves every internal path in
115
+ CMS-managed data against `src/pages`, so a menu item pointing at a missing page fails the build
116
+ rather than 404ing for a visitor. A redirect has no such check — a client toggling one off is
117
+ silent traffic loss — so it stays in code.
118
+
98
119
  ⚠ **A CMS DELETES EVERY KEY ITS SCHEMA FORGOT.** It rewrites the whole file from the schema, so
99
120
  anything undeclared is absent from what it writes back — the client changes one field, saves, and
100
121
  the rest is gone, looking like an ordinary content commit. `npm run check:cms` refuses a
@@ -8,36 +8,38 @@
8
8
  "node": ">=22.12.0"
9
9
  },
10
10
  "scripts": {
11
- "dev": "astro dev",
12
- "check": "astro check",
13
11
  "a11y": "node scripts/check-a11y.mjs",
14
12
  "a11y:evidence": "node scripts/a11y-evidence.mjs",
15
- "reflow": "node scripts/check-reflow.mjs",
16
- "console": "node scripts/check-console.mjs",
17
- "shots": "node scripts/shots.mjs",
18
- "tells": "node scripts/tells.mjs",
19
- "media": "node scripts/optimize-media.mjs",
13
+ "build": "astro build",
14
+ "build:production": "node scripts/build.mjs production",
15
+ "build:staging": "node scripts/build.mjs staging",
20
16
  "cards": "node scripts/og-cards.mjs",
21
- "lastmod": "node scripts/lastmod.mjs",
22
- "indexnow": "node scripts/indexnow.mjs",
23
- "verify": "node scripts/verify.mjs",
17
+ "check": "astro check",
18
+ "check:cms": "node scripts/check-cms.mjs",
19
+ "check:contrast": "node scripts/check-contrast.mjs",
20
+ "check:copy": "node scripts/check-copy.mjs",
21
+ "check:drift": "node scripts/check-drift.mjs",
22
+ "check:form": "node scripts/check-form.mjs",
23
+ "check:secrets": "node scripts/check-secrets.mjs",
24
24
  "check:sitemap": "node scripts/check-sitemap.mjs",
25
- "recon": "node scripts/recon.mjs",
26
- "extract": "node scripts/extract.mjs",
25
+ "console": "node scripts/check-console.mjs",
26
+ "deploy:production": "npm run build:production && wrangler deploy && node scripts/check-secrets.mjs",
27
+ "deploy:staging": "npm run build:staging && wrangler deploy && node scripts/check-secrets.mjs",
28
+ "dev": "astro dev",
27
29
  "dns": "node scripts/dns-snapshot.mjs",
28
- "seo": "npx --yes @nurkamol/seo-audit@1",
29
- "redirects": "node scripts/redirects.mjs",
30
+ "extract": "node scripts/extract.mjs",
30
31
  "handover": "node scripts/md-to-pdf.mjs docs/handover.md docs/handover.pdf",
31
- "build": "astro build",
32
- "build:staging": "node scripts/build.mjs staging",
33
- "build:production": "node scripts/build.mjs production",
32
+ "indexnow": "node scripts/indexnow.mjs",
33
+ "lastmod": "node scripts/lastmod.mjs",
34
+ "media": "node scripts/optimize-media.mjs",
34
35
  "preview": "wrangler dev",
35
- "deploy:staging": "npm run build:staging && wrangler deploy && node scripts/check-secrets.mjs",
36
- "deploy:production": "npm run build:production && wrangler deploy && node scripts/check-secrets.mjs",
37
- "check:secrets": "node scripts/check-secrets.mjs",
38
- "check:copy": "node scripts/check-copy.mjs",
39
- "check:form": "node scripts/check-form.mjs",
40
- "check:cms": "node scripts/check-cms.mjs"
36
+ "recon": "node scripts/recon.mjs",
37
+ "redirects": "node scripts/redirects.mjs",
38
+ "reflow": "node scripts/check-reflow.mjs",
39
+ "seo": "npx --yes @nurkamol/seo-audit@1",
40
+ "shots": "node scripts/shots.mjs",
41
+ "tells": "node scripts/tells.mjs",
42
+ "verify": "node scripts/verify.mjs"
41
43
  },
42
44
  "dependencies": {
43
45
  "@astrojs/cloudflare": "^14.1.7",
@@ -122,6 +122,10 @@ step(process.execPath, ['scripts/check-env.mjs']);
122
122
 
123
123
  if (env === 'production') {
124
124
  step(process.execPath, ['scripts/check-sitemap.mjs']);
125
+ /* Production only: it measures the GENERATED images, and a staging build is
126
+ often run before `npm run media` has caught up. A no-op until a project
127
+ declares regions — the template has no design and therefore none. */
128
+ step(process.execPath, ['scripts/check-contrast.mjs']);
125
129
  }
126
130
 
127
131
  /* A sanity line, so the log says which environment actually ran rather than
@@ -41,6 +41,8 @@
41
41
  import { existsSync, readFileSync, readdirSync, statSync } from 'node:fs';
42
42
  import { join, relative, sep } from 'node:path';
43
43
  import { parse } from 'yaml';
44
+ import { literalImages } from './lib/literal-images.mjs';
45
+ import { routeExists, routesFromPages } from './lib/routes.mjs';
44
46
 
45
47
  const RESET = '\x1b[0m';
46
48
  const RED = '\x1b[31m';
@@ -321,6 +323,83 @@ for (const entry of entries) {
321
323
  }
322
324
  }
323
325
 
326
+ /* ── internal links a client can type ────────────────────────────────────── */
327
+
328
+ /*
329
+ * ⚠ THIS IS WHAT MAKES NAVIGATION SAFE TO PUT IN A CMS.
330
+ *
331
+ * `stacks.md` kept nav out of the CMS for a good reason — a bad value should
332
+ * fail the build, not publish. A typo'd path gives a menu item leading to a
333
+ * 404: the page renders, nothing errors, and only a visitor finds it.
334
+ *
335
+ * But navigation was missing from all five audited sites, so every client had
336
+ * to ask for a menu change. That is not a rule being respected, it is a gap
337
+ * the rule creates. The answer is not to forbid the field, it is to verify
338
+ * it — before the build, while somebody is still looking at the config.
339
+ *
340
+ * ⚠ A DYNAMIC ROUTE IS A PATTERN. `[slug].astro` serves every legal page, so
341
+ * treating routes as literal strings would report most of a site as broken.
342
+ * `routesFromPages` returns patterns for those and `routeExists` matches them.
343
+ *
344
+ * External links, `mailto:`, `tel:` and bare anchors are somebody else's
345
+ * problem — `verify` checks those against the deployed site, where they can
346
+ * actually be resolved.
347
+ */
348
+ const LINKISH = /(^|\.)(href|url|link|to|target|destination)$/i;
349
+ const routes = routesFromPages();
350
+ const brokenLinks = [];
351
+
352
+ if (routes.static.size || routes.dynamic.length) {
353
+ for (const entry of entries) {
354
+ const documents = [];
355
+ if (entry?.type === 'collection' && existsSync(entry.path ?? '')) {
356
+ const walk = (d) =>
357
+ readdirSync(d).flatMap((e) => {
358
+ const full = join(d, e);
359
+ return statSync(full).isDirectory() ? walk(full) : [full];
360
+ });
361
+ for (const file of walk(entry.path).filter((f) => /\.mdx?$/.test(f))) {
362
+ const m = /^---\r?\n([\s\S]*?)\r?\n---/.exec(readFileSync(file, 'utf8'));
363
+ if (!m) continue;
364
+ try {
365
+ documents.push({ where: rel(file), data: parse(m[1]) ?? {} });
366
+ } catch {
367
+ /* reported elsewhere */
368
+ }
369
+ }
370
+ } else if (/\.json$/.test(entry?.path ?? '') && existsSync(entry.path)) {
371
+ try {
372
+ documents.push({ where: rel(entry.path), data: JSON.parse(readFileSync(entry.path, 'utf8')) });
373
+ } catch {
374
+ /* reported above */
375
+ }
376
+ }
377
+
378
+ for (const doc of documents) {
379
+ const visit = (value, path) => {
380
+ if (Array.isArray(value)) return value.forEach((v) => visit(v, path));
381
+ if (value && typeof value === 'object') {
382
+ for (const [k, v] of Object.entries(value)) visit(v, path ? `${path}.${k}` : k);
383
+ return;
384
+ }
385
+ if (typeof value !== 'string' || !LINKISH.test(path)) return;
386
+ if (!value.startsWith('/')) return; // external, mailto:, tel:, #anchor
387
+ if (routeExists(value, routes)) return;
388
+ brokenLinks.push({ where: doc.where, path, value });
389
+ };
390
+ visit(doc.data, '');
391
+ }
392
+ }
393
+ }
394
+
395
+ if (brokenLinks.length) {
396
+ problems.push({
397
+ label: 'internal links',
398
+ why: `${brokenLinks.length} point at a page this site does not serve`,
399
+ links: brokenLinks,
400
+ });
401
+ }
402
+
324
403
  /* ── coverage, and secrets ───────────────────────────────────────────────── */
325
404
 
326
405
  /*
@@ -375,6 +454,70 @@ if (uncovered.length) {
375
454
  );
376
455
  }
377
456
 
457
+ /*
458
+ * ⚠ A CLIENT GUIDE DOES NOT GO OUT OF DATE GRACEFULLY. IT STARTS LYING.
459
+ *
460
+ * `docs/handover.md` is the only document written for the client. One
461
+ * project's was written when the CMS had six entries; it had thirteen by the
462
+ * time anyone looked, and nothing noticed. That is the mild half.
463
+ *
464
+ * The serious half is that it still said the address and phone number "are
465
+ * not editable" — which stopped being true the day those moved into the CMS.
466
+ * A client reading that either asks you to do something she can do herself,
467
+ * or assumes her address updates everywhere on its own because the document
468
+ * told her the site owned it.
469
+ *
470
+ * Only the client ever finds out. So: every entry the CMS shows should be
471
+ * named in the guide. A warning, not a failure — what the guide says is a
472
+ * judgement, and a section deliberately left out is a decision.
473
+ */
474
+ const GUIDE = join('docs', 'handover.md');
475
+
476
+ if (existsSync(GUIDE) && entries.length) {
477
+ const guide = readFileSync(GUIDE, 'utf8').toLowerCase();
478
+ const unmentioned = entries
479
+ .map((e) => e?.label ?? e?.name)
480
+ .filter(Boolean)
481
+ .filter((label) => !guide.includes(String(label).toLowerCase()));
482
+ if (unmentioned.length) {
483
+ warnings.push(
484
+ `${unmentioned.length} CMS section(s) the client guide never mentions: ` +
485
+ `${unmentioned.join(', ')}.\n` +
486
+ ` ${GUIDE} is the only document written for the client. A section it omits is one ` +
487
+ `they will not know they can edit — and a claim it makes that the CMS has since ` +
488
+ `contradicted is worse, because they will believe it.`,
489
+ );
490
+ }
491
+ }
492
+
493
+ /*
494
+ * ⚠ A SENTENCE SAYING "PHOTOGRAPHS ARE CHOSEN IN CODE" COVERS THE FIELDS THAT
495
+ * EXIST AND EXCUSES THE ONES THAT DO NOT.
496
+ *
497
+ * On a real build that left a header band, four class tiles and a gift-card
498
+ * picture as string literals in `.astro`, while the config claimed images
499
+ * were deliberately developer-controlled. The client opened the page, saw a
500
+ * photograph, and had no way to change it. Nothing was broken; the only
501
+ * symptom was someone looking for a field that was never there.
502
+ *
503
+ * A warning, because a fixed image IS sometimes right — a logo, an
504
+ * illustration that belongs to the layout. The rule is that it must be a
505
+ * decision, not an oversight.
506
+ */
507
+ const literals = literalImages();
508
+ if (literals.length) {
509
+ warnings.push(
510
+ `${literals.length} image(s) hardcoded in pages, which the CMS cannot change:\n` +
511
+ literals
512
+ .slice(0, 10)
513
+ .map((l) => ` ${l.file} ${l.value}`)
514
+ .join('\n') +
515
+ (literals.length > 10 ? `\n …and ${literals.length - 10} more` : '') +
516
+ `\n Each is a field the client does not have. Either give it one, or write down ` +
517
+ `why it is fixed — "chosen in code" stops being true the moment the next one is added.`,
518
+ );
519
+ }
520
+
378
521
  /*
379
522
  * ⚠ A SECRET IN A CMS IS A SECRET THE CLIENT CAN READ AND CHANGE. Analytics
380
523
  * IDs, tokens and keys are technical configuration: their failure mode is
@@ -418,6 +561,16 @@ for (const p of problems) {
418
561
  ` the client will never touch — or move them out of a CMS-managed file.${RESET}`,
419
562
  );
420
563
  }
564
+ if (p.links) {
565
+ for (const l of p.links.slice(0, 8)) {
566
+ console.error(` ${DIM}${l.where} ${l.path} = ${JSON.stringify(l.value)}${RESET}`);
567
+ }
568
+ console.error(
569
+ ` ${DIM}A menu item pointing at a missing page renders perfectly and 404s only\n` +
570
+ ` for a visitor. This is what lets navigation be a CMS field at all: the\n` +
571
+ ` value is checked before the build rather than trusted.${RESET}`,
572
+ );
573
+ }
421
574
  if (p.picker) {
422
575
  console.error(
423
576
  ` ${DIM}The site still renders this: <Img> accepts a manifest key as well as a\n` +
@@ -0,0 +1,218 @@
1
+ /**
2
+ * Contrast of text sitting on a photograph, measured off the real pixels.
3
+ *
4
+ * npm run check:contrast
5
+ *
6
+ * Runs in `build:production`. A failure is a red build, so the last good deploy
7
+ * stays live — which is the correct outcome for a photograph that has made the
8
+ * navigation unreadable.
9
+ *
10
+ * ── THE GAP THIS FILLS ─────────────────────────────────────────────────────
11
+ * ⚠ NO ACCESSIBILITY RUNNER CAN SEE THIS. axe and pa11y report a flat ~1.01:1
12
+ * for every text-over-image case, because neither composites a transparent
13
+ * element over the pixels behind it. So the failure is invisible to `a11y`,
14
+ * invisible to the build, and invisible to the client who swapped the photo.
15
+ *
16
+ * That gap is what forces a bad choice: either let a client change a header
17
+ * photograph and risk breaking the nav, or refuse to let them choose at all.
18
+ * It is a false choice. Let them choose, and measure what they chose.
19
+ *
20
+ * ── WHAT MEASURING IT ACTUALLY TAUGHT ──────────────────────────────────────
21
+ * Fed a deliberately hostile frame, on a real site with three such regions:
22
+ *
23
+ * band header, 82% ink scrim near-white photo → 9.66:1 cannot fail
24
+ * tile label, 72% ink scrim near-white photo → 6.76:1 cannot fail
25
+ * script text, 62% cream scrim near-black photo → 2.86:1 ✗ rejected
26
+ *
27
+ * ⚠ TWO OF THE THREE COULD NOT FAIL. Those scrims were strong enough that no
28
+ * photograph gets through them — which is what "guarantee the ground instead
29
+ * of hoping for it" is for. The pattern had already solved the problem and
30
+ * everyone was still behaving as though it had not.
31
+ *
32
+ * The one real exposure was a scrim that had been LIGHTENED, from 92% to 62%,
33
+ * so a client's new photography could show its colour. **The danger is never
34
+ * the photograph. It is a weakened scrim** — and this check is what makes
35
+ * weakening one safe to do.
36
+ *
37
+ * ── WHY THE REGIONS ARE DECLARED AND NOT DETECTED ──────────────────────────
38
+ * A region is a box, a scrim strength and a text colour. All three belong to a
39
+ * project's design, and ⚠ **this template has no design** — so the kit ships
40
+ * the measurement and the declaration format, never the regions. With none
41
+ * declared this exits 0 and says so, rather than printing a tick it has not
42
+ * earned.
43
+ *
44
+ * Declare them in `src/data/contrast.json`:
45
+ *
46
+ * {
47
+ * "regions": [
48
+ * {
49
+ * "label": "header band",
50
+ * "image": "photos/hero-band",
51
+ * "box": { "x": 0, "y": 0, "w": 1, "h": 0.4 },
52
+ * "scrim": { "colour": "#1a0d05", "from": 0.82, "to": 0.82 },
53
+ * "text": "#ffffff"
54
+ * }
55
+ * ]
56
+ * }
57
+ *
58
+ * `box` is fractions of the image. `scrim.from`/`to` are alpha at the top and
59
+ * bottom of the box, so a gradient is expressible. `image` is a manifest key.
60
+ */
61
+
62
+ import { existsSync, readFileSync } from 'node:fs';
63
+ import sharp from 'sharp';
64
+
65
+ const RESET = '\x1b[0m';
66
+ const RED = '\x1b[31m';
67
+ const GREEN = '\x1b[32m';
68
+ const DIM = '\x1b[2m';
69
+
70
+ const DECL = 'src/data/contrast.json';
71
+ const MANIFEST = 'src/data/image-manifest.json';
72
+ const AA = 4.5;
73
+
74
+ if (!existsSync(DECL)) {
75
+ console.log(`${DIM}·${RESET} no ${DECL} — no text-over-photograph regions declared`);
76
+ process.exit(0);
77
+ }
78
+
79
+ const fail = (msg) => {
80
+ console.error(`\n${RED}✗ ${msg}${RESET}\n`);
81
+ process.exit(1);
82
+ };
83
+
84
+ let regions;
85
+ try {
86
+ regions = JSON.parse(readFileSync(DECL, 'utf8')).regions;
87
+ } catch (err) {
88
+ fail(`${DECL} is not valid JSON — ${err.message}`);
89
+ }
90
+ if (!Array.isArray(regions) || !regions.length) {
91
+ fail(`${DECL} declares no regions. Delete the file, or describe what to measure.`);
92
+ }
93
+
94
+ const manifest = existsSync(MANIFEST) ? JSON.parse(readFileSync(MANIFEST, 'utf8')) : {};
95
+
96
+ const hex = (value) => {
97
+ const m = /^#?([0-9a-f]{6})$/i.exec(String(value ?? ''));
98
+ if (!m) return null;
99
+ const n = parseInt(m[1], 16);
100
+ return [(n >> 16) & 255, (n >> 8) & 255, n & 255];
101
+ };
102
+
103
+ /* sRGB relative luminance, per WCAG. */
104
+ const lum = ([r, g, b]) => {
105
+ const c = [r, g, b].map((v) => {
106
+ const s = v / 255;
107
+ return s <= 0.03928 ? s / 12.92 : ((s + 0.055) / 1.055) ** 2.4;
108
+ });
109
+ return 0.2126 * c[0] + 0.7152 * c[1] + 0.0722 * c[2];
110
+ };
111
+
112
+ const ratio = (a, b) => {
113
+ const [x, y] = [lum(a), lum(b)].sort((p, q) => q - p);
114
+ return (x + 0.05) / (y + 0.05);
115
+ };
116
+
117
+ /** Source-over: the scrim colour at `alpha` laid on the pixel beneath. */
118
+ const over = (pixel, scrim, alpha) => pixel.map((p, i) => scrim[i] * alpha + p * (1 - alpha));
119
+
120
+ const problems = [];
121
+ const measured = [];
122
+
123
+ for (const region of regions) {
124
+ const label = region?.label ?? '(unlabelled)';
125
+ const text = hex(region?.text);
126
+ const scrimColour = hex(region?.scrim?.colour);
127
+ if (!text) fail(`region "${label}": \`text\` must be a #rrggbb colour`);
128
+ if (!scrimColour) fail(`region "${label}": \`scrim.colour\` must be a #rrggbb colour`);
129
+
130
+ const from = Number(region?.scrim?.from ?? 0);
131
+ const to = Number(region?.scrim?.to ?? from);
132
+ const min = Number(region?.min ?? AA);
133
+
134
+ /*
135
+ * ⚠ A MANIFEST KEY, NOT A PICKER PATH — and deliberately not normalised here.
136
+ * `toImageKey` lives in `src/lib/image-key.ts`, which a plain .mjs script
137
+ * cannot import; copying it in would leave two implementations of the same
138
+ * mapping free to drift, which is the failure this whole round was about.
139
+ * This file is written by a developer, not by a CMS, so it can simply ask
140
+ * for the key and say so when it gets a path.
141
+ */
142
+ const key = String(region?.image ?? '');
143
+ const entry = manifest[key];
144
+ if (!entry) {
145
+ fail(
146
+ key.startsWith('/img/')
147
+ ? `region "${label}": \`image\` is "${key}", which is a media-picker path.\n` +
148
+ ` Declare the manifest key instead — the part between /img/ and the -width suffix.`
149
+ : `region "${label}": no manifest entry for "${key}".\n` +
150
+ ` Run \`npm run media\` first — this measures the generated image, not the source.`,
151
+ );
152
+ }
153
+
154
+ /* The widest generated variant: the most pixels, so the least sampling luck. */
155
+ const widest = (entry.widths ?? []).length ? Math.max(...entry.widths) : null;
156
+ const file = widest ? `public/img/${key}-${widest}.webp` : null;
157
+ if (!file || !existsSync(file)) {
158
+ fail(`region "${label}": ${file ?? 'no variant'} is missing. Run \`npm run media\`.`);
159
+ }
160
+
161
+ const box = region?.box ?? { x: 0, y: 0, w: 1, h: 1 };
162
+ const image = sharp(file);
163
+ const meta = await image.metadata();
164
+ const left = Math.max(0, Math.round((box.x ?? 0) * meta.width));
165
+ const top = Math.max(0, Math.round((box.y ?? 0) * meta.height));
166
+ const width = Math.max(1, Math.min(meta.width - left, Math.round((box.w ?? 1) * meta.width)));
167
+ const height = Math.max(1, Math.min(meta.height - top, Math.round((box.h ?? 1) * meta.height)));
168
+
169
+ const { data } = await image
170
+ .extract({ left, top, width, height })
171
+ .removeAlpha()
172
+ .raw()
173
+ .toBuffer({ resolveWithObject: true });
174
+
175
+ /*
176
+ * ⚠ PER-CHANNEL EXTREMES, NOT THE AVERAGE. An average hides exactly the
177
+ * highlight that breaks a word — the brightest pixel under the type is what
178
+ * a reader's eye lands on. Both extremes are taken because light text fails
179
+ * against a bright pixel and dark text fails against a dark one, and a
180
+ * check that only knows one of those is half a check.
181
+ */
182
+ const brightest = [0, 0, 0];
183
+ const darkest = [255, 255, 255];
184
+ for (let i = 0; i < data.length; i += 3) {
185
+ const y = Math.floor(i / 3 / width);
186
+ const alpha = from + (to - from) * (height > 1 ? y / (height - 1) : 0);
187
+ const composited = over([data[i], data[i + 1], data[i + 2]], scrimColour, alpha);
188
+ for (let c = 0; c < 3; c++) {
189
+ if (composited[c] > brightest[c]) brightest[c] = composited[c];
190
+ if (composited[c] < darkest[c]) darkest[c] = composited[c];
191
+ }
192
+ }
193
+
194
+ const worst = Math.min(ratio(text, brightest), ratio(text, darkest));
195
+ measured.push({ label, worst, min });
196
+ if (worst < min) problems.push({ label, worst, min, image: region?.image });
197
+ }
198
+
199
+ for (const m of measured) {
200
+ const mark = m.worst < m.min ? `${RED}✗${RESET}` : `${GREEN}✓${RESET}`;
201
+ console.log(` ${mark} ${m.label} ${m.worst.toFixed(2)}:1 ${DIM}(needs ${m.min}:1)${RESET}`);
202
+ }
203
+
204
+ if (!problems.length) {
205
+ console.log(`${GREEN}✓${RESET} ${measured.length} text-over-photograph region(s) legible`);
206
+ process.exit(0);
207
+ }
208
+
209
+ console.error(`\n${RED}✗ ${problems.length} region(s) below the contrast floor${RESET}\n`);
210
+ for (const p of problems) {
211
+ console.error(` ${p.label} ${p.worst.toFixed(2)}:1 needs ${p.min}:1 ${DIM}${p.image}${RESET}`);
212
+ }
213
+ console.error(
214
+ `\n ${DIM}The photograph is rarely the problem. Check whether this region's scrim has\n` +
215
+ ` been lightened — that is what turns a guarantee into a hope. Strengthen the\n` +
216
+ ` scrim, or choose a photograph without a bright area under the type.${RESET}\n`,
217
+ );
218
+ process.exit(1);
@@ -0,0 +1,291 @@
1
+ /**
2
+ * What a delivered site is missing, because the kit moved on without it.
3
+ *
4
+ * npm run check:drift
5
+ * node scripts/check-drift.mjs --json # for CI, or for many sites at once
6
+ *
7
+ * ── WHY THIS IS DIFFERENT FROM EVERY OTHER CHECK HERE ──────────────────────
8
+ * ⚠ THE TEMPLATE IS COPIED, NOT LINKED. Nothing the kit fixes afterwards
9
+ * reaches a site already built. Every other gate in this directory protects
10
+ * the next project; this one is the only thing that speaks to the ones
11
+ * already shipped.
12
+ *
13
+ * The cost is not hypothetical. A delivered site served every image about 19%
14
+ * larger than intended for weeks after the pipeline gained AVIF, and it
15
+ * surfaced only because somebody happened to read two trees side by side for an
16
+ * unrelated reason. The same site still carried a source file with a literal
17
+ * NUL — invisible to `git diff`, to `grep`, and to the provenance sweep.
18
+ *
19
+ * ── IT REPORTS. IT NEVER CHANGES ANYTHING. ─────────────────────────────────
20
+ * Exit 0 whatever it finds, unless it cannot run at all. A remediation tool
21
+ * that edits before you have read its findings is not a tool, it is a surprise.
22
+ *
23
+ * ── WHY IT DOES NOT RE-IMPLEMENT THE OTHER CHECKS ──────────────────────────
24
+ * Where the current kit ships a check, drift means *not having that check*, so
25
+ * this looks for the file rather than repeating what it does. Copying the
26
+ * logic in would leave two implementations free to disagree — which is exactly
27
+ * the failure this whole round of work was about.
28
+ *
29
+ * Where no check exists, it analyses: AVIF, binary source files, hardcoded
30
+ * images, a pipeline that discards silently.
31
+ *
32
+ * ── RUNNING IT IN A SITE THAT PREDATES IT ──────────────────────────────────
33
+ * Copy `scripts/check-drift.mjs` and `scripts/lib/` in, then run it. It reads
34
+ * only; nothing it needs has to be installed first, except `yaml` for the two
35
+ * CMS rows — and it says so rather than skipping them in silence.
36
+ */
37
+
38
+ import { existsSync, readFileSync } from 'node:fs';
39
+ import { join } from 'node:path';
40
+ import { binarySourceFiles } from './lib/binary-files.mjs';
41
+ import { literalImages } from './lib/literal-images.mjs';
42
+
43
+ const RESET = '\x1b[0m';
44
+ const RED = '\x1b[31m';
45
+ const GREEN = '\x1b[32m';
46
+ const YELLOW = '\x1b[33m';
47
+ const DIM = '\x1b[2m';
48
+ const BOLD = '\x1b[1m';
49
+
50
+ const json = process.argv.includes('--json');
51
+
52
+ /* `yaml` arrives with Astro, but a site old enough to drift may not resolve it.
53
+ Two rows depend on it, and they say so rather than reporting a clean result
54
+ they never computed. */
55
+ let parseYaml = null;
56
+ try {
57
+ ({ parse: parseYaml } = await import('yaml'));
58
+ } catch {
59
+ /* handled per row */
60
+ }
61
+
62
+ const read = (file) => {
63
+ try {
64
+ return readFileSync(file, 'utf8');
65
+ } catch {
66
+ return null;
67
+ }
68
+ };
69
+
70
+ const findings = [];
71
+ const add = (id, title, status, detail) => findings.push({ id, title, status, detail });
72
+
73
+ /* ── which kit, if it says ────────────────────────────────────────────────── */
74
+
75
+ const pkg_ = read('package.json');
76
+ const manifest = pkg_ ? JSON.parse(pkg_) : {};
77
+ const stamp = manifest.websiteBuildKit ?? null;
78
+
79
+ /*
80
+ * ⚠ THE TEMPLATE ITSELF IS NOT A DRIFTED SITE. It is the source, and it never
81
+ * carries a stamp — the scaffolder writes one into the COPY. Reporting the
82
+ * kit's own template as behind the kit is the kind of false positive that
83
+ * teaches people to ignore the whole report, and this one appeared the first
84
+ * time this script was run.
85
+ */
86
+ const isTemplate = manifest.name === 'site-name';
87
+
88
+ add(
89
+ 'K',
90
+ 'Kit version recorded',
91
+ isTemplate ? 'n/a' : stamp ? 'ok' : 'drift',
92
+ isTemplate
93
+ ? 'this is the kit template itself, which is stamped when it is scaffolded'
94
+ : stamp
95
+ ? `${stamp.version}, scaffolded ${stamp.scaffolded ?? 'unknown'}`
96
+ : 'no stamp — this site predates the version stamp, so which kit it came from has to be worked out by hand',
97
+ );
98
+
99
+ /* ── D1 · a modern output format ──────────────────────────────────────────── */
100
+
101
+ const media = read('scripts/optimize-media.mjs');
102
+ if (!media) {
103
+ add('D1', 'Modern image format', 'n/a', 'no scripts/optimize-media.mjs — this site has no kit media pipeline');
104
+ } else {
105
+ /* ⚠ THE DECLARATION, NOT ANY MENTION OF THE WORD. A pipeline with AVIF turned
106
+ OFF still documents how to turn it on — `Set FORMATS to ['webp'] to turn
107
+ AVIF off` — so a bare search for the string reports the opposite of the
108
+ truth on exactly the sites this exists for. */
109
+ const formats = /const\s+FORMATS\s*=\s*\[([^\]]*)\]/.exec(media);
110
+ const avif = formats ? /['"]avif['"]/.test(formats[1]) : false;
111
+ add(
112
+ 'D1',
113
+ 'Modern image format',
114
+ avif ? 'ok' : 'drift',
115
+ avif
116
+ ? 'the pipeline emits AVIF alongside WebP'
117
+ : 'WebP only. AVIF is about 26% smaller at matched quality — measured, but MEASURE IT HERE before quoting a number, because the saving depends on the photography',
118
+ );
119
+
120
+ /* ── D6 · a pipeline that discards files in silence ─────────────────────── */
121
+ const reports = /produced no image/.test(media);
122
+ const catches = /failed to process/.test(media);
123
+ add(
124
+ 'D6',
125
+ 'Pipeline reports what it dropped',
126
+ reports && catches ? 'ok' : 'drift',
127
+ reports && catches
128
+ ? 'names skipped files, and one bad file no longer aborts the run'
129
+ : `${!reports ? 'a non-raster file is dropped with no output and no warning' : ''}${!reports && !catches ? '; ' : ''}${!catches ? 'one corrupt file aborts the run midway, leaving the manifest describing a state that no longer exists' : ''}`,
130
+ );
131
+
132
+ const heic = /heic/i.test(media);
133
+ add(
134
+ 'D1b',
135
+ 'HEIC accepted',
136
+ heic ? 'ok' : 'drift',
137
+ heic
138
+ ? 'iPhone photographs convert'
139
+ : 'a .heic is discarded with no output and no manifest entry, while the installed libvips reads it fine',
140
+ );
141
+ }
142
+
143
+ /* ── D2 · source files that defeat review ─────────────────────────────────── */
144
+
145
+ try {
146
+ const { tracked, binary } = binarySourceFiles();
147
+ add(
148
+ 'D2',
149
+ 'Source files readable as text',
150
+ binary.length ? 'drift' : 'ok',
151
+ binary.length
152
+ ? `${binary.length} tracked file(s) git calls BINARY — invisible to git diff, to grep, and to the provenance sweep: ${binary.join(', ')}`
153
+ : `${tracked.length} tracked source file(s), all readable`,
154
+ );
155
+ } catch (err) {
156
+ add('D2', 'Source files readable as text', 'n/a', `git unavailable — ${err.message}`);
157
+ }
158
+
159
+ /* ── D5 · images the client cannot change ─────────────────────────────────── */
160
+
161
+ const literals = literalImages();
162
+ add(
163
+ 'D5',
164
+ 'Images are fields, not literals',
165
+ literals.length ? 'drift' : 'ok',
166
+ literals.length
167
+ ? `${literals.length} hardcoded in pages, e.g. ${literals
168
+ .slice(0, 3)
169
+ .map((l) => `${l.value} (${l.file})`)
170
+ .join(', ')} — each is an image the client can see and cannot change`
171
+ : 'no hardcoded image references in pages',
172
+ );
173
+
174
+ /* ── the CMS rows ─────────────────────────────────────────────────────────── */
175
+
176
+ const CONFIG = '.pages.yml';
177
+ if (!existsSync(CONFIG)) {
178
+ for (const [id, title] of [
179
+ ['D3', 'CMS image fields are pickers'],
180
+ ['D4', 'CMS cannot delete undeclared keys'],
181
+ ['D8', 'Client guide matches the CMS'],
182
+ ]) {
183
+ add(id, title, 'n/a', 'no .pages.yml — this site has no CMS');
184
+ }
185
+ } else if (!parseYaml) {
186
+ for (const [id, title] of [
187
+ ['D3', 'CMS image fields are pickers'],
188
+ ['D4', 'CMS cannot delete undeclared keys'],
189
+ ['D8', 'Client guide matches the CMS'],
190
+ ]) {
191
+ add(id, title, 'n/a', 'yaml is not installed here, so the CMS config could not be read — `npm i -D yaml`');
192
+ }
193
+ } else {
194
+ /* D3 is analysed because no shipped check covers it. */
195
+ let config = null;
196
+ try {
197
+ config = parseYaml(readFileSync(CONFIG, 'utf8')) ?? {};
198
+ } catch (err) {
199
+ config = null;
200
+ add('D3', 'CMS image fields are pickers', 'n/a', `${CONFIG} does not parse — ${err.message}`);
201
+ }
202
+
203
+ if (config) {
204
+ const flatten = (list) =>
205
+ (list ?? []).flatMap((e) => (e?.type === 'group' ? flatten(e.items ?? e.content ?? []) : [e]));
206
+ const textBoxes = [];
207
+ const walkFields = (fields, entry, prefix = '') => {
208
+ for (const f of fields ?? []) {
209
+ if (!f?.name) continue;
210
+ const path = prefix ? `${prefix}.${f.name}` : f.name;
211
+ if (/^(image|photo|poster|picture|cover|thumbnail)$/i.test(f.name) && f.type !== 'image') {
212
+ textBoxes.push(`${entry}.${path} (type: ${f.type ?? 'unset'})`);
213
+ }
214
+ if (Array.isArray(f.fields)) walkFields(f.fields, entry, path);
215
+ }
216
+ };
217
+ for (const entry of flatten(config.content)) walkFields(entry?.fields, entry?.name ?? '?');
218
+ add(
219
+ 'D3',
220
+ 'CMS image fields are pickers',
221
+ textBoxes.length ? 'drift' : 'ok',
222
+ textBoxes.length
223
+ ? `${textBoxes.length} image field(s) are text boxes, so the editor must type a key from memory: ${textBoxes.slice(0, 4).join(', ')}`
224
+ : 'image fields use the picker',
225
+ );
226
+ }
227
+
228
+ /* D4 and D8 have a shipped check. Drift means not having it. */
229
+ const hasCms = existsSync(join('scripts', 'check-cms.mjs'));
230
+ add(
231
+ 'D4',
232
+ 'CMS cannot delete undeclared keys',
233
+ hasCms ? 'ok' : 'drift',
234
+ hasCms
235
+ ? 'scripts/check-cms.mjs is present — run it'
236
+ : '⚠ no check-cms.mjs. A CMS rewrites the whole file from its schema, so any key it does not declare is DELETED on the client\'s first save. Five audited sites, five failures, two losing data',
237
+ );
238
+ add(
239
+ 'D8',
240
+ 'Client guide matches the CMS',
241
+ hasCms && existsSync(join('docs', 'handover.md')) ? 'ok' : 'drift',
242
+ !existsSync(join('docs', 'handover.md'))
243
+ ? 'no docs/handover.md — the client has a CMS and no instructions for it'
244
+ : hasCms
245
+ ? 'check-cms.mjs cross-references the guide against the config'
246
+ : 'nothing checks the guide against the config; a guide that has gone stale does not read as stale, it reads as true',
247
+ );
248
+ }
249
+
250
+ /* ── D7 · text over photographs ───────────────────────────────────────────── */
251
+
252
+ const hasContrast = existsSync(join('scripts', 'check-contrast.mjs'));
253
+ const declares = existsSync(join('src', 'data', 'contrast.json'));
254
+ add(
255
+ 'D7',
256
+ 'Text over photographs is measured',
257
+ hasContrast ? (declares ? 'ok' : 'n/a') : 'drift',
258
+ hasContrast
259
+ ? declares
260
+ ? 'regions declared and measured in build:production'
261
+ : 'the check is present and this site declares no regions — correct if no text sits on a photograph'
262
+ : 'no check-contrast.mjs. axe and pa11y report a flat ~1.01:1 for text on an image, so a photograph that makes the navigation unreadable passes every gate',
263
+ );
264
+
265
+ /* ── report ───────────────────────────────────────────────────────────────── */
266
+
267
+ if (json) {
268
+ console.log(JSON.stringify({ findings }, null, 2));
269
+ process.exit(0);
270
+ }
271
+
272
+ const mark = { ok: `${GREEN}✓${RESET}`, drift: `${YELLOW}!${RESET}`, 'n/a': `${DIM}·${RESET}` };
273
+ const drifted = findings.filter((f) => f.status === 'drift');
274
+
275
+ console.log(`\n${BOLD}── Drift from the current kit ${'─'.repeat(28)}${RESET}\n`);
276
+ for (const f of findings) {
277
+ console.log(` ${mark[f.status]} ${f.title}`);
278
+ console.log(` ${DIM}${f.detail}${RESET}`);
279
+ }
280
+
281
+ console.log('');
282
+ if (!drifted.length) {
283
+ console.log(`${GREEN}✓${RESET} nothing behind that this can see\n`);
284
+ } else {
285
+ console.log(
286
+ `${YELLOW}!${RESET} ${drifted.length} of ${findings.length} behind the current kit\n\n` +
287
+ ` ${DIM}Nothing has been changed. Decide what is worth doing before doing any of it —\n` +
288
+ ` some of these are invisible to visitors and some are a client unable to edit\n` +
289
+ ` their own photographs, and they are not the same job.${RESET}\n`,
290
+ );
291
+ }
@@ -0,0 +1,79 @@
1
+ /**
2
+ * Tracked source files that git and grep treat as BINARY.
3
+ *
4
+ * ⚠ THE PROVENANCE SWEEP IS WRITTEN WITH `grep -I`, WHICH SKIPS THEM. One
5
+ * script used a literal NUL as a string sentinel, which made it binary, which
6
+ * made it invisible to the sweep — while carrying a client's entire brand,
7
+ * both typefaces and a base64 palette. Two commits.
8
+ *
9
+ * The tools fail quietly rather than loudly: `grep` returns nothing and exits 1
10
+ * exactly as it does for no-match, and `git diff` says only
11
+ * `Binary files differ`, so changes never appear in review.
12
+ *
13
+ * ⚠ THE OBVIOUS IMPLEMENTATION IS A FUNCTION THAT ALWAYS RETURNS NOTHING.
14
+ * `git grep -I --files-without-match ''` reads like the answer and prints
15
+ * nothing either way. Ask git which files it tracks, ask again which it can
16
+ * read as text, and take the difference.
17
+ *
18
+ * ⚠ AN EMPTY FILE IS NOT A BINARY ONE. `git grep ''` matches LINES, and a
19
+ * zero-byte file has none, so the naive difference reports every empty file.
20
+ * Excluded by size rather than by guessing.
21
+ *
22
+ * Shared by the kit's own `check:binary` and by `check-drift.mjs`, which runs
23
+ * in a delivered project. One implementation, because two would be free to
24
+ * disagree about which files count.
25
+ */
26
+
27
+ import { execFileSync } from 'node:child_process';
28
+ import { statSync } from 'node:fs';
29
+
30
+ /** The extensions a human reviews. A .png is legitimately binary; a .mjs is not. */
31
+ export const SOURCE_GLOBS = [
32
+ '*.mjs',
33
+ '*.js',
34
+ '*.cjs',
35
+ '*.ts',
36
+ '*.tsx',
37
+ '*.astro',
38
+ '*.css',
39
+ '*.md',
40
+ '*.json',
41
+ '*.jsonc',
42
+ '*.yml',
43
+ '*.yaml',
44
+ '*.html',
45
+ '*.txt',
46
+ '*.sh',
47
+ ];
48
+
49
+ const git = (...args) => {
50
+ try {
51
+ return execFileSync('git', args, { encoding: 'utf8', maxBuffer: 64 * 1024 * 1024 })
52
+ .split('\n')
53
+ .filter(Boolean);
54
+ } catch (err) {
55
+ /* `git grep` exits 1 when nothing matches, which is not an error here. */
56
+ if (err.status === 1 && typeof err.stdout === 'string') {
57
+ return err.stdout.split('\n').filter(Boolean);
58
+ }
59
+ throw err;
60
+ }
61
+ };
62
+
63
+ /**
64
+ * `{ tracked, binary }` for the current working directory.
65
+ * Throws if git is unavailable — a silent empty result would be a lie.
66
+ */
67
+ export function binarySourceFiles() {
68
+ const tracked = git('ls-files', '--', ...SOURCE_GLOBS);
69
+ const textual = new Set(git('grep', '-I', '-l', '', '--', ...SOURCE_GLOBS));
70
+ const binary = tracked.filter((file) => {
71
+ if (textual.has(file)) return false;
72
+ try {
73
+ return statSync(file).size > 0;
74
+ } catch {
75
+ return false; // deleted but still indexed
76
+ }
77
+ });
78
+ return { tracked, binary };
79
+ }
@@ -0,0 +1,95 @@
1
+ /**
2
+ * Images a page renders from a hardcoded string, which no editor can change.
3
+ *
4
+ * ── THE FAILURE THIS FINDS ─────────────────────────────────────────────────
5
+ * After wiring five image fields into a CMS, someone wrote in the config that
6
+ * "photographs are chosen in code, not here". That sentence was true of the
7
+ * five fields that existed and quietly excused the eight that did not — a
8
+ * header band, four class tiles, a gift-card picture and three more, all
9
+ * `name="photos/x"` literals sitting in `.astro` files.
10
+ *
11
+ * ⚠ THE CLIENT OPENED THE PAGE, SAW A PHOTOGRAPH, AND HAD NO WAY TO CHANGE IT.
12
+ * Nothing was broken. The build was green, the page was correct, and the
13
+ * only symptom was a person looking for a field that was never there.
14
+ *
15
+ * The rule it enforces: an image a page renders is **a field, or a deliberate
16
+ * exception somebody wrote down** — never an oversight dressed as a principle.
17
+ *
18
+ * ── WHY A LIB AND NOT A SCRIPT ─────────────────────────────────────────────
19
+ * Two callers need it and they run in different worlds: `check-cms.mjs` in a
20
+ * project that has a CMS, and `check-drift.mjs` in a delivered site that may
21
+ * have neither. A second copy is two implementations free to disagree, which
22
+ * is the failure the whole media round was about.
23
+ */
24
+
25
+ import { readFileSync, readdirSync, statSync } from 'node:fs';
26
+ import { join, relative, sep } from 'node:path';
27
+
28
+ /*
29
+ * ⚠ ONLY THE ATTRIBUTES THAT NAME AN IMAGE, AND ONLY QUOTED VALUES.
30
+ *
31
+ * An earlier attempt in the source project matched every `name="…"` and
32
+ * returned form fields, icon names and `<meta name="viewport">` — fifteen
33
+ * hits where the truth was zero. A check that cries wolf on a form field
34
+ * gets switched off before it ever finds a photograph.
35
+ *
36
+ * `name=` is therefore accepted only on an element whose tag looks like an
37
+ * image component, while `image=` and `poster=` are unambiguous anywhere.
38
+ * An expression — name={photo} — has no quotes and never matches, which is
39
+ * exactly right: that value came from somewhere else, which is the point.
40
+ */
41
+ const IMAGE_COMPONENT = /<(Img|Image|Picture|BandHeader|Hero)\b[^>]*?\bname=["']([^"']+)["']/gis;
42
+ const IMAGE_ATTR = /\b(?:image|poster|photo|bgImage|backgroundImage)=["']([^"']+)["']/gis;
43
+
44
+ /** Values that are never a photograph a client would want to change. */
45
+ const NOT_A_PHOTO = /^(#|https?:|data:|\/|\.\.?\/)|\.(svg|ico)$/i;
46
+
47
+ const walk = (dir) =>
48
+ readdirSync(dir).flatMap((entry) => {
49
+ const full = join(dir, entry);
50
+ return statSync(full).isDirectory() ? walk(full) : [full];
51
+ });
52
+
53
+ /**
54
+ * Every hardcoded image reference under `root`.
55
+ *
56
+ * Returns `[{ file, value }]`, deduplicated per file+value, with paths in
57
+ * forward slashes so a Windows run reports what a Linux one does.
58
+ */
59
+ export function literalImages(root = 'src/pages') {
60
+ const out = [];
61
+ const seen = new Set();
62
+
63
+ let files;
64
+ try {
65
+ files = walk(root).filter((f) => /\.(astro|mdx)$/.test(f));
66
+ } catch {
67
+ return out; // no pages directory is not this check's problem
68
+ }
69
+
70
+ for (const file of files) {
71
+ const source = readFileSync(file, 'utf8');
72
+ const rel = relative(process.cwd(), file).split(sep).join('/');
73
+
74
+ /* Astro comments are `{/* … *\/}` and HTML comments render; neither should
75
+ contribute a finding, so strip both before matching. */
76
+ const body = source.replace(/<!--[\s\S]*?-->/g, ' ');
77
+
78
+ for (const [regex, group] of [
79
+ [IMAGE_COMPONENT, 2],
80
+ [IMAGE_ATTR, 1],
81
+ ]) {
82
+ regex.lastIndex = 0;
83
+ for (const match of body.matchAll(regex)) {
84
+ const value = match[group];
85
+ if (!value || NOT_A_PHOTO.test(value)) continue;
86
+ const key = `${rel}::${value}`;
87
+ if (seen.has(key)) continue;
88
+ seen.add(key);
89
+ out.push({ file: rel, value });
90
+ }
91
+ }
92
+ }
93
+
94
+ return out.sort((a, b) => a.file.localeCompare(b.file) || a.value.localeCompare(b.value));
95
+ }
@@ -96,3 +96,70 @@ export async function discoverRoutes(origin, fetcher = fetch) {
96
96
 
97
97
  return { routes: [], source: 'nothing — no sitemap and no dist/' };
98
98
  }
99
+
100
+ /**
101
+ * Routes derived from `src/pages`, without building anything.
102
+ *
103
+ * ── WHY NOT routesFromDist ─────────────────────────────────────────────────
104
+ * `check-cms.mjs` runs BEFORE the build, so `dist/` does not exist yet. The
105
+ * point of checking a navigation target early is to fail while somebody is
106
+ * still looking at the config, not after a deploy.
107
+ *
108
+ * Returns `{ static: Set<string>, dynamic: RegExp[] }`.
109
+ *
110
+ * ⚠ A DYNAMIC ROUTE IS A PATTERN, NOT A ROUTE. `[slug].astro` serves every
111
+ * legal page and `[...path].astro` serves any depth. Treating those as
112
+ * literal strings would report every real link through them as broken, which
113
+ * on a site with a `[slug]` catch-all is *most of the site* — a check that
114
+ * confident and that wrong gets switched off within a day.
115
+ *
116
+ * ⚠ ENDPOINTS ARE NOT PAGES. `robots.txt.ts` and `api/contact.ts` produce
117
+ * responses, never navigable pages, so they are excluded — nobody puts them
118
+ * in a menu and reporting them as available would be noise.
119
+ */
120
+ export function routesFromPages(dir = 'src/pages') {
121
+ const out = { static: new Set(), dynamic: [] };
122
+ if (!existsSync(dir)) return out;
123
+
124
+ const walk = (d) =>
125
+ readdirSync(d, { withFileTypes: true }).flatMap((e) => {
126
+ const full = join(d, e.name);
127
+ return e.isDirectory() ? walk(full) : [full];
128
+ });
129
+
130
+ for (const file of walk(dir)) {
131
+ const rel = relative(dir, file).split(sep).join('/');
132
+ /* `_` prefixed files and directories are not routed by Astro. */
133
+ if (rel.split('/').some((part) => part.startsWith('_'))) continue;
134
+ if (!/\.(astro|md|mdx)$/.test(rel)) continue; // .ts endpoints are not pages
135
+
136
+ const path =
137
+ '/' +
138
+ rel
139
+ .replace(/\.(astro|md|mdx)$/, '')
140
+ .replace(/(^|\/)index$/, '$1')
141
+ .replace(/\/$/, '');
142
+ const route = path === '/' ? '/' : `${path}/`.replace(/\/+/g, '/');
143
+
144
+ if (route.includes('[')) {
145
+ /* [...rest] matches any depth; [slug] matches one segment. */
146
+ const pattern = route
147
+ .replace(/[.*+?^${}()|\\]/g, '\\$&')
148
+ .replace(/\[\.\.\.[^\]]+\]/g, '.+')
149
+ .replace(/\[[^\]]+\]/g, '[^/]+');
150
+ out.dynamic.push(new RegExp(`^${pattern}$`));
151
+ } else {
152
+ out.static.add(route);
153
+ }
154
+ }
155
+
156
+ return out;
157
+ }
158
+
159
+ /** Does `href` correspond to a page this site serves? */
160
+ export function routeExists(href, routes) {
161
+ const path = href.split('#')[0].split('?')[0];
162
+ const normalised = path.endsWith('/') || path === '' ? path || '/' : `${path}/`;
163
+ if (routes.static.has(normalised)) return true;
164
+ return routes.dynamic.some((re) => re.test(normalised));
165
+ }
@@ -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
- import puppeteer from 'puppeteer';
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) {