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.
- package/package.json +1 -1
- package/template/CLAUDE.md +3 -1
- package/template/docs/dependencies.md +14 -5
- package/template/package-lock.json +509 -982
- package/template/package.json +1 -0
- package/template/scripts/a11y-evidence.mjs +31 -4
- package/template/scripts/check-a11y.mjs +16 -1
- package/template/scripts/check-cms.mjs +668 -26
- package/template/scripts/check-drift.mjs +154 -8
- package/template/scripts/lib/literal-content.mjs +233 -0
- package/template/scripts/lib/literal-images.mjs +35 -0
- package/template/scripts/lib/schemes.mjs +98 -10
- package/template/scripts/md-to-pdf.mjs +19 -13
|
@@ -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
|
-
|
|
212
|
-
|
|
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 (
|
|
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
|
|
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
|
-
:
|
|
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
|
|
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
|
-
:
|
|
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
|
-
* ──
|
|
20
|
-
*
|
|
21
|
-
*
|
|
22
|
-
*
|
|
23
|
-
*
|
|
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
|
-
*
|
|
32
|
-
*
|
|
33
|
-
*
|
|
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 ?? [])
|
|
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,
|
|
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
|
+
}
|