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 CHANGED
@@ -1,14 +1,22 @@
1
- # create-website-build-kit
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
- Scaffold a production marketing site: **Astro, static, on Cloudflare Workers** — with the
4
- gates, the migration playbook and the accessibility work already wired.
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 and the written method
49
- behind it, including a file of failures that were **green in a build**: clean build, clean
50
- types, clean deploy, wrong result.
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.com/nurkamol/website-build-kit**
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.0",
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.com/nurkamol/website-build-kit#readme",
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",
@@ -1 +1 @@
1
- 24.2.0
1
+ 24.19.0
@@ -1,19 +1,32 @@
1
1
  {
2
2
  "defaults": {
3
3
  "standard": "WCAG2AA",
4
- "runners": ["axe", "htmlcs"],
4
+ "runners": [
5
+ "axe",
6
+ "htmlcs"
7
+ ],
5
8
  "timeout": 30000,
6
- "concurrency": 4,
9
+ "concurrency": 1,
7
10
  "hideElements": "iframe[src*='google.com/maps'], iframe[src*='youtube.com']",
8
- "chromeLaunchConfig": { "args": ["--no-sandbox"] }
11
+ "chromeLaunchConfig": {
12
+ "args": [
13
+ "--no-sandbox"
14
+ ]
15
+ }
9
16
  },
10
17
  "//": [
11
- "One URL per template family, not every page a homepage-only pass misses",
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/",
@@ -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
- ### A the booking vendor plugin that renders nothing because of a missing attribute
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 the booking vendor
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 the booking vendor webchat took three pages from 0 accessibility errors to
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 the booking vendor booking panel's white ground to match
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.
@@ -10,7 +10,7 @@
10
10
  "scripts": {
11
11
  "dev": "astro dev",
12
12
  "check": "astro check",
13
- "a11y": "pa11y-ci --config .pa11yci.json",
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 = '';
@@ -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
- const args = host
76
- ? ['pa11y-ci', '--sitemap', `${host}/sitemap-index.xml`, '--standard', standard, '--json']
77
- : ['pa11y-ci', '--config', '.pa11yci.json', '--json'];
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
- report = JSON.parse(out);
89
- } catch {
90
- console.error(
91
- `${RED}✗${RESET} pa11y-ci could not run.\n` +
92
- ` ${DIM}Start the site first: npm run build:staging && npx wrangler dev${RESET}\n` +
93
- ` ${(error.stderr?.toString() ?? '').slice(0, 400)}`,
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
- const rows = Object.entries(report.results).map(([url, issues]) => ({
100
- url,
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 = rows.reduce((n, r) => n + r.errors.length, 0);
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 = rows
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
- | Page | Errors | Warnings | Notices |
195
- | --- | --- | --- | --- |
196
- ${rows
197
- .map(
198
- (r) =>
199
- `| \`${new URL(r.url).pathname}\` | ${r.errors.length} | ${r.warnings.length} | ${r.notices.length} |`,
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