create-website-build-kit 0.1.1 → 0.1.3
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 +13 -4
- package/package.json +2 -2
- package/template/.node-version +1 -1
- package/template/.pa11yci.json +18 -5
- package/template/docs/traps.md +72 -4
- package/template/package.json +1 -1
- package/template/scripts/a11y-evidence.mjs +98 -46
- package/template/scripts/check-a11y.mjs +86 -0
- package/template/scripts/lib/schemes.mjs +47 -0
- package/template/scripts/og-cards.mjs +14 -3
- package/template/scripts/recon.mjs +2 -3
- package/template/src/data/lastmod.json +5 -1
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,
|
|
@@ -60,6 +68,7 @@ off Elementor."*
|
|
|
60
68
|
Behind it is the written method, including a file of failures that were **green in a build**:
|
|
61
69
|
clean build, clean types, clean deploy, wrong result.
|
|
62
70
|
|
|
63
|
-
**https://github.
|
|
71
|
+
- **Site:** <https://nurkamol.github.io/website-build-kit/>
|
|
72
|
+
- **Source:** <https://github.com/nurkamol/website-build-kit>
|
|
64
73
|
|
|
65
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.3",
|
|
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,71 @@ 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.
|
|
933
|
+
|
|
934
|
+
### A script throws `ReferenceError` for something nothing ever imported
|
|
935
|
+
|
|
936
|
+
**Symptom:** `npm run recon` runs the whole crawl, prints its sitemap and URL
|
|
937
|
+
sections, and then dies at the last one:
|
|
938
|
+
|
|
939
|
+
```
|
|
940
|
+
const PRESERVE = PRESERVED;
|
|
941
|
+
^
|
|
942
|
+
ReferenceError: PRESERVED is not defined
|
|
943
|
+
```
|
|
944
|
+
|
|
945
|
+
The file imports two things and uses a third. It shipped in a published package
|
|
946
|
+
and a user hit it on a real migration, on Windows, on the first command the
|
|
947
|
+
documentation tells you to run.
|
|
948
|
+
|
|
949
|
+
**Why nothing caught it.** This is the important part, because the instinct is
|
|
950
|
+
that surely *something* would have:
|
|
951
|
+
|
|
952
|
+
| | |
|
|
953
|
+
| --- | --- |
|
|
954
|
+
| `node --check` | Parses. An undefined identifier is **valid syntax** |
|
|
955
|
+
| `astro check` | Types `.astro` and `.ts`. The scripts are standalone `.mjs` |
|
|
956
|
+
| CI | Runs the build. `recon` needs a live site, so CI never runs it |
|
|
957
|
+
| Smoke-running it | The throw is on line 302, reached only after the crawl — tested, and a `--help` load-check passes the broken file |
|
|
958
|
+
|
|
959
|
+
**Fix:** import it. The real fix is the gate — `npm run check:refs` cross-checks
|
|
960
|
+
every name `scripts/lib/*.mjs` exports against every script that uses one, and
|
|
961
|
+
fails when a use has no import.
|
|
962
|
+
|
|
963
|
+
**The first version of that gate was worse than none.** It flagged every
|
|
964
|
+
SCREAMING_CASE identifier that was never bound, and produced seven false
|
|
965
|
+
positives on a clean tree: `WCAG` and `CAA` in prose, `ERR_ABORTED` inside a
|
|
966
|
+
regex literal, `AND` in a comment. Stripping comments and strings with regexes
|
|
967
|
+
is a losing game without a parser. Narrowing it to names the libs actually
|
|
968
|
+
export removed the guesswork — prose never collides with a real export.
|
|
969
|
+
|
|
970
|
+
**A checker with false positives gets switched off, and then its silence means
|
|
971
|
+
"nobody looked" rather than "nothing wrong".**
|
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,46 +76,86 @@ 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
|
+
/*
|
|
93
|
+
* ⚠ `shell: true` ON WINDOWS, and it is not optional there. `npx` is
|
|
94
|
+
* `npx.cmd`, and execFileSync does not resolve .cmd without a shell — it
|
|
95
|
+
* fails ENOENT, which reads as "npx is not installed" on a machine where it
|
|
96
|
+
* plainly is. Left off on POSIX, where a shell buys nothing and costs quoting.
|
|
97
|
+
*/
|
|
98
|
+
const WIN = process.platform === 'win32';
|
|
99
|
+
|
|
100
|
+
const tmp = mkdtempSync(join(tmpdir(), 'a11y-evidence-'));
|
|
101
|
+
|
|
102
|
+
const runScheme = (scheme) => {
|
|
103
|
+
const file = join(tmp, `${scheme}.json`);
|
|
104
|
+
writeFileSync(file, JSON.stringify(configForScheme(config, scheme), null, 2));
|
|
105
|
+
|
|
106
|
+
/* --config supplies the forced-scheme defaults either way; --sitemap only
|
|
107
|
+
replaces where the URL list comes from. */
|
|
108
|
+
const args = host
|
|
109
|
+
? ['pa11y-ci', '--config', file, '--sitemap', `${host}/sitemap-index.xml`, '--standard', standard, '--json']
|
|
110
|
+
: ['pa11y-ci', '--config', file, '--json'];
|
|
78
111
|
|
|
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
112
|
try {
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
` ${DIM}Start the site first: npm run build:staging && npx wrangler dev${RESET}\n` +
|
|
93
|
-
` ${(error.stderr?.toString() ?? '').slice(0, 400)}`,
|
|
113
|
+
/* pa11y-ci exits non-zero when it finds errors, and still prints the JSON.
|
|
114
|
+
A non-zero exit here is a RESULT, not a failure to run. */
|
|
115
|
+
return JSON.parse(
|
|
116
|
+
execFileSync('npx', args, { encoding: 'utf8', maxBuffer: 64 * 1024 * 1024, shell: WIN }),
|
|
94
117
|
);
|
|
95
|
-
|
|
118
|
+
} catch (error) {
|
|
119
|
+
const out = error.stdout?.toString() ?? '';
|
|
120
|
+
try {
|
|
121
|
+
return JSON.parse(out);
|
|
122
|
+
} catch {
|
|
123
|
+
console.error(
|
|
124
|
+
`${RED}✗${RESET} pa11y-ci could not run.\n` +
|
|
125
|
+
` ${DIM}Start the site first: npm run build:staging && npx wrangler dev${RESET}\n` +
|
|
126
|
+
` ${(error.stderr?.toString() ?? '').slice(0, 400)}`,
|
|
127
|
+
);
|
|
128
|
+
process.exit(1);
|
|
129
|
+
}
|
|
96
130
|
}
|
|
97
|
-
}
|
|
131
|
+
};
|
|
98
132
|
|
|
99
|
-
const
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
)
|
|
133
|
+
const passes = SCHEMES.map((scheme) => {
|
|
134
|
+
const report = runScheme(scheme);
|
|
135
|
+
const rows = Object.entries(report.results).map(([url, issues]) => ({
|
|
136
|
+
url,
|
|
137
|
+
errors: issues.filter((i) => i.type === 'error'),
|
|
138
|
+
warnings: issues.filter((i) => i.type === 'warning'),
|
|
139
|
+
notices: issues.filter((i) => i.type === 'notice'),
|
|
140
|
+
}));
|
|
141
|
+
return { scheme, rows };
|
|
142
|
+
});
|
|
143
|
+
|
|
144
|
+
for (const { scheme, rows: schemeRows } of passes) {
|
|
145
|
+
console.log(` ${BOLD}${scheme}${RESET}`);
|
|
146
|
+
for (const r of schemeRows) {
|
|
147
|
+
const mark = r.errors.length ? `${RED}✗${RESET}` : `${GREEN}✓${RESET}`;
|
|
148
|
+
const path = new URL(r.url).pathname;
|
|
149
|
+
console.log(
|
|
150
|
+
` ${mark} ${path.padEnd(26)} ${String(r.errors.length).padStart(2)} error(s) ` +
|
|
151
|
+
`${DIM}${r.warnings.length} warning(s)${RESET}`,
|
|
152
|
+
);
|
|
153
|
+
}
|
|
113
154
|
}
|
|
114
155
|
|
|
156
|
+
/* Kept for the sections below that report per-URL rather than per-scheme. */
|
|
157
|
+
const rows = passes[0].rows;
|
|
158
|
+
|
|
115
159
|
/* ── 2. Reflow, which pa11y does not cover ───────────────────────────────── */
|
|
116
160
|
|
|
117
161
|
console.log(`\n${BOLD}── reflow · 320px + 200% ${'─'.repeat(35)}${RESET}`);
|
|
@@ -152,14 +196,17 @@ if (reviewed) {
|
|
|
152
196
|
|
|
153
197
|
/* ── 4. The pack ─────────────────────────────────────────────────────────── */
|
|
154
198
|
|
|
155
|
-
const totalErrors =
|
|
199
|
+
const totalErrors = passes.reduce(
|
|
200
|
+
(n, pass) => n + pass.rows.reduce((m, r) => m + r.errors.length, 0),
|
|
201
|
+
0,
|
|
202
|
+
);
|
|
156
203
|
const target = host ?? 'http://localhost:8788 (wrangler dev)';
|
|
157
204
|
|
|
158
|
-
const issueLines =
|
|
159
|
-
.filter((r) => r.errors.length)
|
|
205
|
+
const issueLines = passes
|
|
206
|
+
.flatMap((pass) => pass.rows.filter((r) => r.errors.length).map((r) => ({ ...r, scheme: pass.scheme })))
|
|
160
207
|
.map(
|
|
161
208
|
(r) =>
|
|
162
|
-
`### ${new URL(r.url).pathname}\n\n` +
|
|
209
|
+
`### ${new URL(r.url).pathname} — ${r.scheme}\n\n` +
|
|
163
210
|
r.errors
|
|
164
211
|
.map(
|
|
165
212
|
(e) =>
|
|
@@ -189,18 +236,23 @@ Standard: **${standard}** · runners: ${runners.join(', ')}
|
|
|
189
236
|
|
|
190
237
|
---
|
|
191
238
|
|
|
192
|
-
## 1. Automated — one URL per template family
|
|
239
|
+
## 1. Automated — one URL per template family, in both colour schemes
|
|
193
240
|
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
241
|
+
Each page is measured twice, with \`prefers-color-scheme\` forced. A light and a dark
|
|
242
|
+
palette are two sets of contrast pairs, and a single run measures one of them.
|
|
243
|
+
|
|
244
|
+
| Page | Scheme | Errors | Warnings | Notices |
|
|
245
|
+
| --- | --- | --- | --- | --- |
|
|
246
|
+
${passes
|
|
247
|
+
.flatMap((pass) =>
|
|
248
|
+
pass.rows.map(
|
|
249
|
+
(r) =>
|
|
250
|
+
`| \`${new URL(r.url).pathname}\` | ${pass.scheme} | ${r.errors.length} | ${r.warnings.length} | ${r.notices.length} |`,
|
|
251
|
+
),
|
|
200
252
|
)
|
|
201
253
|
.join('\n')}
|
|
202
254
|
|
|
203
|
-
**${totalErrors} error(s) across ${rows.length} page(s).**
|
|
255
|
+
**${totalErrors} error(s) across ${rows.length} page(s) × ${passes.length} scheme(s).**
|
|
204
256
|
|
|
205
257
|
${issueLines || '_No errors at this standard._'}
|
|
206
258
|
|
|
@@ -0,0 +1,86 @@
|
|
|
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
|
+
/*
|
|
32
|
+
* ⚠ `shell: true` ON WINDOWS, and it is not optional there. `npx` is
|
|
33
|
+
* `npx.cmd`, and execFileSync does not resolve .cmd without a shell — it
|
|
34
|
+
* fails ENOENT, which reads as "npx is not installed" on a machine where it
|
|
35
|
+
* plainly is. Left off on POSIX, where a shell buys nothing and costs quoting.
|
|
36
|
+
*/
|
|
37
|
+
const WIN = process.platform === 'win32';
|
|
38
|
+
|
|
39
|
+
const CONFIG = '.pa11yci.json';
|
|
40
|
+
if (!existsSync(CONFIG)) {
|
|
41
|
+
console.error(`${RED}✗${RESET} ${CONFIG} not found — run this from the site root.`);
|
|
42
|
+
process.exit(1);
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
const args = process.argv.slice(2);
|
|
46
|
+
const only = (args.find((a) => a.startsWith('--only=')) ?? '').replace('--only=', '');
|
|
47
|
+
const schemes = only ? SCHEMES.filter((s) => s === only) : SCHEMES;
|
|
48
|
+
|
|
49
|
+
if (!schemes.length) {
|
|
50
|
+
console.error(`${RED}✗${RESET} --only=${only} is not a scheme. Try: ${SCHEMES.join(', ')}`);
|
|
51
|
+
process.exit(1);
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
const base = JSON.parse(readFileSync(CONFIG, 'utf8'));
|
|
55
|
+
const urlCount = (base.urls ?? []).length;
|
|
56
|
+
const dir = mkdtempSync(join(tmpdir(), 'a11y-'));
|
|
57
|
+
|
|
58
|
+
console.log(`${BOLD}── Accessibility · WCAG2AA ${'─'.repeat(33)}${RESET}`);
|
|
59
|
+
console.log(` ${DIM}${urlCount} URL(s) × ${schemes.length} scheme(s)${RESET}\n`);
|
|
60
|
+
|
|
61
|
+
let failed = 0;
|
|
62
|
+
|
|
63
|
+
for (const scheme of schemes) {
|
|
64
|
+
const file = join(dir, `${scheme}.json`);
|
|
65
|
+
writeFileSync(file, JSON.stringify(configForScheme(base, scheme), null, 2));
|
|
66
|
+
|
|
67
|
+
console.log(`${BOLD}${scheme}${RESET}`);
|
|
68
|
+
try {
|
|
69
|
+
execFileSync('npx', ['pa11y-ci', '--config', file], { stdio: 'inherit', shell: WIN });
|
|
70
|
+
console.log(` ${GREEN}✓${RESET} ${scheme} clean\n`);
|
|
71
|
+
} catch {
|
|
72
|
+
failed++;
|
|
73
|
+
console.log(` ${RED}✗${RESET} ${scheme} has errors\n`);
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
if (failed) {
|
|
78
|
+
console.error(`${RED}✗ ${failed} of ${schemes.length} scheme(s) failed${RESET}\n`);
|
|
79
|
+
process.exit(1);
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
console.log(
|
|
83
|
+
`${GREEN}✓ clean in ${schemes.join(' and ')}${RESET}` +
|
|
84
|
+
(schemes.length === 1 ? `\n ${DIM}only one scheme measured — the other is untested${RESET}` : '') +
|
|
85
|
+
'\n',
|
|
86
|
+
);
|
|
@@ -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
|
+
}
|
|
@@ -64,6 +64,14 @@ function have(bin, args = ['--version']) {
|
|
|
64
64
|
}
|
|
65
65
|
}
|
|
66
66
|
|
|
67
|
+
/** The install line for the platform this is actually running on. */
|
|
68
|
+
const hint = (brew, winget, apt) =>
|
|
69
|
+
process.platform === 'win32'
|
|
70
|
+
? `winget install ${winget}`
|
|
71
|
+
: process.platform === 'linux'
|
|
72
|
+
? `sudo apt install ${apt}`
|
|
73
|
+
: `brew install ${brew}`;
|
|
74
|
+
|
|
67
75
|
function preflight() {
|
|
68
76
|
const missing = [];
|
|
69
77
|
|
|
@@ -83,9 +91,12 @@ function preflight() {
|
|
|
83
91
|
}
|
|
84
92
|
|
|
85
93
|
const tools = [
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
94
|
+
/* Per-platform. "brew install" on Windows is not a hint, it is a dead
|
|
95
|
+
end — and this preflight exists precisely so a missing tool names its
|
|
96
|
+
own fix. */
|
|
97
|
+
['magick', ['-version'], 'ImageMagick', hint('imagemagick', 'ImageMagick.ImageMagick', 'imagemagick')],
|
|
98
|
+
['rsvg-convert', ['--version'], 'rsvg-convert', hint('librsvg', 'GNOME.Librsvg', 'librsvg2-bin')],
|
|
99
|
+
['python3', ['--version'], 'Python 3', hint('python', 'Python.Python.3.12', 'python3')],
|
|
89
100
|
];
|
|
90
101
|
for (const [bin, args, label, install] of tools) {
|
|
91
102
|
if (!have(bin, args)) {
|
|
@@ -28,6 +28,7 @@
|
|
|
28
28
|
import { mkdirSync, writeFileSync } from 'node:fs';
|
|
29
29
|
|
|
30
30
|
import { GONE_TAG } from './lib/inventory.mjs';
|
|
31
|
+
import { PRESERVED } from './lib/preserved.mjs';
|
|
31
32
|
|
|
32
33
|
const RESET = '[0m';
|
|
33
34
|
const RED = '[31m';
|
|
@@ -299,11 +300,9 @@ if (goneList.length) {
|
|
|
299
300
|
/* ── 3. Paths other systems point at ──────────────────────────────────── */
|
|
300
301
|
section('Preserved paths');
|
|
301
302
|
|
|
302
|
-
const PRESERVE = PRESERVED;
|
|
303
|
-
|
|
304
303
|
/* Manual redirects again: "serves a feed" and "301s to a feed" are different
|
|
305
304
|
facts, and only the first means the path must be reproduced. */
|
|
306
|
-
const preserved = await pool(
|
|
305
|
+
const preserved = await pool(PRESERVED, async ([path, why]) => {
|
|
307
306
|
const r = await req(`${ORIGIN}${path}`, { method: 'HEAD', redirect: 'manual' });
|
|
308
307
|
return { path, why, status: r?.status ?? 0, location: r?.headers.get('location') ?? '' };
|
|
309
308
|
});
|