create-website-build-kit 0.1.19 → 0.1.20

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.
@@ -35,9 +35,10 @@
35
35
  * CMS rows — and it says so rather than skipping them in silence.
36
36
  */
37
37
 
38
- import { existsSync, readFileSync } from 'node:fs';
38
+ import { existsSync, readFileSync, readdirSync } from 'node:fs';
39
39
  import { join } from 'node:path';
40
40
  import { binarySourceFiles } from './lib/binary-files.mjs';
41
+ import { literalContent } from './lib/literal-content.mjs';
41
42
  import { literalImages } from './lib/literal-images.mjs';
42
43
 
43
44
  const RESET = '\x1b[0m';
@@ -67,6 +68,57 @@ const read = (file) => {
67
68
  }
68
69
  };
69
70
 
71
+ /*
72
+ * ⚠ FEATURE-DETECT BY WHAT THE FILE IS, NOT BY WHAT IT IS CALLED.
73
+ *
74
+ * A delivered site has `scripts/check-contrast.mjs` that measures CSS tokens
75
+ * against each other — a completely different job from measuring text over a
76
+ * photograph. This file saw the name, reported the site as covered, and the
77
+ * hero headline had in fact never been measured by anything. When it finally
78
+ * was, it came back at 5.76:1 with about seven points of margin.
79
+ *
80
+ * A row that says "present" because of a filename is worse than a row that
81
+ * says "missing", because nobody re-checks a tick.
82
+ *
83
+ * The marker is a phrase from the script's own OUTPUT, which is what defines
84
+ * what it does. A same-named file that does something else says so, and that
85
+ * is a more useful finding than either "present" or "missing".
86
+ *
87
+ * ⚠ A MARKER IS VERSION-SENSITIVE, AND THE ROW SAYS SO RATHER THAN PRETENDING
88
+ * OTHERWISE. A second delivered site has a bespoke `check-cms.mjs` written
89
+ * for the same failure in its own words. It carries no marker, so it reads
90
+ * as 'other' — which is honest: it is not this check, it may cover part of
91
+ * the same ground, and the only way to know is to read it. A row that
92
+ * guessed either way would be worse than one that says go and look.
93
+ */
94
+ const KIT_SCRIPT = {
95
+ 'check-cms.mjs': 'keys in the file the schema does not declare',
96
+ 'check-contrast.mjs': 'text-over-photograph',
97
+ };
98
+
99
+ /**
100
+ * 'ok' — the check is here under some name
101
+ * 'other' — the canonical NAME is taken by a script that is not this check
102
+ * false — absent
103
+ *
104
+ * ⚠ SEARCH THE DIRECTORY, NOT THE FILENAME. A project that already had a
105
+ * `check-contrast.mjs` doing something else installed this one as
106
+ * `check-photo-contrast.mjs`, which is the right call and which a
107
+ * name-based lookup would then report as missing. What the row is asking is
108
+ * whether the CHECK is here, and only its output can answer that.
109
+ */
110
+ const kitScript = (name) => {
111
+ const marker = KIT_SCRIPT[name];
112
+ let files = [];
113
+ try {
114
+ files = readdirSync('scripts').filter((f) => f.endsWith('.mjs'));
115
+ } catch {
116
+ return false;
117
+ }
118
+ if (files.some((f) => (read(join('scripts', f)) ?? '').includes(marker))) return 'ok';
119
+ return existsSync(join('scripts', name)) ? 'other' : false;
120
+ };
121
+
70
122
  const findings = [];
71
123
  const add = (id, title, status, detail) => findings.push({ id, title, status, detail });
72
124
 
@@ -171,6 +223,21 @@ add(
171
223
  : 'no hardcoded image references in pages',
172
224
  );
173
225
 
226
+ /* ── D5b · copy the client cannot reach ───────────────────────────────────── */
227
+
228
+ const inline = literalContent();
229
+ add(
230
+ 'D5b',
231
+ 'Page copy is fields, not literals',
232
+ inline.length ? 'drift' : 'ok',
233
+ inline.length
234
+ ? `${inline.length} block(s) declared inline in page frontmatter, holding ${inline.reduce((n, b) => n + b.strings, 0)} sentence(s) no editor can reach, e.g. ${inline
235
+ .slice(0, 2)
236
+ .map((b) => `${b.name}[${b.items}] in ${b.file}`)
237
+ .join(', ')} — this is what "required sections cannot be edited" looks like in the source`
238
+ : 'no inline content blocks in pages',
239
+ );
240
+
174
241
  /* ── the CMS rows ─────────────────────────────────────────────────────────── */
175
242
 
176
243
  const CONFIG = '.pages.yml';
@@ -204,14 +271,44 @@ if (!existsSync(CONFIG)) {
204
271
  const flatten = (list) =>
205
272
  (list ?? []).flatMap((e) => (e?.type === 'group' ? flatten(e.items ?? e.content ?? []) : [e]));
206
273
  const textBoxes = [];
274
+ /*
275
+ * ⚠ RESOLVE `component:` FIRST, AND DO NOT MISTAKE A WRAPPER FOR A TEXT
276
+ * BOX. This got both halves wrong on a live trilingual site and
277
+ * reported 18 pickers as text boxes.
278
+ *
279
+ * A picture is very often an OBJECT — `{ src, alt, isRender }` — reached
280
+ * through a shared `component: image_field`. Reading `f.type` on the
281
+ * wrapper finds `undefined`, so a field correctly named `image` was
282
+ * reported as an unset text box; and never descending into the
283
+ * component meant the `type: image` picker actually inside it was never
284
+ * seen at all.
285
+ *
286
+ * So: a field is a text box only if it is neither a picker itself nor a
287
+ * wrapper holding one.
288
+ */
289
+ const componentOf = (f, seen = new Set()) => {
290
+ if (!f) return null;
291
+ if (!f.component) return f;
292
+ if (seen.has(f.component)) return null;
293
+ seen.add(f.component);
294
+ const base = (config.components ?? {})[f.component];
295
+ return base ? componentOf({ ...base, ...f, component: undefined }, seen) : null;
296
+ };
207
297
  const walkFields = (fields, entry, prefix = '') => {
208
298
  for (const f of fields ?? []) {
209
299
  if (!f?.name) continue;
210
300
  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'})`);
301
+ const resolved = componentOf(f) ?? f;
302
+ const sub = Array.isArray(resolved.fields) ? resolved.fields : null;
303
+ const wrapsAPicker = (sub ?? []).some((c) => (componentOf(c) ?? c)?.type === 'image');
304
+ if (
305
+ /^(image|photo|poster|picture|cover|thumbnail)$/i.test(f.name) &&
306
+ resolved.type !== 'image' &&
307
+ !wrapsAPicker
308
+ ) {
309
+ textBoxes.push(`${entry}.${path} (type: ${resolved.type ?? 'unset'})`);
213
310
  }
214
- if (Array.isArray(f.fields)) walkFields(f.fields, entry, path);
311
+ if (sub) walkFields(sub, entry, path);
215
312
  }
216
313
  };
217
314
  for (const entry of flatten(config.content)) walkFields(entry?.fields, entry?.name ?? '?');
@@ -226,14 +323,17 @@ if (!existsSync(CONFIG)) {
226
323
  }
227
324
 
228
325
  /* D4 and D8 have a shipped check. Drift means not having it. */
229
- const hasCms = existsSync(join('scripts', 'check-cms.mjs'));
326
+ const cms = kitScript('check-cms.mjs');
327
+ const hasCms = cms === 'ok';
230
328
  add(
231
329
  'D4',
232
330
  'CMS cannot delete undeclared keys',
233
331
  hasCms ? 'ok' : 'drift',
234
332
  hasCms
235
333
  ? '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',
334
+ : cms === 'other'
335
+ ? '⚠ scripts/check-cms.mjs exists and is NOT this check — a bespoke one, or an older vintage. It may well cover part of the same ground; read it rather than trusting this row either way'
336
+ : '⚠ 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
337
  );
238
338
  add(
239
339
  'D8',
@@ -249,7 +349,8 @@ if (!existsSync(CONFIG)) {
249
349
 
250
350
  /* ── D7 · text over photographs ───────────────────────────────────────────── */
251
351
 
252
- const hasContrast = existsSync(join('scripts', 'check-contrast.mjs'));
352
+ const contrast = kitScript('check-contrast.mjs');
353
+ const hasContrast = contrast === 'ok';
253
354
  const declares = existsSync(join('src', 'data', 'contrast.json'));
254
355
  add(
255
356
  'D7',
@@ -259,7 +360,52 @@ add(
259
360
  ? declares
260
361
  ? 'regions declared and measured in build:production'
261
362
  : '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',
363
+ : contrast === 'other'
364
+ ? '⚠ scripts/check-contrast.mjs exists and is NOT this check — on the site this was found, it measures CSS tokens against each other, while the hero text over a photograph had never been measured by anything'
365
+ : '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',
366
+ );
367
+
368
+ /* ── D9 · the accessibility run measures both palettes ────────────────────── */
369
+
370
+ /*
371
+ * ⚠ THE DANGEROUS STATE HERE IS NOT "MISSING", IT IS "PRESENT AND INERT".
372
+ *
373
+ * Every site the kit has delivered runs pa11y through a script that forces
374
+ * `prefers-color-scheme` with `--force-prefers-color-scheme`. That is not a
375
+ * Chrome switch. Chrome ignores flags it does not know WITHOUT A WORD, so
376
+ * both passes measure whatever scheme the machine is in — light twice on a CI
377
+ * runner — while printing "clean in light and dark", and `a11y:evidence`
378
+ * writes that sentence into a dated compliance pack.
379
+ *
380
+ * So a site can carry the two-scheme runner, a green gate and an evidence
381
+ * pack, and have never measured its dark palette. Found in the kit's own
382
+ * template on 2026-09-20, by covering the a11y gate in `test:gates`.
383
+ */
384
+ const schemeSources = (() => {
385
+ try {
386
+ return readdirSync('scripts')
387
+ .filter((f) => f.endsWith('.mjs'))
388
+ .map((f) => read(join('scripts', f)) ?? '')
389
+ .concat(read(join('scripts', 'lib', 'schemes.mjs')) ?? '');
390
+ } catch {
391
+ return [];
392
+ }
393
+ })();
394
+ const forcesScheme = schemeSources.some((src) => src.includes('--blink-settings=preferredColorScheme'));
395
+ const deadSchemeFlag = schemeSources.some((src) => src.includes('--force-prefers-color-scheme'));
396
+ const runsPa11y = schemeSources.some((src) => src.includes('pa11y'));
397
+
398
+ add(
399
+ 'D9',
400
+ 'Accessibility run measures both colour schemes',
401
+ forcesScheme ? 'ok' : deadSchemeFlag || runsPa11y ? 'drift' : 'n/a',
402
+ forcesScheme
403
+ ? 'the scheme is forced, and the script verifies the forcing took effect before measuring'
404
+ : deadSchemeFlag
405
+ ? '⚠ --force-prefers-color-scheme is NOT a Chrome switch and is ignored in silence — this run measures one palette twice and reports it as two, including in any evidence pack. Copy scripts/lib/schemes.mjs and scripts/check-a11y.mjs from the current kit'
406
+ : runsPa11y
407
+ ? 'pa11y runs in whichever scheme the machine happens to be in — one palette measured, the other untested, and nothing says which'
408
+ : 'no pa11y runner here, so there is no two-scheme claim to be wrong',
263
409
  );
264
410
 
265
411
  /* ── report ───────────────────────────────────────────────────────────────── */
@@ -0,0 +1,233 @@
1
+ /**
2
+ * Page copy declared inline in a page, which no editor can reach.
3
+ *
4
+ * ── THE FAILURE THIS FINDS ─────────────────────────────────────────────────
5
+ * A client-facing page whose content is a `const` array in the page's own
6
+ * frontmatter. It renders correctly, it types correctly, and there is no field
7
+ * for any of it anywhere in the CMS.
8
+ *
9
+ * Measured across seven delivered sites with a CMS: one page carried seven
10
+ * pieces of equipment with patient-facing copy — 58 sentence-length strings —
11
+ * another carried two "about us" bands with their photographs, and a third
12
+ * carried a PRICE. Every one of those sites had a working CMS the client was
13
+ * already using. **The failure is not a missing CMS; it is a CMS that stops
14
+ * short of the page.**
15
+ *
16
+ * ⚠ THIS IS A WARNING AND MUST STAY ONE. An inline list is sometimes right — a
17
+ * legal notice, layout labels, a table nobody will reword. The rule is the
18
+ * same as for images: a decision somebody made, not an oversight nobody
19
+ * noticed.
20
+ *
21
+ * ── EXCLUDED BY SHAPE, NEVER BY NAME ───────────────────────────────────────
22
+ * ⚠ STRUCTURED DATA IS THE ENTIRE FALSE-POSITIVE CLASS. On the seven sites it
23
+ * was a third of every hit, and on one site it was ALL of them — three
24
+ * blocks, three schemas, zero real findings. A JSON-LD graph is developer
25
+ * territory and belongs nowhere near a client.
26
+ *
27
+ * Two shapes exclude it, and neither is the variable's name:
28
+ *
29
+ * 1. `@type` / `@context` in the block — what makes a literal JSON-LD
30
+ * 2. the array's DIRECT elements are not object literals — which is how
31
+ * `[breadcrumbSchema([…])]` gets out, since a helper call is not content
32
+ *
33
+ * A denylist of names tests for the spelling somebody used last time. Both of
34
+ * these were written as name tests first, and both let a schema through.
35
+ *
36
+ * ── WHY A SCANNER AND NOT A REGEX ──────────────────────────────────────────
37
+ * ⚠ THE REGEX VERSION MERGED ADJACENT BLOCKS. `const crumbs = [{ … }];` closes
38
+ * on its own line, so a pattern ending at `\n];` ran straight past it and
39
+ * swallowed the next declaration — reporting one item as sixteen, under the
40
+ * wrong variable name, on a real site. Bracket depth has to be counted, and
41
+ * counting it means knowing when you are inside a string.
42
+ *
43
+ * ── WHY A LIB AND NOT A SCRIPT ─────────────────────────────────────────────
44
+ * Two callers, as with `literal-images.mjs`: `check-cms.mjs` in a project that
45
+ * has a CMS, and `check-drift.mjs` in a delivered site that may have neither.
46
+ */
47
+
48
+ import { readFileSync, readdirSync, statSync } from 'node:fs';
49
+ import { join, relative, sep } from 'node:path';
50
+
51
+ const DECL = /(?:^|\n)[ \t]*(?:export\s+)?const\s+([A-Za-z_$][\w$]*)\s*(?::[^=\n]*)?=\s*\[/g;
52
+
53
+ /*
54
+ * A string long enough to be a sentence rather than a label. 25 characters was
55
+ * chosen against real trees: at 15 it returned CSS class lists and ARIA labels,
56
+ * and at 40 it missed "Starting at $10." — a price living in a page's source,
57
+ * which is the most expensive thing on this list to leave unreachable.
58
+ */
59
+ const MIN_PROSE = 25;
60
+
61
+ /** JSON-LD, by the keys that define it rather than by what it is called. */
62
+ const STRUCTURED_DATA = /['"]@(type|context|id|graph)['"]/;
63
+
64
+ /**
65
+ * Read a bracketed region starting at `open`, returning its inner text and the
66
+ * index just past the closing bracket. Strings and comments are skipped so a
67
+ * `]` inside either does not close the region early.
68
+ *
69
+ * Template literals are scanned to their closing backtick without interpreting
70
+ * `${…}`. A backtick inside a template expression would end it early; that has
71
+ * not occurred in any tree this has been run against, and the cost is one
72
+ * missed block rather than a wrong one.
73
+ */
74
+ function readBracketed(src, open) {
75
+ const CLOSES = { '[': ']', '{': '}', '(': ')' };
76
+ const stack = [CLOSES[src[open]]];
77
+ let i = open + 1;
78
+
79
+ while (i < src.length) {
80
+ const c = src[i];
81
+
82
+ if (c === '/' && src[i + 1] === '/') {
83
+ i = src.indexOf('\n', i);
84
+ if (i === -1) return null;
85
+ continue;
86
+ }
87
+ if (c === '/' && src[i + 1] === '*') {
88
+ const end = src.indexOf('*/', i + 2);
89
+ if (end === -1) return null;
90
+ i = end + 2;
91
+ continue;
92
+ }
93
+ if (c === "'" || c === '"' || c === '`') {
94
+ i++;
95
+ while (i < src.length && src[i] !== c) i += src[i] === '\\' ? 2 : 1;
96
+ i++;
97
+ continue;
98
+ }
99
+ if (c === '[' || c === '{' || c === '(') {
100
+ stack.push(CLOSES[c]);
101
+ i++;
102
+ continue;
103
+ }
104
+ if (c === ']' || c === '}' || c === ')') {
105
+ if (c !== stack.pop()) return null; // unbalanced — not something to reason about
106
+ if (!stack.length) return { body: src.slice(open + 1, i), end: i + 1 };
107
+ i++;
108
+ continue;
109
+ }
110
+ i++;
111
+ }
112
+ return null;
113
+ }
114
+
115
+ /** The array's direct elements, split on commas at depth zero. */
116
+ function directElements(body) {
117
+ const out = [];
118
+ let depth = 0;
119
+ let start = 0;
120
+ let i = 0;
121
+
122
+ while (i < body.length) {
123
+ const c = body[i];
124
+ if (c === "'" || c === '"' || c === '`') {
125
+ i++;
126
+ while (i < body.length && body[i] !== c) i += body[i] === '\\' ? 2 : 1;
127
+ i++;
128
+ continue;
129
+ }
130
+ if (c === '[' || c === '{' || c === '(') depth++;
131
+ else if (c === ']' || c === '}' || c === ')') depth--;
132
+ else if (c === ',' && depth === 0) {
133
+ out.push(body.slice(start, i));
134
+ start = i + 1;
135
+ }
136
+ i++;
137
+ }
138
+ out.push(body.slice(start));
139
+ return out.map((s) => s.trim()).filter(Boolean);
140
+ }
141
+
142
+ /** Every string literal in `body` at least `MIN_PROSE` characters long. */
143
+ function proseStrings(body) {
144
+ const out = [];
145
+ let i = 0;
146
+ while (i < body.length) {
147
+ const c = body[i];
148
+ if (c === "'" || c === '"' || c === '`') {
149
+ const start = ++i;
150
+ while (i < body.length && body[i] !== c) i += body[i] === '\\' ? 2 : 1;
151
+ const value = body.slice(start, i);
152
+ /* ⚠ AN INTERPOLATED TEMPLATE IS NOT PROSE. `/problems-we-solve/#${p.id}`
153
+ is 26 characters of URL, and it was two of the strings that put a
154
+ search page's route table into the findings on a real site. */
155
+ if (value.length >= MIN_PROSE && !value.includes('${')) out.push(value);
156
+ i++;
157
+ continue;
158
+ }
159
+ i++;
160
+ }
161
+ return out;
162
+ }
163
+
164
+ const walk = (dir) =>
165
+ readdirSync(dir).flatMap((entry) => {
166
+ const full = join(dir, entry);
167
+ return statSync(full).isDirectory() ? walk(full) : [full];
168
+ });
169
+
170
+ /**
171
+ * Every inline content block under `root`.
172
+ *
173
+ * Returns `[{ file, name, items, strings, sample }]`, largest first, with paths
174
+ * in forward slashes so a Windows run reports what a Linux one does.
175
+ */
176
+ export function literalContent(root = 'src/pages') {
177
+ const out = [];
178
+
179
+ let files;
180
+ try {
181
+ files = walk(root).filter((f) => f.endsWith('.astro'));
182
+ } catch {
183
+ return out; // no pages directory is not this check's problem
184
+ }
185
+
186
+ for (const file of files) {
187
+ const source = readFileSync(file, 'utf8');
188
+ const fm = /^---\n([\s\S]*?)\n---/.exec(source);
189
+ if (!fm) continue;
190
+
191
+ const frontmatter = fm[1];
192
+ const rel = relative(process.cwd(), file).split(sep).join('/');
193
+
194
+ DECL.lastIndex = 0;
195
+ for (const match of frontmatter.matchAll(DECL)) {
196
+ const open = match.index + match[0].length - 1;
197
+ const region = readBracketed(frontmatter, open);
198
+ if (!region) continue;
199
+
200
+ const { body } = region;
201
+ if (STRUCTURED_DATA.test(body)) continue;
202
+
203
+ /*
204
+ * Every direct element must be an object literal. `[helper([…])]` is a
205
+ * call, and a call is not content however much prose it encloses — that
206
+ * is what lets a breadcrumb schema built by a helper out.
207
+ *
208
+ * ⚠ REQUIRING *EVERY* ELEMENT ALSO DROPS A LIST THAT SPREADS IN SHARED
209
+ * ITEMS, and that is deliberate. On a real site `[…, ...navSections,
210
+ * …]` was a sitemap page assembled from navigation data — structure,
211
+ * not copy. This under-reports rather than over-reports, which is the
212
+ * only safe direction for a warning: one that cries wolf gets switched
213
+ * off, and then its silence means "not looked at" rather than "fine".
214
+ */
215
+ const elements = directElements(body);
216
+ const objects = elements.filter((e) => e.startsWith('{'));
217
+ if (!objects.length || objects.length < elements.length) continue;
218
+
219
+ const strings = proseStrings(body);
220
+ if (strings.length < 2) continue;
221
+
222
+ out.push({
223
+ file: rel,
224
+ name: match[1],
225
+ items: objects.length,
226
+ strings: strings.length,
227
+ sample: strings[0].slice(0, 60),
228
+ });
229
+ }
230
+ }
231
+
232
+ return out.sort((a, b) => b.strings - a.strings || a.file.localeCompare(b.file));
233
+ }
@@ -41,6 +41,29 @@ import { join, relative, sep } from 'node:path';
41
41
  const IMAGE_COMPONENT = /<(Img|Image|Picture|BandHeader|Hero)\b[^>]*?\bname=["']([^"']+)["']/gis;
42
42
  const IMAGE_ATTR = /\b(?:image|poster|photo|bgImage|backgroundImage)=["']([^"']+)["']/gis;
43
43
 
44
+ /*
45
+ * ⚠ AN EXPRESSION IS NOT AUTOMATICALLY SAFE, AND ASSUMING SO MISSES THE
46
+ * IDIOMATIC ASTRO CASE ENTIRELY.
47
+ *
48
+ * The rule above — that `name={photo}` has no quotes and so "came from
49
+ * somewhere else" — holds when the value comes from content. It does NOT
50
+ * hold when "somewhere else" is a static import at the top of the same
51
+ * file:
52
+ *
53
+ * import heroBg from '@/assets/hero/forum-hero-silk-road.jpg';
54
+ * <Image src={heroBg} />
55
+ *
56
+ * That is the standard way to use Astro's image pipeline, the build
57
+ * optimises it properly, and the client still cannot change the photograph.
58
+ * Measured on a delivered site: EIGHT such imports across its pages while
59
+ * this check reported zero.
60
+ *
61
+ * Only counted when the identifier is used somewhere other than its own
62
+ * import line — an unused import renders nothing and is a lint's problem.
63
+ */
64
+ const IMAGE_IMPORT =
65
+ /^[ \t]*import\s+([A-Za-z_$][\w$]*)\s+from\s+["']([^"']+\.(?:jpe?g|png|webp|avif|gif))["']/gim;
66
+
44
67
  /** Values that are never a photograph a client would want to change. */
45
68
  const NOT_A_PHOTO = /^(#|https?:|data:|\/|\.\.?\/)|\.(svg|ico)$/i;
46
69
 
@@ -75,6 +98,18 @@ export function literalImages(root = 'src/pages') {
75
98
  contribute a finding, so strip both before matching. */
76
99
  const body = source.replace(/<!--[\s\S]*?-->/g, ' ');
77
100
 
101
+ IMAGE_IMPORT.lastIndex = 0;
102
+ for (const match of source.matchAll(IMAGE_IMPORT)) {
103
+ const [line, identifier, specifier] = match;
104
+ /* Used anywhere but its own import statement? */
105
+ const elsewhere = source.replace(line, ' ');
106
+ if (!new RegExp(`\\b${identifier}\\b`).test(elsewhere)) continue;
107
+ const key = `${rel}::${specifier}`;
108
+ if (seen.has(key)) continue;
109
+ seen.add(key);
110
+ out.push({ file: rel, value: specifier });
111
+ }
112
+
78
113
  for (const [regex, group] of [
79
114
  [IMAGE_COMPONENT, 2],
80
115
  [IMAGE_ATTR, 1],
@@ -16,32 +16,120 @@
16
16
  * A real AA failure, in the half nobody happened to test. Forcing the scheme
17
17
  * removes the luck.
18
18
  *
19
- * ── IT RUNS BOTH EVEN WITH NO DARK MODE ────────────────────────────────────
20
- * Deliberately. A fresh template ships no `prefers-color-scheme` rule at all,
21
- * so the two runs are identical and cost a few seconds. The day somebody adds
22
- * a dark palette is exactly the day the blind spot would otherwise open, and
23
- * a check you have to remember to switch on is a check that is off.
19
+ * ── THE FIRST FIX FOR THAT DID NOTHING AT ALL ────────────────────────────
20
+ * It passed `--force-prefers-color-scheme=<scheme>` to Chrome. There is no such
21
+ * switch. Chrome ignores flags it does not know WITHOUT A WORD, so both runs
22
+ * measured whatever scheme the machine was in light twice on a CI runner,
23
+ * dark twice on this laptop while printing "clean in light and dark".
24
+ *
25
+ * Measured on Chrome 148, reading the media query the page itself sees:
26
+ *
27
+ * no flag dark (this machine's setting)
28
+ * --force-prefers-color-scheme=light dark ← ignored
29
+ * --force-dark-mode dark ← browser UI only
30
+ * --blink-settings=preferredColorScheme=0 dark
31
+ * --blink-settings=preferredColorScheme=1 light
32
+ *
33
+ * So the dark palette of every site built with this kit went unmeasured by the
34
+ * gate that reported measuring it, and the evidence pack said so in writing.
35
+ * A gate that always passes is worse than no gate; one that produces a dated
36
+ * conformance document is worse again.
37
+ *
38
+ * ── AND WHY THE REPLACEMENT IS CHECKED AT RUN TIME ─────────────────────────
39
+ * `preferredColorScheme` is a Blink setting taken by its ORDINAL — the mojom
40
+ * enum `PreferredColorScheme { kDark, kLight }`. An ordinal is not a name: if
41
+ * that enum is ever reordered or the setting renamed, the flag goes quiet in
42
+ * exactly the same way, and out-of-range values fall back to light rather than
43
+ * erroring (`=2` renders light).
44
+ *
45
+ * `assertForced()` therefore never trusts the flag. It launches Chrome, reads
46
+ * `prefers-color-scheme` from a page, and refuses when what came back is not
47
+ * what was asked for. That assertion is the load-bearing part of this file —
48
+ * the flag is only how it is currently achieved.
24
49
  */
25
50
 
26
51
  export const SCHEMES = ['light', 'dark'];
27
52
 
53
+ /** mojom::blink::PreferredColorScheme — kDark = 0, kLight = 1. */
54
+ const ORDINAL = { dark: 0, light: 1 };
55
+
56
+ /** The Chrome arguments that pin `prefers-color-scheme` for a whole session. */
57
+ export function schemeArgs(scheme) {
58
+ return [`--blink-settings=preferredColorScheme=${ORDINAL[scheme]}`];
59
+ }
60
+
61
+ /** Arguments with any previous scheme forcing — including the dead flag — removed. */
62
+ function withoutScheme(args) {
63
+ return args.filter(
64
+ (a) =>
65
+ !a.startsWith('--force-prefers-color-scheme') &&
66
+ !a.startsWith('--blink-settings=preferredColorScheme'),
67
+ );
68
+ }
69
+
28
70
  /**
29
71
  * A pa11y-ci config with the colour scheme pinned.
30
72
  *
31
- * `--force-prefers-color-scheme` makes Chrome answer the media query the same
32
- * way on every machine, which is the only reason a contrast result is
33
- * comparable between a laptop and a runner.
73
+ * `.pa11yci.json` stays the only place the URL list lives; the scheme is
74
+ * injected into a copy at run time, because two config files is two URL lists
75
+ * and one of them goes stale.
34
76
  */
35
77
  export function configForScheme(base, scheme) {
36
78
  const defaults = { ...(base.defaults ?? {}) };
37
79
  const launch = { ...(defaults.chromeLaunchConfig ?? {}) };
38
- const args = (launch.args ?? []).filter((a) => !a.startsWith('--force-prefers-color-scheme'));
80
+ const args = withoutScheme(launch.args ?? []);
39
81
 
40
82
  return {
41
83
  ...base,
42
84
  defaults: {
43
85
  ...defaults,
44
- chromeLaunchConfig: { ...launch, args: [...args, `--force-prefers-color-scheme=${scheme}`] },
86
+ chromeLaunchConfig: { ...launch, args: [...args, ...schemeArgs(scheme)] },
45
87
  },
46
88
  };
47
89
  }
90
+
91
+ /**
92
+ * Prove Chrome actually answers `prefers-color-scheme` the way we asked, by
93
+ * launching it and reading the media query from a page.
94
+ *
95
+ * Returns null when the forcing works, and the reason it cannot be trusted
96
+ * otherwise — a string the caller prints before refusing to run. It is a
97
+ * refusal rather than a warning on purpose: a scheme that did not take means
98
+ * the run measures one palette twice and reports two.
99
+ */
100
+ export async function assertForced(scheme) {
101
+ let puppeteer;
102
+ try {
103
+ puppeteer = (await import('puppeteer')).default;
104
+ } catch {
105
+ return (
106
+ 'puppeteer is not installed, so the colour scheme cannot be verified.\n' +
107
+ ' It normally arrives with pa11y-ci: npm install'
108
+ );
109
+ }
110
+
111
+ let browser;
112
+ try {
113
+ browser = await puppeteer.launch({ args: ['--no-sandbox', ...schemeArgs(scheme)] });
114
+ const page = await browser.newPage();
115
+ /* A data: URL — the probe must not depend on the site being up, because
116
+ "the server is down" and "the flag stopped working" would then look the
117
+ same and the second one is the dangerous one. */
118
+ await page.goto('data:text/html,<!doctype html><title>scheme probe</title>');
119
+ const got = (await page.evaluate(() => matchMedia('(prefers-color-scheme: dark)').matches))
120
+ ? 'dark'
121
+ : 'light';
122
+ if (got !== scheme) {
123
+ return (
124
+ `asked Chrome for ${scheme} and the page reported ${got}.\n` +
125
+ ` ${schemeArgs(scheme).join(' ')} no longer forces the scheme, so a run now\n` +
126
+ ` measures this machine's palette twice and reports it as both.`
127
+ );
128
+ }
129
+ return null;
130
+ } catch (error) {
131
+ return `could not launch Chrome to verify the colour scheme: ${String(error).slice(0, 200)}`;
132
+ } finally {
133
+ await browser?.close();
134
+ }
135
+ }