create-website-build-kit 0.1.15 → 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 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.16",
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,22 @@ 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
+
98
114
  ⚠ **A CMS DELETES EVERY KEY ITS SCHEMA FORGOT.** It rewrites the whole file from the schema, so
99
115
  anything undeclared is absent from what it writes back — the client changes one field, saves, and
100
116
  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,7 @@
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';
44
45
 
45
46
  const RESET = '\x1b[0m';
46
47
  const RED = '\x1b[31m';
@@ -375,6 +376,70 @@ if (uncovered.length) {
375
376
  );
376
377
  }
377
378
 
379
+ /*
380
+ * ⚠ A CLIENT GUIDE DOES NOT GO OUT OF DATE GRACEFULLY. IT STARTS LYING.
381
+ *
382
+ * `docs/handover.md` is the only document written for the client. One
383
+ * project's was written when the CMS had six entries; it had thirteen by the
384
+ * time anyone looked, and nothing noticed. That is the mild half.
385
+ *
386
+ * The serious half is that it still said the address and phone number "are
387
+ * not editable" — which stopped being true the day those moved into the CMS.
388
+ * A client reading that either asks you to do something she can do herself,
389
+ * or assumes her address updates everywhere on its own because the document
390
+ * told her the site owned it.
391
+ *
392
+ * Only the client ever finds out. So: every entry the CMS shows should be
393
+ * named in the guide. A warning, not a failure — what the guide says is a
394
+ * judgement, and a section deliberately left out is a decision.
395
+ */
396
+ const GUIDE = join('docs', 'handover.md');
397
+
398
+ if (existsSync(GUIDE) && entries.length) {
399
+ const guide = readFileSync(GUIDE, 'utf8').toLowerCase();
400
+ const unmentioned = entries
401
+ .map((e) => e?.label ?? e?.name)
402
+ .filter(Boolean)
403
+ .filter((label) => !guide.includes(String(label).toLowerCase()));
404
+ if (unmentioned.length) {
405
+ warnings.push(
406
+ `${unmentioned.length} CMS section(s) the client guide never mentions: ` +
407
+ `${unmentioned.join(', ')}.\n` +
408
+ ` ${GUIDE} is the only document written for the client. A section it omits is one ` +
409
+ `they will not know they can edit — and a claim it makes that the CMS has since ` +
410
+ `contradicted is worse, because they will believe it.`,
411
+ );
412
+ }
413
+ }
414
+
415
+ /*
416
+ * ⚠ A SENTENCE SAYING "PHOTOGRAPHS ARE CHOSEN IN CODE" COVERS THE FIELDS THAT
417
+ * EXIST AND EXCUSES THE ONES THAT DO NOT.
418
+ *
419
+ * On a real build that left a header band, four class tiles and a gift-card
420
+ * picture as string literals in `.astro`, while the config claimed images
421
+ * were deliberately developer-controlled. The client opened the page, saw a
422
+ * photograph, and had no way to change it. Nothing was broken; the only
423
+ * symptom was someone looking for a field that was never there.
424
+ *
425
+ * A warning, because a fixed image IS sometimes right — a logo, an
426
+ * illustration that belongs to the layout. The rule is that it must be a
427
+ * decision, not an oversight.
428
+ */
429
+ const literals = literalImages();
430
+ if (literals.length) {
431
+ warnings.push(
432
+ `${literals.length} image(s) hardcoded in pages, which the CMS cannot change:\n` +
433
+ literals
434
+ .slice(0, 10)
435
+ .map((l) => ` ${l.file} ${l.value}`)
436
+ .join('\n') +
437
+ (literals.length > 10 ? `\n …and ${literals.length - 10} more` : '') +
438
+ `\n Each is a field the client does not have. Either give it one, or write down ` +
439
+ `why it is fixed — "chosen in code" stops being true the moment the next one is added.`,
440
+ );
441
+ }
442
+
378
443
  /*
379
444
  * ⚠ A SECRET IN A CMS IS A SECRET THE CLIENT CAN READ AND CHANGE. Analytics
380
445
  * IDs, tokens and keys are technical configuration: their failure mode is
@@ -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
+ }
@@ -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) {