create-website-build-kit 0.1.2 → 0.1.4
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/docs/traps.md +74 -0
- package/template/scripts/a11y-evidence.mjs +11 -1
- package/template/scripts/check-a11y.mjs +9 -1
- package/template/scripts/check-env.mjs +62 -7
- package/template/scripts/og-cards.mjs +14 -3
- package/template/scripts/recon.mjs +2 -3
- package/template/scripts/tells.mjs +16 -3
- package/template/src/data/lastmod.json +5 -1
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.4",
|
|
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",
|
package/template/docs/traps.md
CHANGED
|
@@ -930,3 +930,77 @@ Worth knowing before you go hunting: this got *more* visible when the run starte
|
|
|
930
930
|
covering both colour schemes, because that doubles the number of Chrome sessions
|
|
931
931
|
and so doubles the chances of hitting the race. The change that surfaced it was
|
|
932
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".**
|
|
972
|
+
|
|
973
|
+
### A checker that counts source AND build output counts everything twice
|
|
974
|
+
|
|
975
|
+
**Symptom:** `npm run tells` reports *"3 auto-fill/auto-fit minmax grids"* on a
|
|
976
|
+
project whose entire source contains **one**. The threshold is "more than
|
|
977
|
+
twice", so a single grid fails the check. Removing grids does not help — the
|
|
978
|
+
count only drops when you get to zero.
|
|
979
|
+
|
|
980
|
+
`tells.mjs` reads `allCss = [...styleFiles, ...distCss]`, and including the
|
|
981
|
+
built stylesheets is deliberate and right *for presence tests*: a rule that
|
|
982
|
+
never reaches the build is not a rule the site has. It is wrong for **counting**.
|
|
983
|
+
Astro inlines shared CSS into every entry bundle, so one rule in `project.css`
|
|
984
|
+
is read once from source and again from each built stylesheet:
|
|
985
|
+
|
|
986
|
+
```
|
|
987
|
+
1× src/styles/project.css
|
|
988
|
+
1× dist/client/_astro/Base.U1P5p-HP.css
|
|
989
|
+
1× dist/client/_astro/contact.D3DAZGAV.css
|
|
990
|
+
```
|
|
991
|
+
|
|
992
|
+
One rule, three matches, against a threshold of two.
|
|
993
|
+
|
|
994
|
+
**Fix:** count from source only. `tells.mjs` now builds a separate `sourceCss`
|
|
995
|
+
for the three tells that count rather than test presence — the grid count, the
|
|
996
|
+
long-animation count and the stripped-focus-ring count. The presence tests keep
|
|
997
|
+
reading the built CSS, because for those the build output is the point.
|
|
998
|
+
|
|
999
|
+
**Why it hid for so long:** the other two counting tells threshold at `> 0`, so
|
|
1000
|
+
duplication inflated their *reported number* without ever changing the verdict.
|
|
1001
|
+
Only the grid tell compares against a number greater than one, and only that one
|
|
1002
|
+
gave a wrong answer. The bug was in all three the whole time.
|
|
1003
|
+
|
|
1004
|
+
The general shape is worth keeping: **a checker that reads both a source and a
|
|
1005
|
+
generated copy of that source is counting the same thing more than once.** If
|
|
1006
|
+
its threshold is anything other than "any", it is wrong.
|
|
@@ -89,6 +89,14 @@ const runners = config.defaults?.runners ?? ['htmlcs'];
|
|
|
89
89
|
* was tested when half of it was not is the thing somebody hands to a lawyer.
|
|
90
90
|
* See scripts/lib/schemes.mjs.
|
|
91
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
|
+
|
|
92
100
|
const tmp = mkdtempSync(join(tmpdir(), 'a11y-evidence-'));
|
|
93
101
|
|
|
94
102
|
const runScheme = (scheme) => {
|
|
@@ -104,7 +112,9 @@ const runScheme = (scheme) => {
|
|
|
104
112
|
try {
|
|
105
113
|
/* pa11y-ci exits non-zero when it finds errors, and still prints the JSON.
|
|
106
114
|
A non-zero exit here is a RESULT, not a failure to run. */
|
|
107
|
-
return JSON.parse(
|
|
115
|
+
return JSON.parse(
|
|
116
|
+
execFileSync('npx', args, { encoding: 'utf8', maxBuffer: 64 * 1024 * 1024, shell: WIN }),
|
|
117
|
+
);
|
|
108
118
|
} catch (error) {
|
|
109
119
|
const out = error.stdout?.toString() ?? '';
|
|
110
120
|
try {
|
|
@@ -28,6 +28,14 @@ const GREEN = '\x1b[32m';
|
|
|
28
28
|
const DIM = '\x1b[2m';
|
|
29
29
|
const BOLD = '\x1b[1m';
|
|
30
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
|
+
|
|
31
39
|
const CONFIG = '.pa11yci.json';
|
|
32
40
|
if (!existsSync(CONFIG)) {
|
|
33
41
|
console.error(`${RED}✗${RESET} ${CONFIG} not found — run this from the site root.`);
|
|
@@ -58,7 +66,7 @@ for (const scheme of schemes) {
|
|
|
58
66
|
|
|
59
67
|
console.log(`${BOLD}${scheme}${RESET}`);
|
|
60
68
|
try {
|
|
61
|
-
execFileSync('npx', ['pa11y-ci', '--config', file], { stdio: 'inherit' });
|
|
69
|
+
execFileSync('npx', ['pa11y-ci', '--config', file], { stdio: 'inherit', shell: WIN });
|
|
62
70
|
console.log(` ${GREEN}✓${RESET} ${scheme} clean\n`);
|
|
63
71
|
} catch {
|
|
64
72
|
failed++;
|
|
@@ -11,7 +11,8 @@
|
|
|
11
11
|
* ── THE FAILURE THIS EXISTS FOR ────────────────────────────────────────────
|
|
12
12
|
* `PUBLIC_SITE_ENV` decides indexability, canonical host, which KV namespace
|
|
13
13
|
* leads land in, and whether analytics is emitted at all. `wrangler.jsonc`
|
|
14
|
-
* decides which domains answer. Nothing connects the two
|
|
14
|
+
* decides which domains answer. Nothing connects the two — this script is the
|
|
15
|
+
* only thing that does, so it needs no per-project editing to work.
|
|
15
16
|
*
|
|
16
17
|
* At go-live, two edits have to happen together: the routes gain
|
|
17
18
|
* example.com, and the build command becomes `build:production`. Do
|
|
@@ -56,14 +57,68 @@ const patterns = (wrangler.routes ?? []).map((r) => (typeof r === 'string' ? r :
|
|
|
56
57
|
was pointed at staging-only routes. It would have blocked the cutover. */
|
|
57
58
|
const hostOf = (p) => String(p).split('/')[0];
|
|
58
59
|
/*
|
|
59
|
-
*
|
|
60
|
-
*
|
|
61
|
-
*
|
|
62
|
-
*
|
|
60
|
+
* The production hostnames come from src/data/site.ts — the same list the site
|
|
61
|
+
* itself uses to decide indexability, canonicals and which KV namespace leads
|
|
62
|
+
* land in. NOT a copy of it.
|
|
63
|
+
*
|
|
64
|
+
* ── WHY THIS IS NOT A CONSTANT HERE ────────────────────────────────────────
|
|
65
|
+
* It used to be one, with a comment saying to keep it in step with site.ts.
|
|
66
|
+
* On the first real project it was not: site.ts had the client's domain, this
|
|
67
|
+
* file still had the template's example.com. So the guard matched nothing,
|
|
68
|
+
* called every deploy fine, and passed for the whole build — a guard that
|
|
69
|
+
* always passes is worse than none, because it reads as a check that ran.
|
|
70
|
+
*
|
|
71
|
+
* The drift WAS the failure this script exists to catch, reproduced inside the
|
|
72
|
+
* script. One source of truth is the only fix that holds; a sterner comment
|
|
73
|
+
* would not have survived the same afternoon.
|
|
74
|
+
*
|
|
75
|
+
* site.ts is TypeScript and imports `import.meta.env`, so node cannot import
|
|
76
|
+
* it. Read the literal out instead — same reasoning as stripping comments from
|
|
77
|
+
* wrangler.jsonc above rather than adding a parser.
|
|
63
78
|
*/
|
|
64
|
-
|
|
79
|
+
function readProductionHosts() {
|
|
80
|
+
let src;
|
|
81
|
+
try {
|
|
82
|
+
src = readFileSync('src/data/site.ts', 'utf8');
|
|
83
|
+
} catch {
|
|
84
|
+
/* An ENOENT stack trace is not an answer to someone mid-deploy. It also
|
|
85
|
+
usually means the script is being run from the wrong directory. */
|
|
86
|
+
console.error(
|
|
87
|
+
`\n${RED}✗ src/data/site.ts not found${RESET}\n\n` +
|
|
88
|
+
' This guard reads the production hostnames from it. Run it from the\n' +
|
|
89
|
+
' project root — `npm run build:staging` and `build:production` do.\n',
|
|
90
|
+
);
|
|
91
|
+
process.exit(1);
|
|
92
|
+
}
|
|
93
|
+
const m = /export const PRODUCTION_HOSTS\s*=\s*\[([^\]]*)\]/.exec(src);
|
|
94
|
+
/* A guard that cannot find its own input must FAIL, never pass quietly —
|
|
95
|
+
that is the whole lesson above, and it applies to this branch too. */
|
|
96
|
+
if (!m) {
|
|
97
|
+
console.error(
|
|
98
|
+
`\n${RED}✗ cannot read PRODUCTION_HOSTS from src/data/site.ts${RESET}\n\n` +
|
|
99
|
+
' This guard derives the production hostnames from that export. Without it\n' +
|
|
100
|
+
' it cannot tell a production deploy from a staging one, so it refuses to\n' +
|
|
101
|
+
' pass rather than wave the build through.\n\n' +
|
|
102
|
+
" Expected a line like: export const PRODUCTION_HOSTS = ['example.com'] as const;\n",
|
|
103
|
+
);
|
|
104
|
+
process.exit(1);
|
|
105
|
+
}
|
|
106
|
+
const hosts = [...m[1].matchAll(/['"]([^'"]+)['"]/g)].map((h) => h[1].toLowerCase());
|
|
107
|
+
if (!hosts.length) {
|
|
108
|
+
console.error(
|
|
109
|
+
`\n${RED}✗ PRODUCTION_HOSTS in src/data/site.ts is empty${RESET}\n\n` +
|
|
110
|
+
' Every deploy would read as staging, including the production one.\n',
|
|
111
|
+
);
|
|
112
|
+
process.exit(1);
|
|
113
|
+
}
|
|
114
|
+
return hosts;
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
/* Exact membership, never a suffix match — `new.example.com` ends in
|
|
118
|
+
`example.com` and is NOT production. Same rule as isProductionHost(). */
|
|
119
|
+
const PRODUCTION_HOSTS = readProductionHosts();
|
|
65
120
|
|
|
66
|
-
const isProdHost = (p) =>
|
|
121
|
+
const isProdHost = (p) => PRODUCTION_HOSTS.includes(hostOf(p).toLowerCase());
|
|
67
122
|
const routesProduction = patterns.some(isProdHost);
|
|
68
123
|
const routesStagingOnly = patterns.length > 0 && !routesProduction;
|
|
69
124
|
|
|
@@ -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
|
});
|
|
@@ -64,6 +64,19 @@ const componentCss = componentFiles
|
|
|
64
64
|
.flatMap((source) => [...source.matchAll(/<style[^>]*>([\s\S]*?)<\/style>/g)].map((m) => m[1]))
|
|
65
65
|
.join('\n');
|
|
66
66
|
const everyCss = allCss + '\n' + componentCss;
|
|
67
|
+
|
|
68
|
+
/*
|
|
69
|
+
* ⚠ SOURCE ONLY, FOR ANYTHING THAT COUNTS. `allCss` deliberately includes the
|
|
70
|
+
* built stylesheets, which is right for presence tests — a rule that never
|
|
71
|
+
* reaches the build is not a rule the site has. It is wrong for counting: Astro
|
|
72
|
+
* inlines the same CSS into every entry bundle, so ONE rule in project.css is
|
|
73
|
+
* read once from source and again from each built stylesheet.
|
|
74
|
+
*
|
|
75
|
+
* That made the auto-fill grid tell fire on a single grid — 1 rule counted as
|
|
76
|
+
* 3, against a threshold of "more than twice". The tell was telling the truth
|
|
77
|
+
* about its own arithmetic and nothing about the site.
|
|
78
|
+
*/
|
|
79
|
+
const sourceCss = styleFiles.map(read).join('\n') + '\n' + componentCss;
|
|
67
80
|
/**
|
|
68
81
|
* Raw component source, not just its <style> blocks. Inline `style=`
|
|
69
82
|
* attributes are exactly where a card grid gets written when someone is
|
|
@@ -144,7 +157,7 @@ tell(
|
|
|
144
157
|
// "three equal cards, centred, more than twice on one page"
|
|
145
158
|
{
|
|
146
159
|
const grids = [
|
|
147
|
-
...(
|
|
160
|
+
...(sourceCss + componentSource).matchAll(/repeat\(\s*auto-(fill|fit)\s*,\s*minmax/g),
|
|
148
161
|
].length;
|
|
149
162
|
tell(
|
|
150
163
|
'the auto-fill card grid, more than twice',
|
|
@@ -185,7 +198,7 @@ tell(
|
|
|
185
198
|
|
|
186
199
|
// "any animation runs longer than ~400ms"
|
|
187
200
|
{
|
|
188
|
-
const slow = [...
|
|
201
|
+
const slow = [...sourceCss.matchAll(/(?:transition|animation)(?:-duration)?:[^;]*?(\d{3,4})ms/g)]
|
|
189
202
|
.map((m) => Number(m[1]))
|
|
190
203
|
.filter((ms) => ms > 400);
|
|
191
204
|
tell(
|
|
@@ -197,7 +210,7 @@ tell(
|
|
|
197
210
|
|
|
198
211
|
// "focus rings are the browser default, or removed"
|
|
199
212
|
{
|
|
200
|
-
const stripped = [...
|
|
213
|
+
const stripped = [...sourceCss.matchAll(/outline:\s*(none|0)\b/g)].length;
|
|
201
214
|
const restored = /:focus-visible[^{]*\{[^}]*outline:/.test(everyCss);
|
|
202
215
|
tell(
|
|
203
216
|
'focus ring removed and not replaced',
|