create-website-build-kit 0.1.14 → 0.1.16

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -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
+ }