create-website-build-kit 0.1.0 → 0.1.2
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/README.md +27 -7
- package/package.json +2 -2
- package/template/.node-version +1 -1
- package/template/.pa11yci.json +18 -5
- package/template/docs/traps.md +33 -4
- package/template/package.json +1 -1
- package/template/scripts/a11y-evidence.mjs +88 -46
- package/template/scripts/check-a11y.mjs +78 -0
- package/template/scripts/lib/schemes.mjs +47 -0
- package/template/docs/handover.pdf +0 -0
package/README.md
CHANGED
|
@@ -1,14 +1,22 @@
|
|
|
1
|
-
|
|
1
|
+
<p align="center">
|
|
2
|
+
<img src="https://raw.githubusercontent.com/nurkamol/website-build-kit/master/site/brand/mark-light.png" width="72" height="72" alt="">
|
|
3
|
+
</p>
|
|
2
4
|
|
|
3
|
-
|
|
4
|
-
|
|
5
|
+
<h1 align="center">create-website-build-kit</h1>
|
|
6
|
+
|
|
7
|
+
<p align="center"><em>Your build was green. The site was wrong.</em></p>
|
|
5
8
|
|
|
6
9
|
```bash
|
|
7
10
|
npm create website-build-kit@latest my-site
|
|
8
11
|
```
|
|
9
12
|
|
|
13
|
+
Scaffold a production marketing site: **Astro, static, on Cloudflare Workers** — with the
|
|
14
|
+
gates, the migration playbook and the accessibility work already wired.
|
|
15
|
+
|
|
10
16
|
Node 22.12+. It builds green immediately, with no content, no images and no secrets.
|
|
11
17
|
|
|
18
|
+
**[The method, the traps and the docs →](https://nurkamol.github.io/website-build-kit/)**
|
|
19
|
+
|
|
12
20
|
## What you get
|
|
13
21
|
|
|
14
22
|
A skeleton, not a theme. It ships **no palette, no typeface and no home page** — deliberately,
|
|
@@ -45,10 +53,22 @@ Bricks — the extractable copy is the rendered HTML, never the database.
|
|
|
45
53
|
|
|
46
54
|
## The method
|
|
47
55
|
|
|
48
|
-
The template is half of it. The other half is a Claude Code skill
|
|
49
|
-
|
|
50
|
-
|
|
56
|
+
The template is half of it. The other half is a Claude Code skill that runs the whole job —
|
|
57
|
+
discovery, stack and provider decisions, the build, then verification against the deployed
|
|
58
|
+
site:
|
|
59
|
+
|
|
60
|
+
```
|
|
61
|
+
/plugin marketplace add nurkamol/website-build-kit
|
|
62
|
+
/plugin install website-build@website-build-kit
|
|
63
|
+
```
|
|
64
|
+
|
|
65
|
+
Then just ask: *"Rebuild https://acmeplumbing.com on Astro and Cloudflare — we're migrating
|
|
66
|
+
off Elementor."*
|
|
67
|
+
|
|
68
|
+
Behind it is the written method, including a file of failures that were **green in a build**:
|
|
69
|
+
clean build, clean types, clean deploy, wrong result.
|
|
51
70
|
|
|
52
|
-
**https://github.
|
|
71
|
+
- **Site:** <https://nurkamol.github.io/website-build-kit/>
|
|
72
|
+
- **Source:** <https://github.com/nurkamol/website-build-kit>
|
|
53
73
|
|
|
54
74
|
MIT.
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "create-website-build-kit",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.2",
|
|
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",
|
|
@@ -15,7 +15,7 @@
|
|
|
15
15
|
"wcag",
|
|
16
16
|
"seo"
|
|
17
17
|
],
|
|
18
|
-
"homepage": "https://github.
|
|
18
|
+
"homepage": "https://nurkamol.github.io/website-build-kit/",
|
|
19
19
|
"bugs": "https://github.com/nurkamol/website-build-kit/issues",
|
|
20
20
|
"repository": {
|
|
21
21
|
"type": "git",
|
package/template/.node-version
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
24.
|
|
1
|
+
24.19.0
|
package/template/.pa11yci.json
CHANGED
|
@@ -1,19 +1,32 @@
|
|
|
1
1
|
{
|
|
2
2
|
"defaults": {
|
|
3
3
|
"standard": "WCAG2AA",
|
|
4
|
-
"runners": [
|
|
4
|
+
"runners": [
|
|
5
|
+
"axe",
|
|
6
|
+
"htmlcs"
|
|
7
|
+
],
|
|
5
8
|
"timeout": 30000,
|
|
6
|
-
"concurrency":
|
|
9
|
+
"concurrency": 1,
|
|
7
10
|
"hideElements": "iframe[src*='google.com/maps'], iframe[src*='youtube.com']",
|
|
8
|
-
"chromeLaunchConfig": {
|
|
11
|
+
"chromeLaunchConfig": {
|
|
12
|
+
"args": [
|
|
13
|
+
"--no-sandbox"
|
|
14
|
+
]
|
|
15
|
+
}
|
|
9
16
|
},
|
|
10
17
|
"//": [
|
|
11
|
-
"One URL per template family, not every page
|
|
18
|
+
"One URL per template family, not every page \u2014 a homepage-only pass misses",
|
|
12
19
|
"everything the blog does differently. Point these at `wrangler dev`",
|
|
13
20
|
"(localhost:8788) while building, or at the deployed staging host.",
|
|
14
21
|
"Staging emits no sitemap on purpose, which is why these are explicit.",
|
|
15
22
|
"Against production you can instead run:",
|
|
16
|
-
" npx pa11y-ci --sitemap https://example.com/sitemap-index.xml --standard WCAG2AA"
|
|
23
|
+
" npx pa11y-ci --sitemap https://example.com/sitemap-index.xml --standard WCAG2AA",
|
|
24
|
+
"",
|
|
25
|
+
"concurrency is 1 DELIBERATELY. Above 1, pa11y intermittently reports",
|
|
26
|
+
"'Failed to run \u2014 Protocol error (Target.closeTarget)' on a random URL, which",
|
|
27
|
+
"reads as an accessibility failure and is Chrome tearing down targets.",
|
|
28
|
+
"Measured on this template: 4/5 runs failed at concurrency 4, 5/5 at 2, 0/5 at 1.",
|
|
29
|
+
"A gate that is wrong most of the time is a gate people learn to ignore."
|
|
17
30
|
],
|
|
18
31
|
"urls": [
|
|
19
32
|
"http://localhost:8788/",
|
package/template/docs/traps.md
CHANGED
|
@@ -182,7 +182,7 @@ perfectly, on time, to an empty room.
|
|
|
182
182
|
revealing everything only if the observer never fired at all — which is the
|
|
183
183
|
failure the guard actually exists for.
|
|
184
184
|
|
|
185
|
-
###
|
|
185
|
+
### The booking vendor's plugin renders nothing, because of a missing attribute
|
|
186
186
|
|
|
187
187
|
`/schedule/` was blank. The embed had been carried over from the WordPress site
|
|
188
188
|
before the booking vendor's snippet gained `locale="en"`. Without it the plugin loads,
|
|
@@ -192,13 +192,13 @@ container.
|
|
|
192
192
|
**Symptom:** no console error, no failed request, a populated network tab and
|
|
193
193
|
an empty div.
|
|
194
194
|
|
|
195
|
-
**Fix:** diff the embed against a freshly generated snippet from the
|
|
195
|
+
**Fix:** diff the embed against a freshly generated snippet from the booking vendor
|
|
196
196
|
dashboard rather than debugging the plugin. Any third-party embed carried
|
|
197
197
|
across a migration deserves the same check.
|
|
198
198
|
|
|
199
199
|
### A third-party widget that changes your own CSS
|
|
200
200
|
|
|
201
|
-
Enabling the
|
|
201
|
+
Enabling the booking vendor's webchat took three pages from 0 accessibility errors to
|
|
202
202
|
failing. It injects a stylesheet into the page. Separately, its fixed overlay
|
|
203
203
|
makes axe report colour-contrast violations on elements it covers, which look
|
|
204
204
|
identical to real failures — `htmlcs` reported nothing and the computed colours
|
|
@@ -418,7 +418,7 @@ site, which is what `docs/runbook.md` asks for.
|
|
|
418
418
|
|
|
419
419
|
### "It's inside a cross-origin iframe" is not the same as "it cannot be changed"
|
|
420
420
|
|
|
421
|
-
The client asked twice for the
|
|
421
|
+
The client asked twice for the booking vendor's panel to match
|
|
422
422
|
the cream page. The white comes from `html, body, #root { background-color:
|
|
423
423
|
#ffffff }` inside vendor.com's own document — cross-origin, unreachable, and
|
|
424
424
|
that was reported back as "the only lever is the booking vendor's branding settings."
|
|
@@ -901,3 +901,32 @@ grep -rnE '[a-z](https?://|[A-Z][a-z])' src/content/ | grep -vE 'iPhone|YouTube|
|
|
|
901
901
|
```
|
|
902
902
|
|
|
903
903
|
Expect a few false positives from camelCase and brand names; read them.
|
|
904
|
+
|
|
905
|
+
### The accessibility gate fails on a random URL, and it is not accessibility
|
|
906
|
+
|
|
907
|
+
**Symptom:** `npm run a11y` reports `Failed to run` against one URL, a different
|
|
908
|
+
one each time, with `Error: Protocol error (Target.closeTarget): No target with
|
|
909
|
+
given id found`. It reads as an accessibility failure on that page. The page is
|
|
910
|
+
fine, and a re-run usually blames a different page — or passes.
|
|
911
|
+
|
|
912
|
+
It is Chrome tearing down browser targets while another is still closing, and it
|
|
913
|
+
is driven entirely by pa11y's `concurrency`. Measured on this template, five runs
|
|
914
|
+
at each setting:
|
|
915
|
+
|
|
916
|
+
| `concurrency` | Runs that failed |
|
|
917
|
+
| --- | --- |
|
|
918
|
+
| 4 | **4 / 5** |
|
|
919
|
+
| 2 | **5 / 5** |
|
|
920
|
+
| 1 | **0 / 5** |
|
|
921
|
+
|
|
922
|
+
Note that 2 was no better than 4 — this is a race in target teardown, not a
|
|
923
|
+
resource limit, so lowering the number without going to 1 buys nothing.
|
|
924
|
+
|
|
925
|
+
**Fix:** `"concurrency": 1` in `.pa11yci.json`. Four URLs sequentially is a few
|
|
926
|
+
seconds; a gate that is wrong four times in five is a gate the team learns to
|
|
927
|
+
ignore, and then it is worse than no gate because its silence means nothing.
|
|
928
|
+
|
|
929
|
+
Worth knowing before you go hunting: this got *more* visible when the run started
|
|
930
|
+
covering both colour schemes, because that doubles the number of Chrome sessions
|
|
931
|
+
and so doubles the chances of hitting the race. The change that surfaced it was
|
|
932
|
+
not the change that caused it.
|
package/template/package.json
CHANGED
|
@@ -10,7 +10,7 @@
|
|
|
10
10
|
"scripts": {
|
|
11
11
|
"dev": "astro dev",
|
|
12
12
|
"check": "astro check",
|
|
13
|
-
"a11y": "
|
|
13
|
+
"a11y": "node scripts/check-a11y.mjs",
|
|
14
14
|
"a11y:evidence": "node scripts/a11y-evidence.mjs",
|
|
15
15
|
"reflow": "node scripts/check-reflow.mjs",
|
|
16
16
|
"console": "node scripts/check-console.mjs",
|
|
@@ -28,9 +28,13 @@
|
|
|
28
28
|
*/
|
|
29
29
|
|
|
30
30
|
import { execFileSync } from 'node:child_process';
|
|
31
|
-
import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs';
|
|
31
|
+
import { existsSync, mkdirSync, mkdtempSync, readFileSync, writeFileSync } from 'node:fs';
|
|
32
|
+
import { tmpdir } from 'node:os';
|
|
33
|
+
import { join } from 'node:path';
|
|
32
34
|
import { createRequire } from 'node:module';
|
|
33
35
|
|
|
36
|
+
import { SCHEMES, configForScheme } from './lib/schemes.mjs';
|
|
37
|
+
|
|
34
38
|
const require = createRequire(import.meta.url);
|
|
35
39
|
|
|
36
40
|
const RESET = '[0m';
|
|
@@ -72,45 +76,75 @@ const config = JSON.parse(readFileSync('.pa11yci.json', 'utf8'));
|
|
|
72
76
|
const standard = config.defaults?.standard ?? 'WCAG2AA';
|
|
73
77
|
const runners = config.defaults?.runners ?? ['htmlcs'];
|
|
74
78
|
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
79
|
+
/*
|
|
80
|
+
* ⚠ EVERY SCHEME, because the pack is a COMPLIANCE ARTEFACT.
|
|
81
|
+
*
|
|
82
|
+
* pa11y drives Chrome, and Chrome picks a colour scheme from the machine it
|
|
83
|
+
* runs on. A site with a light and a dark palette has two sets of contrast
|
|
84
|
+
* pairs, so a single run measures one and says nothing about the other —
|
|
85
|
+
* measured on the kit's own landing page at 4.83:1 dark and **3.91:1 light**,
|
|
86
|
+
* a real AA failure in the half nobody tested.
|
|
87
|
+
*
|
|
88
|
+
* A red CI step from that is a nuisance. A dated evidence pack claiming a site
|
|
89
|
+
* was tested when half of it was not is the thing somebody hands to a lawyer.
|
|
90
|
+
* See scripts/lib/schemes.mjs.
|
|
91
|
+
*/
|
|
92
|
+
const tmp = mkdtempSync(join(tmpdir(), 'a11y-evidence-'));
|
|
93
|
+
|
|
94
|
+
const runScheme = (scheme) => {
|
|
95
|
+
const file = join(tmp, `${scheme}.json`);
|
|
96
|
+
writeFileSync(file, JSON.stringify(configForScheme(config, scheme), null, 2));
|
|
97
|
+
|
|
98
|
+
/* --config supplies the forced-scheme defaults either way; --sitemap only
|
|
99
|
+
replaces where the URL list comes from. */
|
|
100
|
+
const args = host
|
|
101
|
+
? ['pa11y-ci', '--config', file, '--sitemap', `${host}/sitemap-index.xml`, '--standard', standard, '--json']
|
|
102
|
+
: ['pa11y-ci', '--config', file, '--json'];
|
|
78
103
|
|
|
79
|
-
let report;
|
|
80
|
-
try {
|
|
81
|
-
/* pa11y-ci exits non-zero when it finds errors, and still prints the JSON.
|
|
82
|
-
A non-zero exit here is a RESULT, not a failure to run. */
|
|
83
|
-
const out = execFileSync('npx', args, { encoding: 'utf8', maxBuffer: 64 * 1024 * 1024 });
|
|
84
|
-
report = JSON.parse(out);
|
|
85
|
-
} catch (error) {
|
|
86
|
-
const out = error.stdout?.toString() ?? '';
|
|
87
104
|
try {
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
105
|
+
/* pa11y-ci exits non-zero when it finds errors, and still prints the JSON.
|
|
106
|
+
A non-zero exit here is a RESULT, not a failure to run. */
|
|
107
|
+
return JSON.parse(execFileSync('npx', args, { encoding: 'utf8', maxBuffer: 64 * 1024 * 1024 }));
|
|
108
|
+
} catch (error) {
|
|
109
|
+
const out = error.stdout?.toString() ?? '';
|
|
110
|
+
try {
|
|
111
|
+
return JSON.parse(out);
|
|
112
|
+
} catch {
|
|
113
|
+
console.error(
|
|
114
|
+
`${RED}✗${RESET} pa11y-ci could not run.\n` +
|
|
115
|
+
` ${DIM}Start the site first: npm run build:staging && npx wrangler dev${RESET}\n` +
|
|
116
|
+
` ${(error.stderr?.toString() ?? '').slice(0, 400)}`,
|
|
117
|
+
);
|
|
118
|
+
process.exit(1);
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
};
|
|
122
|
+
|
|
123
|
+
const passes = SCHEMES.map((scheme) => {
|
|
124
|
+
const report = runScheme(scheme);
|
|
125
|
+
const rows = Object.entries(report.results).map(([url, issues]) => ({
|
|
126
|
+
url,
|
|
127
|
+
errors: issues.filter((i) => i.type === 'error'),
|
|
128
|
+
warnings: issues.filter((i) => i.type === 'warning'),
|
|
129
|
+
notices: issues.filter((i) => i.type === 'notice'),
|
|
130
|
+
}));
|
|
131
|
+
return { scheme, rows };
|
|
132
|
+
});
|
|
133
|
+
|
|
134
|
+
for (const { scheme, rows: schemeRows } of passes) {
|
|
135
|
+
console.log(` ${BOLD}${scheme}${RESET}`);
|
|
136
|
+
for (const r of schemeRows) {
|
|
137
|
+
const mark = r.errors.length ? `${RED}✗${RESET}` : `${GREEN}✓${RESET}`;
|
|
138
|
+
const path = new URL(r.url).pathname;
|
|
139
|
+
console.log(
|
|
140
|
+
` ${mark} ${path.padEnd(26)} ${String(r.errors.length).padStart(2)} error(s) ` +
|
|
141
|
+
`${DIM}${r.warnings.length} warning(s)${RESET}`,
|
|
94
142
|
);
|
|
95
|
-
process.exit(1);
|
|
96
143
|
}
|
|
97
144
|
}
|
|
98
145
|
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
errors: issues.filter((i) => i.type === 'error'),
|
|
102
|
-
warnings: issues.filter((i) => i.type === 'warning'),
|
|
103
|
-
notices: issues.filter((i) => i.type === 'notice'),
|
|
104
|
-
}));
|
|
105
|
-
|
|
106
|
-
for (const r of rows) {
|
|
107
|
-
const mark = r.errors.length ? `${RED}✗${RESET}` : `${GREEN}✓${RESET}`;
|
|
108
|
-
const path = new URL(r.url).pathname;
|
|
109
|
-
console.log(
|
|
110
|
-
` ${mark} ${path.padEnd(28)} ${String(r.errors.length).padStart(2)} error(s) ` +
|
|
111
|
-
`${DIM}${r.warnings.length} warning(s)${RESET}`,
|
|
112
|
-
);
|
|
113
|
-
}
|
|
146
|
+
/* Kept for the sections below that report per-URL rather than per-scheme. */
|
|
147
|
+
const rows = passes[0].rows;
|
|
114
148
|
|
|
115
149
|
/* ── 2. Reflow, which pa11y does not cover ───────────────────────────────── */
|
|
116
150
|
|
|
@@ -152,14 +186,17 @@ if (reviewed) {
|
|
|
152
186
|
|
|
153
187
|
/* ── 4. The pack ─────────────────────────────────────────────────────────── */
|
|
154
188
|
|
|
155
|
-
const totalErrors =
|
|
189
|
+
const totalErrors = passes.reduce(
|
|
190
|
+
(n, pass) => n + pass.rows.reduce((m, r) => m + r.errors.length, 0),
|
|
191
|
+
0,
|
|
192
|
+
);
|
|
156
193
|
const target = host ?? 'http://localhost:8788 (wrangler dev)';
|
|
157
194
|
|
|
158
|
-
const issueLines =
|
|
159
|
-
.filter((r) => r.errors.length)
|
|
195
|
+
const issueLines = passes
|
|
196
|
+
.flatMap((pass) => pass.rows.filter((r) => r.errors.length).map((r) => ({ ...r, scheme: pass.scheme })))
|
|
160
197
|
.map(
|
|
161
198
|
(r) =>
|
|
162
|
-
`### ${new URL(r.url).pathname}\n\n` +
|
|
199
|
+
`### ${new URL(r.url).pathname} — ${r.scheme}\n\n` +
|
|
163
200
|
r.errors
|
|
164
201
|
.map(
|
|
165
202
|
(e) =>
|
|
@@ -189,18 +226,23 @@ Standard: **${standard}** · runners: ${runners.join(', ')}
|
|
|
189
226
|
|
|
190
227
|
---
|
|
191
228
|
|
|
192
|
-
## 1. Automated — one URL per template family
|
|
229
|
+
## 1. Automated — one URL per template family, in both colour schemes
|
|
193
230
|
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
231
|
+
Each page is measured twice, with \`prefers-color-scheme\` forced. A light and a dark
|
|
232
|
+
palette are two sets of contrast pairs, and a single run measures one of them.
|
|
233
|
+
|
|
234
|
+
| Page | Scheme | Errors | Warnings | Notices |
|
|
235
|
+
| --- | --- | --- | --- | --- |
|
|
236
|
+
${passes
|
|
237
|
+
.flatMap((pass) =>
|
|
238
|
+
pass.rows.map(
|
|
239
|
+
(r) =>
|
|
240
|
+
`| \`${new URL(r.url).pathname}\` | ${pass.scheme} | ${r.errors.length} | ${r.warnings.length} | ${r.notices.length} |`,
|
|
241
|
+
),
|
|
200
242
|
)
|
|
201
243
|
.join('\n')}
|
|
202
244
|
|
|
203
|
-
**${totalErrors} error(s) across ${rows.length} page(s).**
|
|
245
|
+
**${totalErrors} error(s) across ${rows.length} page(s) × ${passes.length} scheme(s).**
|
|
204
246
|
|
|
205
247
|
${issueLines || '_No errors at this standard._'}
|
|
206
248
|
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* pa11y-ci at WCAG 2.2 AA, in BOTH colour schemes.
|
|
3
|
+
*
|
|
4
|
+
* npm run a11y # every URL in .pa11yci.json
|
|
5
|
+
* npm run a11y -- --only=light # one scheme
|
|
6
|
+
*
|
|
7
|
+
* ── WHY NOT JUST `pa11y-ci --config .pa11yci.json` ─────────────────────────
|
|
8
|
+
* Because that measures whichever scheme the machine happens to be in. See
|
|
9
|
+
* scripts/lib/schemes.mjs — the kit's own landing page passed a local run in
|
|
10
|
+
* dark mode and failed CI in light on a genuine 3.91:1 contrast pair.
|
|
11
|
+
*
|
|
12
|
+
* ── ONE SOURCE FOR THE URLS ────────────────────────────────────────────────
|
|
13
|
+
* `.pa11yci.json` stays the only place the URL list lives. The scheme is
|
|
14
|
+
* injected into a copy at run time rather than kept as a second config file,
|
|
15
|
+
* because two config files is two URL lists and one of them goes stale.
|
|
16
|
+
*/
|
|
17
|
+
|
|
18
|
+
import { execFileSync } from 'node:child_process';
|
|
19
|
+
import { existsSync, mkdtempSync, readFileSync, writeFileSync } from 'node:fs';
|
|
20
|
+
import { tmpdir } from 'node:os';
|
|
21
|
+
import { join } from 'node:path';
|
|
22
|
+
|
|
23
|
+
import { SCHEMES, configForScheme } from './lib/schemes.mjs';
|
|
24
|
+
|
|
25
|
+
const RESET = '\x1b[0m';
|
|
26
|
+
const RED = '\x1b[31m';
|
|
27
|
+
const GREEN = '\x1b[32m';
|
|
28
|
+
const DIM = '\x1b[2m';
|
|
29
|
+
const BOLD = '\x1b[1m';
|
|
30
|
+
|
|
31
|
+
const CONFIG = '.pa11yci.json';
|
|
32
|
+
if (!existsSync(CONFIG)) {
|
|
33
|
+
console.error(`${RED}✗${RESET} ${CONFIG} not found — run this from the site root.`);
|
|
34
|
+
process.exit(1);
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
const args = process.argv.slice(2);
|
|
38
|
+
const only = (args.find((a) => a.startsWith('--only=')) ?? '').replace('--only=', '');
|
|
39
|
+
const schemes = only ? SCHEMES.filter((s) => s === only) : SCHEMES;
|
|
40
|
+
|
|
41
|
+
if (!schemes.length) {
|
|
42
|
+
console.error(`${RED}✗${RESET} --only=${only} is not a scheme. Try: ${SCHEMES.join(', ')}`);
|
|
43
|
+
process.exit(1);
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
const base = JSON.parse(readFileSync(CONFIG, 'utf8'));
|
|
47
|
+
const urlCount = (base.urls ?? []).length;
|
|
48
|
+
const dir = mkdtempSync(join(tmpdir(), 'a11y-'));
|
|
49
|
+
|
|
50
|
+
console.log(`${BOLD}── Accessibility · WCAG2AA ${'─'.repeat(33)}${RESET}`);
|
|
51
|
+
console.log(` ${DIM}${urlCount} URL(s) × ${schemes.length} scheme(s)${RESET}\n`);
|
|
52
|
+
|
|
53
|
+
let failed = 0;
|
|
54
|
+
|
|
55
|
+
for (const scheme of schemes) {
|
|
56
|
+
const file = join(dir, `${scheme}.json`);
|
|
57
|
+
writeFileSync(file, JSON.stringify(configForScheme(base, scheme), null, 2));
|
|
58
|
+
|
|
59
|
+
console.log(`${BOLD}${scheme}${RESET}`);
|
|
60
|
+
try {
|
|
61
|
+
execFileSync('npx', ['pa11y-ci', '--config', file], { stdio: 'inherit' });
|
|
62
|
+
console.log(` ${GREEN}✓${RESET} ${scheme} clean\n`);
|
|
63
|
+
} catch {
|
|
64
|
+
failed++;
|
|
65
|
+
console.log(` ${RED}✗${RESET} ${scheme} has errors\n`);
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
if (failed) {
|
|
70
|
+
console.error(`${RED}✗ ${failed} of ${schemes.length} scheme(s) failed${RESET}\n`);
|
|
71
|
+
process.exit(1);
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
console.log(
|
|
75
|
+
`${GREEN}✓ clean in ${schemes.join(' and ')}${RESET}` +
|
|
76
|
+
(schemes.length === 1 ? `\n ${DIM}only one scheme measured — the other is untested${RESET}` : '') +
|
|
77
|
+
'\n',
|
|
78
|
+
);
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Colour schemes an accessibility run has to cover.
|
|
3
|
+
*
|
|
4
|
+
* ── WHY THIS EXISTS ────────────────────────────────────────────────────────
|
|
5
|
+
* A site with a light and a dark palette has TWO sets of contrast pairs, and
|
|
6
|
+
* testing one proves nothing about the other. pa11y drives Chrome, and Chrome
|
|
7
|
+
* picks a scheme from the machine it runs on — so the same page passes on a
|
|
8
|
+
* developer's laptop in dark mode and fails in CI in light, from one commit,
|
|
9
|
+
* with nothing to say which half was measured.
|
|
10
|
+
*
|
|
11
|
+
* That is not hypothetical. The kit's own landing page passed locally and
|
|
12
|
+
* failed the moment it ran on a light-mode runner:
|
|
13
|
+
*
|
|
14
|
+
* --ink-3 on the page background 4.83:1 dark · 3.91:1 light
|
|
15
|
+
*
|
|
16
|
+
* A real AA failure, in the half nobody happened to test. Forcing the scheme
|
|
17
|
+
* removes the luck.
|
|
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.
|
|
24
|
+
*/
|
|
25
|
+
|
|
26
|
+
export const SCHEMES = ['light', 'dark'];
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* A pa11y-ci config with the colour scheme pinned.
|
|
30
|
+
*
|
|
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.
|
|
34
|
+
*/
|
|
35
|
+
export function configForScheme(base, scheme) {
|
|
36
|
+
const defaults = { ...(base.defaults ?? {}) };
|
|
37
|
+
const launch = { ...(defaults.chromeLaunchConfig ?? {}) };
|
|
38
|
+
const args = (launch.args ?? []).filter((a) => !a.startsWith('--force-prefers-color-scheme'));
|
|
39
|
+
|
|
40
|
+
return {
|
|
41
|
+
...base,
|
|
42
|
+
defaults: {
|
|
43
|
+
...defaults,
|
|
44
|
+
chromeLaunchConfig: { ...launch, args: [...args, `--force-prefers-color-scheme=${scheme}`] },
|
|
45
|
+
},
|
|
46
|
+
};
|
|
47
|
+
}
|
|
Binary file
|