create-website-build-kit 0.1.0

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.
Files changed (91) hide show
  1. package/README.md +54 -0
  2. package/index.mjs +149 -0
  3. package/package.json +42 -0
  4. package/template/.dev.vars.example +3 -0
  5. package/template/.github/workflows/gates.yml +58 -0
  6. package/template/.node-version +1 -0
  7. package/template/.pa11yci.json +24 -0
  8. package/template/BUILD-STATE.md +47 -0
  9. package/template/CLAUDE.md +153 -0
  10. package/template/astro.config.mjs +150 -0
  11. package/template/docs/analytics.md +86 -0
  12. package/template/docs/content.md +138 -0
  13. package/template/docs/handover.md +182 -0
  14. package/template/docs/handover.pdf +0 -0
  15. package/template/docs/runbook.md +661 -0
  16. package/template/docs/traps.md +903 -0
  17. package/template/gitignore +31 -0
  18. package/template/package-lock.json +8159 -0
  19. package/template/package.json +53 -0
  20. package/template/public/_headers +61 -0
  21. package/template/public/_redirects +39 -0
  22. package/template/public/site.webmanifest +13 -0
  23. package/template/scripts/a11y-evidence.mjs +258 -0
  24. package/template/scripts/check-console.mjs +125 -0
  25. package/template/scripts/check-env.mjs +99 -0
  26. package/template/scripts/check-reflow.mjs +148 -0
  27. package/template/scripts/check-sitemap.mjs +113 -0
  28. package/template/scripts/dns-snapshot.mjs +267 -0
  29. package/template/scripts/extract.mjs +317 -0
  30. package/template/scripts/indexnow.mjs +154 -0
  31. package/template/scripts/lastmod.mjs +147 -0
  32. package/template/scripts/lib/inventory.mjs +104 -0
  33. package/template/scripts/lib/preserved.mjs +42 -0
  34. package/template/scripts/lib/routes.mjs +92 -0
  35. package/template/scripts/md-to-pdf.mjs +335 -0
  36. package/template/scripts/og-cards.config.mjs +114 -0
  37. package/template/scripts/og-cards.mjs +487 -0
  38. package/template/scripts/optimize-media.mjs +380 -0
  39. package/template/scripts/recon.mjs +480 -0
  40. package/template/scripts/redirects.mjs +298 -0
  41. package/template/scripts/shots.mjs +447 -0
  42. package/template/scripts/staging-headers.mjs +102 -0
  43. package/template/scripts/tells.mjs +268 -0
  44. package/template/scripts/verify.mjs +1069 -0
  45. package/template/src/components/ContactForm.astro +405 -0
  46. package/template/src/components/CtaBand.astro +82 -0
  47. package/template/src/components/EnvBadge.astro +146 -0
  48. package/template/src/components/Footer.astro +210 -0
  49. package/template/src/components/Header.astro +530 -0
  50. package/template/src/components/Icon.astro +56 -0
  51. package/template/src/components/Img.astro +129 -0
  52. package/template/src/components/PageHero.astro +88 -0
  53. package/template/src/components/Seo.astro +119 -0
  54. package/template/src/components/StructuredData.astro +173 -0
  55. package/template/src/content/blog/.gitkeep +5 -0
  56. package/template/src/content/legal/.gitkeep +0 -0
  57. package/template/src/content.config.ts +81 -0
  58. package/template/src/data/areas.ts +31 -0
  59. package/template/src/data/business.ts +121 -0
  60. package/template/src/data/categories.ts +37 -0
  61. package/template/src/data/fonts.ts +25 -0
  62. package/template/src/data/image-manifest.json +1 -0
  63. package/template/src/data/lastmod.json +1 -0
  64. package/template/src/data/nav.ts +49 -0
  65. package/template/src/data/services.ts +39 -0
  66. package/template/src/data/site.ts +136 -0
  67. package/template/src/env.d.ts +28 -0
  68. package/template/src/layouts/Base.astro +223 -0
  69. package/template/src/lib/brevo.ts +96 -0
  70. package/template/src/lib/hast-media.mjs +55 -0
  71. package/template/src/lib/lastmod.mjs +47 -0
  72. package/template/src/lib/lead.ts +92 -0
  73. package/template/src/lib/legal-routes.mjs +31 -0
  74. package/template/src/lib/legal.ts +75 -0
  75. package/template/src/lib/posts.ts +64 -0
  76. package/template/src/lib/runtime.ts +33 -0
  77. package/template/src/pages/404.astro +51 -0
  78. package/template/src/pages/[slug].astro +111 -0
  79. package/template/src/pages/accessibility.astro +128 -0
  80. package/template/src/pages/api/contact.ts +191 -0
  81. package/template/src/pages/api/leads.csv.ts +82 -0
  82. package/template/src/pages/contact.astro +112 -0
  83. package/template/src/pages/index.astro +84 -0
  84. package/template/src/pages/robots.txt.ts +38 -0
  85. package/template/src/pages/rss.xml.ts +27 -0
  86. package/template/src/styles/global.css +463 -0
  87. package/template/src/styles/project.css +14 -0
  88. package/template/src/styles/prose.css +182 -0
  89. package/template/src/styles/tokens.css +218 -0
  90. package/template/tsconfig.json +5 -0
  91. package/template/wrangler.jsonc +63 -0
@@ -0,0 +1,53 @@
1
+ {
2
+ "name": "site-name",
3
+ "version": "1.0.0",
4
+ "private": true,
5
+ "license": "UNLICENSED",
6
+ "type": "module",
7
+ "engines": {
8
+ "node": ">=22.12.0"
9
+ },
10
+ "scripts": {
11
+ "dev": "astro dev",
12
+ "check": "astro check",
13
+ "a11y": "pa11y-ci --config .pa11yci.json",
14
+ "a11y:evidence": "node scripts/a11y-evidence.mjs",
15
+ "reflow": "node scripts/check-reflow.mjs",
16
+ "console": "node scripts/check-console.mjs",
17
+ "shots": "node scripts/shots.mjs",
18
+ "tells": "node scripts/tells.mjs",
19
+ "media": "node scripts/optimize-media.mjs",
20
+ "cards": "node scripts/og-cards.mjs",
21
+ "lastmod": "node scripts/lastmod.mjs",
22
+ "indexnow": "node scripts/indexnow.mjs",
23
+ "verify": "node scripts/verify.mjs",
24
+ "check:sitemap": "node scripts/check-sitemap.mjs",
25
+ "recon": "node scripts/recon.mjs",
26
+ "extract": "node scripts/extract.mjs",
27
+ "dns": "node scripts/dns-snapshot.mjs",
28
+ "seo": "npx --yes github:nurkamol/seo-audit",
29
+ "redirects": "node scripts/redirects.mjs",
30
+ "handover": "node scripts/md-to-pdf.mjs docs/handover.md docs/handover.pdf",
31
+ "build": "astro build",
32
+ "build:staging": "PUBLIC_SITE_ENV=staging PUBLIC_SITE_URL=https://new.example.com astro build && PUBLIC_SITE_ENV=staging node scripts/staging-headers.mjs && PUBLIC_SITE_ENV=staging node scripts/check-env.mjs",
33
+ "build:production": "node scripts/tells.mjs --undecided-only && PUBLIC_SITE_ENV=production astro check && PUBLIC_SITE_ENV=production PUBLIC_SITE_URL=https://example.com astro build && PUBLIC_SITE_ENV=production node scripts/check-env.mjs && node scripts/check-sitemap.mjs",
34
+ "preview": "wrangler dev",
35
+ "deploy:staging": "npm run build:staging && wrangler deploy",
36
+ "deploy:production": "npm run build:production && wrangler deploy"
37
+ },
38
+ "dependencies": {
39
+ "@astrojs/cloudflare": "^14.1.7",
40
+ "@astrojs/rss": "^4.0.19",
41
+ "@astrojs/sitemap": "^3.7.3",
42
+ "astro": "^7.1.6"
43
+ },
44
+ "devDependencies": {
45
+ "@astrojs/check": "^0.9.10",
46
+ "@cloudflare/workers-types": "^5.20260815.1",
47
+ "pa11y-ci": "^4.1.1",
48
+ "sharp": "^0.35.3",
49
+ "turndown": "^7.2.4",
50
+ "typescript": "^6.0.3",
51
+ "wrangler": "^4.118.0"
52
+ }
53
+ }
@@ -0,0 +1,61 @@
1
+ # ── Cache headers for assets the build does not fingerprint ──────────────────
2
+ #
3
+ # The Cloudflare adapter writes an immutable rule for /_astro/* — everything it
4
+ # hashes into a filename. Anything in public/ keeps the name it was given, so it
5
+ # gets the default instead: `public, max-age=0, must-revalidate`. Measured on
6
+ # staging, both webfonts came back with exactly that, which means every visit
7
+ # pays a revalidation round trip for a file that had not changed since the last
8
+ # deploy.
9
+ #
10
+ # ⚠ THESE FILENAMES ARE NOW A CONTRACT. `immutable` tells a browser never to
11
+ # ask again for a year, so a font or a badge REPLACED UNDER THE SAME NAME will
12
+ # not reach anyone who already has it. Change the filename when you change the
13
+ # file. That is the trade for not revalidating.
14
+
15
+ /fonts/*
16
+ Cache-Control: public, max-age=31536000, immutable
17
+
18
+ # Brand artwork: logos, the favicons' PNG fallbacks, the app-store badges.
19
+ /img/brand/*
20
+ Cache-Control: public, max-age=31536000, immutable
21
+
22
+ # Generated Open Graph cards. Regenerated by scripts/og-cards.mjs under the same
23
+ # names, so a week rather than a year — a stale share card is a live mistake.
24
+ /img/social/*
25
+ Cache-Control: public, max-age=604800
26
+
27
+ # The media pipeline emits these with width suffixes but no content hash.
28
+ /img/photos/*
29
+ Cache-Control: public, max-age=2592000
30
+
31
+ # ── Security headers ─────────────────────────────────────────────────────────
32
+ #
33
+ # HSTS and X-Content-Type-Options already arrive from the Cloudflare zone, not
34
+ # from here — check both are still present after any SSL/TLS settings change,
35
+ # because nothing in this repo would notice them disappearing.
36
+ #
37
+ # ⚠ WHAT THIS CSP DELIBERATELY DOES NOT DO: restrict scripts.
38
+ # `script-src` is absent on purpose. Google Tag Manager exists to inject
39
+ # arbitrary scripts chosen later by whoever runs the container, and this is a
40
+ # static site, so per-request nonces are not available. Any workable
41
+ # `script-src` would need `'unsafe-inline'` plus an allow-list of every origin
42
+ # GTM might load — which blocks almost no real XSS while silently breaking the
43
+ # next marketing tag the client adds. A CSP that gets switched off the first
44
+ # time it breaks something protects nobody.
45
+ #
46
+ # What IS here are the directives that cost nothing and cannot break a working
47
+ # page, verified against the live site before shipping:
48
+ # frame-ancestors clickjacking — nothing embeds this site
49
+ # base-uri stops an injected <base> rewriting every relative URL
50
+ # form-action every form on the site posts to its own origin
51
+ # object-src no plugins, no <object>, no Flash-era attack surface
52
+ #
53
+ # If a real script-src is ever wanted, ship it as Content-Security-Policy-
54
+ # Report-Only FIRST, and watch the console on every route that embeds a
55
+ # third-party surface — a booking widget, a chat bundle, a map, a review feed.
56
+ # Those are what a script-src breaks, and they break silently. Only then
57
+ # enforce.
58
+ /*
59
+ Referrer-Policy: strict-origin-when-cross-origin
60
+ Permissions-Policy: camera=(), microphone=(), geolocation=(), interest-cohort=()
61
+ Content-Security-Policy: frame-ancestors 'self'; base-uri 'self'; form-action 'self'; object-src 'none'
@@ -0,0 +1,39 @@
1
+ # Legacy URL map. Hit counts are from the Rank Math redirections export
2
+ # (e.g. dump/seo/rank-math-settings-*.json) — they are the difference between
3
+ # "cleanup" and "deleting traffic". Nothing here points at the homepage when a
4
+ # specific equivalent exists; search engines read that as a soft 404.
5
+
6
+ # ── Carried over from Rank Math ──────────────────────────────────────────────
7
+ # EXAMPLE, commented out because its target does not exist in a fresh template
8
+ # and `npm run verify` correctly reports a 301 into a 404. Uncomment and point
9
+ # it at a real route.
10
+ #
11
+ # Typo in an original slug, fixed later on the live site. Even at 0 recorded
12
+ # hits the wrong spelling is still in the wild and in old sitemaps.
13
+ #/exmaple-post-slug-with-a-typo /example-post-slug-with-a-typo/ 301
14
+
15
+ # ── WordPress plumbing that no longer exists ─────────────────────────────────
16
+ # These were live URLs on the old stack. Left to 404 they would show up in
17
+ # Search Console as broken links from anything that ever scraped the site.
18
+ /feed/ /rss.xml 301
19
+ /feed /rss.xml 301
20
+ /comments/feed/ /rss.xml 301
21
+ /sitemap_index.xml /sitemap-index.xml 301
22
+ /post-sitemap.xml /sitemap-index.xml 301
23
+ /page-sitemap.xml /sitemap-index.xml 301
24
+ /local-sitemap.xml /sitemap-index.xml 301
25
+
26
+ # Author archives were noindexed on the old site and have no equivalent here.
27
+ # The blog index is the closest real page, not the homepage.
28
+ /author/* /blog/ 301
29
+
30
+ # Attachment pages. Rank Math already redirected these to the homepage
31
+ # (`attachment_redirect_default`); the media library itself is gone. No rule
32
+ # here on purpose — Cloudflare `_redirects` only accepts 200/301/302/303/307/308,
33
+ # and these paths *should* 404 rather than 301 to a page that is not the image
34
+ # someone asked for. The catch-all worker returns a real 404 for them.
35
+
36
+ # ── Chrome ───────────────────────────────────────────────────────────────────
37
+ # /projects/ and /privacy-policy/ were registered in WordPress but returned 404
38
+ # on the live site — /privacy-policy/ now exists for real, /projects/ does not
39
+ # and correctly 404s.
@@ -0,0 +1,13 @@
1
+ {
2
+ "name": "Business Name",
3
+ "short_name": "Business",
4
+ "description": "What you do, in one sentence. Keep in sync with business.ts.",
5
+ "start_url": "/",
6
+ "display": "standalone",
7
+ "background_color": "#ffffff",
8
+ "theme_color": "#000000",
9
+ "icons": [
10
+ { "src": "/icon-192.png", "sizes": "192x192", "type": "image/png", "purpose": "any" },
11
+ { "src": "/icon-512.png", "sizes": "512x512", "type": "image/png", "purpose": "any" }
12
+ ]
13
+ }
@@ -0,0 +1,258 @@
1
+ /**
2
+ * Write the accessibility evidence pack.
3
+ *
4
+ * npm run a11y:evidence # against .pa11yci.json (wrangler dev)
5
+ * npm run a11y:evidence -- https://example.com # against a deployed host
6
+ *
7
+ * Writes docs/a11y-evidence/<date>.md and leaves it for you to commit.
8
+ *
9
+ * ── WHY THIS EXISTS ────────────────────────────────────────────────────────
10
+ * `build.md` phase 9 requires an evidence pack and `compliance.md` §7 defines
11
+ * what counts. Nothing produced it: `npm run a11y` printed to a terminal and
12
+ * the output died with the scrollback. So the artefact that has value if anyone
13
+ * ever asks was the one artefact never written down.
14
+ *
15
+ * `compliance.md` §7, verbatim: *"Hand over the evidence, not the assertion.
16
+ * Dated tool output, which pages were tested manually and how, known gaps with
17
+ * an owner."* Three of those four are things a script cannot know.
18
+ *
19
+ * ── IT MUST NOT LOOK COMPLETE ──────────────────────────────────────────────
20
+ * Automated tooling catches roughly a third of issues, and about 42% of WCAG
21
+ * criteria cannot be machine-checked at all. A pack that listed a clean axe run
22
+ * and stopped would be worse than no pack: it reads as a finished audit, and it
23
+ * is the floor.
24
+ *
25
+ * So the manual layers are written in as UNCHECKED every time, with the date
26
+ * they were last done left blank for a human. The file is deliberately not
27
+ * signable until someone fills those in.
28
+ */
29
+
30
+ import { execFileSync } from 'node:child_process';
31
+ import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs';
32
+ import { createRequire } from 'node:module';
33
+
34
+ const require = createRequire(import.meta.url);
35
+
36
+ const RESET = '';
37
+ const RED = '';
38
+ const GREEN = '';
39
+ const YELLOW = '';
40
+ const DIM = '';
41
+ const BOLD = '';
42
+
43
+ const host = process.argv.slice(2).find((a) => a.startsWith('http'))?.replace(/\/$/, '');
44
+ const OUT_DIR = 'docs/a11y-evidence';
45
+
46
+ /*
47
+ * LOCAL date, not `toISOString()`.
48
+ *
49
+ * This is a record a person attests to having made, so it has to carry the date
50
+ * that person would write on it. `toISOString()` is UTC: on a machine at UTC+5
51
+ * it dates the pack YESTERDAY for the first five hours of every day, and on a
52
+ * machine behind UTC it dates it tomorrow for the last few. Either way the file
53
+ * is named a day off, the staleness comparison drifts with it, and the error is
54
+ * invisible because the date it prints is always plausible.
55
+ */
56
+ const now = new Date();
57
+ const today = `${now.getFullYear()}-${String(now.getMonth() + 1).padStart(2, '0')}-${String(now.getDate()).padStart(2, '0')}`;
58
+
59
+ const version = (pkg) => {
60
+ try {
61
+ return require(`${pkg}/package.json`).version;
62
+ } catch {
63
+ return 'unknown';
64
+ }
65
+ };
66
+
67
+ /* ── 1. Automated sweep ──────────────────────────────────────────────────── */
68
+
69
+ console.log(`${BOLD}── pa11y-ci · WCAG2AA ${'─'.repeat(38)}${RESET}`);
70
+
71
+ const config = JSON.parse(readFileSync('.pa11yci.json', 'utf8'));
72
+ const standard = config.defaults?.standard ?? 'WCAG2AA';
73
+ const runners = config.defaults?.runners ?? ['htmlcs'];
74
+
75
+ const args = host
76
+ ? ['pa11y-ci', '--sitemap', `${host}/sitemap-index.xml`, '--standard', standard, '--json']
77
+ : ['pa11y-ci', '--config', '.pa11yci.json', '--json'];
78
+
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
+ 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)}`,
94
+ );
95
+ process.exit(1);
96
+ }
97
+ }
98
+
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
+ }
114
+
115
+ /* ── 2. Reflow, which pa11y does not cover ───────────────────────────────── */
116
+
117
+ console.log(`\n${BOLD}── reflow · 320px + 200% ${'─'.repeat(35)}${RESET}`);
118
+
119
+ let reflowOut = '';
120
+ let reflowOk = false;
121
+ try {
122
+ reflowOut = execFileSync('node', ['scripts/check-reflow.mjs', ...(host ? [host] : [])], {
123
+ encoding: 'utf8',
124
+ });
125
+ reflowOk = true;
126
+ } catch (error) {
127
+ reflowOut = (error.stdout?.toString() ?? '') + (error.stderr?.toString() ?? '');
128
+ }
129
+ const reflowSummary = reflowOut.trim().split('\n').filter(Boolean).slice(-1)[0] ?? 'did not run';
130
+ console.log(` ${reflowOk ? `${GREEN}✓${RESET}` : `${RED}✗${RESET}`} ${reflowSummary.replace(/\[[0-9;]*m/g, '')}`);
131
+
132
+ /* ── 3. Is the published statement still telling the truth? ──────────────── */
133
+
134
+ const statement = existsSync('src/pages/accessibility.astro')
135
+ ? readFileSync('src/pages/accessibility.astro', 'utf8')
136
+ : '';
137
+ const reviewed = /const reviewed = '([^']+)'/.exec(statement)?.[1] ?? null;
138
+
139
+ let staleWarning = null;
140
+ if (reviewed) {
141
+ const parsed = new Date(reviewed);
142
+ if (!Number.isNaN(parsed.valueOf())) {
143
+ const days = Math.round((Date.now() - parsed.valueOf()) / 86400000);
144
+ if (days > 0) {
145
+ staleWarning =
146
+ `/accessibility says it was last reviewed ${reviewed} — ${days} day(s) ago.\n` +
147
+ ` This run is newer. Update \`reviewed\` in src/pages/accessibility.astro, or the\n` +
148
+ ` published page is making a testing claim about a site that has moved on.`;
149
+ }
150
+ }
151
+ }
152
+
153
+ /* ── 4. The pack ─────────────────────────────────────────────────────────── */
154
+
155
+ const totalErrors = rows.reduce((n, r) => n + r.errors.length, 0);
156
+ const target = host ?? 'http://localhost:8788 (wrangler dev)';
157
+
158
+ const issueLines = rows
159
+ .filter((r) => r.errors.length)
160
+ .map(
161
+ (r) =>
162
+ `### ${new URL(r.url).pathname}\n\n` +
163
+ r.errors
164
+ .map(
165
+ (e) =>
166
+ `- **${e.code ?? 'unknown'}** — ${e.message}\n` +
167
+ ` - selector: \`${e.selector || '—'}\`\n` +
168
+ ` - runner: ${e.runner ?? '—'}\n` +
169
+ ` - owner: ⚠ · fixed by: ⚠`,
170
+ )
171
+ .join('\n'),
172
+ )
173
+ .join('\n\n');
174
+
175
+ const pack = `# Accessibility evidence — ${today}
176
+
177
+ Target: \`${target}\`
178
+ Standard: **${standard}** · runners: ${runners.join(', ')}
179
+
180
+ | Tool | Version |
181
+ | --- | --- |
182
+ | pa11y-ci | ${version('pa11y-ci')} |
183
+ | pa11y | ${version('pa11y')} |
184
+ | axe-core | ${version('axe-core')} |
185
+
186
+ > **This is the floor, not an audit.** Automated tooling catches roughly a third of
187
+ > issues by count, and about 42% of WCAG success criteria cannot be machine-checked at
188
+ > all. The section below marked *not done by this run* is where the rest lives.
189
+
190
+ ---
191
+
192
+ ## 1. Automated — one URL per template family
193
+
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} |`,
200
+ )
201
+ .join('\n')}
202
+
203
+ **${totalErrors} error(s) across ${rows.length} page(s).**
204
+
205
+ ${issueLines || '_No errors at this standard._'}
206
+
207
+ ## 2. Reflow and resize — WCAG 1.4.10 and 1.4.4
208
+
209
+ \`\`\`
210
+ ${reflowOut.replace(/\[[0-9;]*m/g, '').trim() || 'did not run'}
211
+ \`\`\`
212
+
213
+ ## 3. NOT done by this run — a human has to, and has to date it
214
+
215
+ ⚠ **Fill these in or the pack is incomplete.** Each is a layer no tool covers, from
216
+ \`compliance.md\` §7. One full pass on one page per template family, not every URL.
217
+
218
+ | Layer | How | Done on | By |
219
+ | --- | --- | --- | --- |
220
+ | Keyboard | Tab, Shift-Tab, Enter, Space, Escape — hands off the mouse | ⚠ | ⚠ |
221
+ | Screen reader | VoiceOver + Safari, or NVDA + Firefox | ⚠ | ⚠ |
222
+ | Forms | Submit empty, submit bad, submit with JavaScript off | ⚠ | ⚠ |
223
+ | Zoom | 200% and 400% | ⚠ | ⚠ |
224
+ | Looked at it | On a real phone, at 100% | ⚠ | ⚠ |
225
+
226
+ ## 4. Known gaps
227
+
228
+ ⚠ **List them with an owner against each.** A documented gap is worth more than a clean
229
+ claim — and never write "fully compliant", which is the claim that gets challenged.
230
+
231
+ | Gap | Impact | Owner | Plan |
232
+ | --- | --- | --- | --- |
233
+ | ⚠ | ⚠ | ⚠ | ⚠ |
234
+
235
+ ---
236
+
237
+ _Generated by \`npm run a11y:evidence\`. The published statement is at \`/accessibility\`;
238
+ keep its \`reviewed\` date in step with the newest pack in this directory._
239
+ `;
240
+
241
+ mkdirSync(OUT_DIR, { recursive: true });
242
+ const outPath = `${OUT_DIR}/${today}.md`;
243
+ writeFileSync(outPath, pack);
244
+
245
+ console.log(`\n${GREEN}✓${RESET} ${outPath}`);
246
+ console.log(
247
+ `${DIM} tool + version, date, per-family results, what was tested BY HAND (blank),\n` +
248
+ ` and known gaps with an owner (blank). Fill in §3 and §4, then commit it.${RESET}`,
249
+ );
250
+
251
+ if (staleWarning) console.log(`\n${YELLOW}⚠${RESET} ${staleWarning}`);
252
+
253
+ /*
254
+ * Exit 0 even with errors found. This writes a RECORD; it is not a gate — the
255
+ * gate is `npm run a11y`, which fails the build. A generator that refused to
256
+ * write the evidence whenever the evidence was bad would only ever document
257
+ * sites that were already fine.
258
+ */
@@ -0,0 +1,125 @@
1
+ /**
2
+ * Console errors and failed requests, on the DEPLOYED site.
3
+ *
4
+ * npm run console # every route, localhost:8788
5
+ * npm run console -- https://new.example.com # a deployed host
6
+ * npm run console -- --only=/contact/ # one route, substring match
7
+ *
8
+ * ── WHY THIS EXISTS ────────────────────────────────────────────────────────
9
+ * `build.md` §7 has carried this as a definition-of-done row from the start —
10
+ * *"zero console errors and zero failed requests on the deployed site"* — and
11
+ * the row says why nothing else can do it: **a blocked third-party script or a
12
+ * 404 asset is invisible to a status-code check.** The page returns 200. The
13
+ * HTML is correct. Something inside it failed after the response was complete.
14
+ *
15
+ * `npm run verify` deliberately uses only `fetch`, so it can never see this.
16
+ * That is the gap this closes, and it is why this is a separate script rather
17
+ * than another section there.
18
+ *
19
+ * ── WHAT COUNTS AS A FAILURE ───────────────────────────────────────────────
20
+ * Console *errors* and *failed requests* fail the run. Warnings are printed and
21
+ * do not, because third-party embeds produce warnings constantly and a check
22
+ * that goes red on someone else's deprecation notice gets switched off.
23
+ */
24
+
25
+ import puppeteer from 'puppeteer';
26
+
27
+ import { discoverRoutes } from './lib/routes.mjs';
28
+
29
+ const RESET = '[0m';
30
+ const RED = '[31m';
31
+ const GREEN = '[32m';
32
+ const YELLOW = '[33m';
33
+ const DIM = '[2m';
34
+ const BOLD = '[1m';
35
+
36
+ const args = process.argv.slice(2);
37
+ const only = (args.find((a) => a.startsWith('--only=')) ?? '').replace('--only=', '');
38
+ const host = args.find((a) => !a.startsWith('--'));
39
+ const BASE = (host ?? process.env.PUBLIC_SITE_URL ?? 'http://localhost:8788').replace(/\/$/, '');
40
+
41
+ const { routes: discovered, source } = await discoverRoutes(BASE);
42
+ const ROUTES = discovered
43
+ .map((url) => new URL(url).pathname)
44
+ .filter((r) => !only || only.split(',').some((f) => r.includes(f.trim())));
45
+
46
+ if (!ROUTES.length) {
47
+ console.error(
48
+ `No routes for ${BASE}.\n Build first (\`npm run build:staging\`), or pass a host that serves a sitemap.`,
49
+ );
50
+ process.exit(1);
51
+ }
52
+
53
+ console.log(`${BOLD}── Console and network ${'─'.repeat(36)}${RESET}`);
54
+ console.log(` ${DIM}${ROUTES.length} route(s) from ${source}, against ${BASE}${RESET}\n`);
55
+
56
+ const browser = await puppeteer.launch({ args: ['--no-sandbox'] });
57
+ const findings = [];
58
+ let warnings = 0;
59
+
60
+ try {
61
+ for (const route of ROUTES) {
62
+ const page = await browser.newPage();
63
+ const errors = [];
64
+ const failed = [];
65
+
66
+ page.on('console', (msg) => {
67
+ if (msg.type() === 'error') errors.push(msg.text().slice(0, 200));
68
+ else if (msg.type() === 'warning') warnings++;
69
+ });
70
+ page.on('pageerror', (err) => errors.push(`uncaught: ${String(err).slice(0, 200)}`));
71
+ page.on('requestfailed', (req) => {
72
+ /* An aborted request is usually the navigation itself being replaced, not
73
+ a broken asset. Only report what actually could not be retrieved. */
74
+ const reason = req.failure()?.errorText ?? '';
75
+ if (/ERR_ABORTED/.test(reason)) return;
76
+ failed.push(`${reason} ${req.url()}`);
77
+ });
78
+ page.on('response', (res) => {
79
+ if (res.status() >= 400) failed.push(`${res.status()} ${res.url()}`);
80
+ });
81
+
82
+ try {
83
+ await page.goto(BASE + route, { waitUntil: 'networkidle0', timeout: 45000 });
84
+ } catch (e) {
85
+ errors.push(`navigation: ${e.name}`);
86
+ }
87
+
88
+ if (errors.length || failed.length) {
89
+ findings.push({ route, errors, failed });
90
+ console.log(` ${RED}✗${RESET} ${route}`);
91
+ for (const e of errors) console.log(` ${DIM}console ${e}${RESET}`);
92
+ for (const f of failed) {
93
+ /* Browsers request /favicon.ico on their own when no icon is declared.
94
+ No markup causes it, so say what it actually means rather than
95
+ leaving someone hunting for the reference. */
96
+ const hint = /\/favicon\.ico$/.test(f)
97
+ ? ' ← the browser asks for this by itself; declare `icons` in src/data/site.ts'
98
+ : '';
99
+ console.log(` ${DIM}request ${f}${hint}${RESET}`);
100
+ }
101
+ } else {
102
+ console.log(` ${GREEN}✓${RESET} ${route}`);
103
+ }
104
+
105
+ await page.close();
106
+ }
107
+ } finally {
108
+ await browser.close();
109
+ }
110
+
111
+ if (warnings) {
112
+ console.log(`\n ${YELLOW}!${RESET} ${warnings} console warning(s) — printed, not failed.`);
113
+ console.log(
114
+ ` ${DIM}Third-party embeds warn constantly. A check that goes red on someone else's\n` +
115
+ ` deprecation notice is a check that gets switched off.${RESET}`,
116
+ );
117
+ }
118
+
119
+ console.log(
120
+ findings.length
121
+ ? `\n${RED}✗ ${findings.length} route(s) with errors or failed requests${RESET}\n`
122
+ : `\n${GREEN}✓ no console errors and no failed requests across ${ROUTES.length} route(s)${RESET}\n`,
123
+ );
124
+
125
+ process.exit(findings.length ? 1 : 0);
@@ -0,0 +1,99 @@
1
+ /**
2
+ * Refuse to ship a build whose environment does not match the domains it will
3
+ * be served on.
4
+ *
5
+ * node scripts/check-env.mjs
6
+ *
7
+ * Runs at the end of `build:staging` and `build:production`, so it cannot be
8
+ * forgotten — including on Cloudflare Workers Builds, where the build command
9
+ * is typed once into a settings dialog and then never looked at again.
10
+ *
11
+ * ── THE FAILURE THIS EXISTS FOR ────────────────────────────────────────────
12
+ * `PUBLIC_SITE_ENV` decides indexability, canonical host, which KV namespace
13
+ * leads land in, and whether analytics is emitted at all. `wrangler.jsonc`
14
+ * decides which domains answer. Nothing connects the two.
15
+ *
16
+ * At go-live, two edits have to happen together: the routes gain
17
+ * example.com, and the build command becomes `build:production`. Do
18
+ * the first and forget the second and the live site ships with `noindex` on
19
+ * every page, canonicals pointing at the staging host, leads written to
20
+ * LEADS_STAGING, and no analytics.
21
+ *
22
+ * It would look perfect. It would be invisible to Google, and every enquiry
23
+ * would go to the wrong place. Nothing in a build log or a page would say so.
24
+ *
25
+ * The reverse — a production build served only on the staging host — is the
26
+ * other half: an indexable duplicate of the real site.
27
+ */
28
+
29
+ import { readFileSync } from 'node:fs';
30
+
31
+ const RESET = '';
32
+ const RED = '';
33
+ const GREEN = '';
34
+
35
+ /** wrangler.jsonc is JSON with comments; strip them rather than add a parser. */
36
+ function readWrangler() {
37
+ const raw = readFileSync('wrangler.jsonc', 'utf8')
38
+ .replace(/\/\*[\s\S]*?\*\//g, '')
39
+ .replace(/(^|[^:"'])\/\/.*$/gm, '$1')
40
+ .replace(/,(\s*[}\]])/g, '$1');
41
+ return JSON.parse(raw);
42
+ }
43
+
44
+ const env = process.env.PUBLIC_SITE_ENV ?? 'development';
45
+ const wrangler = readWrangler();
46
+ const patterns = (wrangler.routes ?? []).map((r) => (typeof r === 'string' ? r : r.pattern));
47
+
48
+ /* The production host is the apex, or www — NOT the staging subdomain, which
49
+ also ends in the same string. Matching on `.includes()` alone would treat
50
+ new.example.com as production.
51
+
52
+ A custom domain is a bare hostname ("new.example.com"); a ROUTE
53
+ carries a path ("example.com/*"). Go-live uses routes, so the path
54
+ is stripped before matching — without this the guard would look at
55
+ "example.com/*", fail to match, and conclude a production build
56
+ was pointed at staging-only routes. It would have blocked the cutover. */
57
+ const hostOf = (p) => String(p).split('/')[0];
58
+ /*
59
+ * ⚠ SET THIS PER PROJECT. It must match the production apex, with or without
60
+ * `www.` — and must NOT match the staging subdomain, which ends in the same
61
+ * string. Keep it in step with `PRODUCTION_HOSTS` in src/data/site.ts; those
62
+ * two disagreeing is the failure this whole script exists to catch.
63
+ */
64
+ const PRODUCTION_HOST = /^(www\.)?example\.com$/;
65
+
66
+ const isProdHost = (p) => PRODUCTION_HOST.test(hostOf(p));
67
+ const routesProduction = patterns.some(isProdHost);
68
+ const routesStagingOnly = patterns.length > 0 && !routesProduction;
69
+
70
+ const problems = [];
71
+
72
+ if (routesProduction && env !== 'production') {
73
+ problems.push(
74
+ `wrangler.jsonc routes the PRODUCTION domain (${patterns.filter(isProdHost).join(', ')}) ` +
75
+ `but this build is PUBLIC_SITE_ENV=${env}.\n` +
76
+ ` That ships noindex on every page, canonicals pointing at the staging host,\n` +
77
+ ` leads written to LEADS_STAGING, and no analytics — on the live site.\n` +
78
+ ` Fix: build with \`npm run build:production\`.`,
79
+ );
80
+ }
81
+
82
+ if (routesStagingOnly && env === 'production') {
83
+ problems.push(
84
+ `This is a PRODUCTION build, but wrangler.jsonc only routes ${patterns.join(', ')}.\n` +
85
+ ` An indexable production build answering on the staging host is a duplicate\n` +
86
+ ` of the real site. Fix: add the production routes, or build with\n` +
87
+ ` \`npm run build:staging\`.`,
88
+ );
89
+ }
90
+
91
+ if (problems.length) {
92
+ console.error(`\n${RED}✗ environment does not match the deploy target${RESET}\n`);
93
+ for (const p of problems) console.error(` ${p}\n`);
94
+ process.exit(1);
95
+ }
96
+
97
+ console.log(
98
+ `${GREEN}✓${RESET} PUBLIC_SITE_ENV=${env} matches routes: ${patterns.join(', ') || '(none)'}`,
99
+ );