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,661 @@
1
+ # Runbook
2
+
3
+ Setup, verification, go-live and what to watch afterwards. Everything here runs against a
4
+ **deployed** site — a green build proves the bundler ran, nothing more.
5
+
6
+ Replace `example.com` / `new.example.com` throughout, or export them once:
7
+
8
+ ```bash
9
+ export PROD=example.com
10
+ export STAGING=new.example.com
11
+ ```
12
+
13
+ ---
14
+
15
+ ## 1. First-time setup
16
+
17
+ Node **22.12+** is required (`.node-version` pins the version this was built against).
18
+
19
+ ```bash
20
+ npm install
21
+ ```
22
+
23
+ **On a migration, capture the old site before you change anything.** All three write to
24
+ `recon/` and all three are worth committing — together they are what you diff against after
25
+ cutover, and what you restore from if it goes wrong:
26
+
27
+ ```bash
28
+ npm run recon -- https://old-site.com # URLs, preserved paths, integrations
29
+ npm run dns -- old-site.com # MX, SPF/DKIM/DMARC, CAA, verification TXT
30
+ npm run seo -- https://old-site.com --json recon/seo-before.json --fail-on never
31
+ ```
32
+
33
+ The DNS one matters most and is the least obvious: moving the apex without carrying MX kills
34
+ the client's email, silently. See `docs/traps.md`.
35
+
36
+ **Create the KV namespaces and paste the real ids into `wrangler.jsonc`.** An undeclared
37
+ binding gets auto-created on deploy, which works exactly once — recreating the worker then
38
+ fails on a name the previous incarnation left behind.
39
+
40
+ ```bash
41
+ npx wrangler kv namespace create "<site>-leads"
42
+ npx wrangler kv namespace create "<site>-leads-staging"
43
+ npx wrangler kv namespace list # confirm both exist with ids
44
+ ```
45
+
46
+ **Secrets.** Never in the repo, never in chat.
47
+
48
+ ```bash
49
+ npx wrangler secret put BREVO_API_KEY
50
+ npx wrangler secret put LEADS_EXPORT_TOKEN # openssl rand -base64 32
51
+ cp .dev.vars.example .dev.vars # local only, gitignored
52
+ ```
53
+
54
+ **Fill in, in this order** — each one is read by everything after it:
55
+
56
+ 1. `src/data/business.ts` — every fact the site states about the business
57
+ 2. `src/data/nav.ts` — the real routes, and the one action that counts as a win
58
+ 3. `src/data/services.ts`, `areas.ts`, `categories.ts`
59
+ 4. `src/data/site.ts` — `PRODUCTION_HOSTS`, and the client's **own** analytics IDs
60
+ 5. `package.json` — staging and production URLs in the build scripts
61
+ 6. `wrangler.jsonc` — worker name, routes, KV ids
62
+ 7. Brand assets into `media/source/brand/`, then `npm run media`
63
+
64
+ **Then the design, which is a separate job and a larger one.** The template ships undecided:
65
+ a grey placeholder ramp, the system font stack for both faces, no typefaces in `public/fonts/`
66
+ and a scaffold home page. Nothing here is a default you can leave — a starter that arrived
67
+ with a palette would give every site built from it the same one.
68
+
69
+ ```bash
70
+ npm run tells # what is still undecided, and the design.md §3 tells
71
+ ```
72
+
73
+ 1. `src/styles/tokens.css` — the real brand ramp and accent, then delete the `--unset` line
74
+ 2. `public/fonts/` + the `@font-face` block in `src/styles/global.css` + `src/data/fonts.ts` —
75
+ two faces, display and body, self-hosted and subset
76
+ 3. `src/pages/index.astro` — replace it. Section order comes from the archetype
77
+ 4. `<meta name="theme-color">` in `src/layouts/Base.astro` and `public/site.webmanifest` —
78
+ keep the two in step, or an installed icon's splash screen is the wrong colour
79
+
80
+ `npm run build:production` refuses while any of that is half-done: a project with a brand
81
+ colour and no typeface is a project that stopped in the middle.
82
+
83
+ ```bash
84
+ npm run dev # layout work only — no bindings
85
+ npm run build:staging && npx wrangler dev # real KV, real secrets, real redirects
86
+ ```
87
+
88
+ ### Keeping staging out of the index
89
+
90
+ The template ships three things, and **only the first is protection**. The others reduce the
91
+ damage when the first is missing.
92
+
93
+ | | What it does | What it does not do |
94
+ | --- | --- | --- |
95
+ | **Cloudflare Access** ✅ | Nothing reaches staging without logging in | — |
96
+ | `noindex` + `X-Robots-Tag` | Asks a crawler not to index what it fetched | Nothing, if the crawler never fetched |
97
+ | `Disallow: /` | Stops the fetch — **and therefore stops the two above being read** | Does not prevent indexing |
98
+ | The badge | Tells *you* | Nothing to a crawler |
99
+
100
+ **Set up Access. It is five minutes and it ends the question.**
101
+
102
+ Cloudflare dashboard → Zero Trust → Access → Applications → Add → Self-hosted. Application
103
+ domain is the staging hostname. Policy: Allow, with `Emails ending in @yourdomain` plus the
104
+ client's addresses. One-time PIN needs no account on their side.
105
+
106
+ Everything else on this page becomes belt and braces the moment that exists.
107
+
108
+ #### How staging gets found without anyone linking it
109
+
110
+ This is the part people miss, and it is why "nobody knows the URL" is not a plan.
111
+
112
+ - **Certificate Transparency logs.** Every TLS certificate issued for `new.example.com` is
113
+ published to public, searchable logs within minutes. Bots monitor them specifically to find
114
+ new staging hosts. You cannot opt out of CT — it is how the web verifies certificates.
115
+ - **Chat and email unfurls.** Slack, WhatsApp, Teams and Gmail fetch a URL to build the
116
+ preview card. That fetch is a real request from an infrastructure you do not control.
117
+ - **Guessable hostnames.** `staging.`, `dev.`, `test.` and `new.` are the first four things any
118
+ subdomain scanner tries.
119
+ - **Referrer leakage.** Someone clicks an outbound link from staging and the destination's logs
120
+ now contain the staging URL.
121
+
122
+ A wildcard certificate on the zone hides the subdomain from CT, and an unguessable hostname
123
+ raises the bar — but neither is protection, and Access is. **Assume the URL is public the
124
+ moment the certificate is issued, and put a login in front of it.**
125
+
126
+ #### After Access is on, `npm run verify` needs a way in
127
+
128
+ `verify` makes plain unauthenticated requests, so it will get the Access login page for every
129
+ route and report the site as broken. Either run it before enabling Access, or add a **service
130
+ token** (Zero Trust → Access → Service Auth) and an Access policy accepting it, then pass the
131
+ headers. It is the same problem the client's own preview link has, and worth solving once.
132
+
133
+ Every non-production build also carries a **standing badge** in the corner — see below.
134
+
135
+ **Use `wrangler dev` for anything touching `/api/*`, redirects or status codes.** The Astro
136
+ dev server models none of them, and they are exactly where things break.
137
+
138
+ ### `npm install` reports high-severity vulnerabilities. Read this before acting.
139
+
140
+ They are **dev tooling, and none of it ships**. Every one traces to `extract-zip`, which
141
+ puppeteer uses to unpack Chrome for the accessibility suite:
142
+
143
+ ```
144
+ extract-zip → @puppeteer/browsers → puppeteer → pa11y / pa11y-ci
145
+ ```
146
+
147
+ The deployed Worker contains none of it. Confirm that yourself rather than taking it on trust:
148
+
149
+ ```bash
150
+ npm audit --omit=dev # what could actually ship. Should be: found 0 vulnerabilities
151
+ ```
152
+
153
+ **Do not run `npm audit fix --force` here.** Its "fix" is downgrading `pa11y-ci` to v3 — a major
154
+ version back, which takes axe-core with it. Trading current accessibility tooling for a symlink
155
+ issue in a zip extractor that only ever unpacks Chrome from Google is the wrong way round.
156
+
157
+ `npm audit fix` without `--force` is safe and worth running: it moves patch versions inside the
158
+ existing ranges and touches nothing in `package.json`. That is what cleared the `undici`
159
+ advisories that came in through wrangler.
160
+
161
+ The kit's CI gates on `npm audit --omit=dev --audit-level=high`, not the full tree — a check
162
+ nobody can make green is a check everyone learns to ignore.
163
+
164
+ ### Optional tooling — not npm packages
165
+
166
+ Nothing below is needed to build, deploy or run the site. Each is required by one script,
167
+ which checks for it and names it rather than failing with a stack trace.
168
+
169
+ | Needed by | Requires | Install |
170
+ | --- | --- | --- |
171
+ | `npm run cards` | ImageMagick, librsvg, python3 + fontTools + brotli | `brew install imagemagick librsvg` then `python3 -m pip install fonttools brotli` |
172
+
173
+ `npm run a11y`, `npm run reflow` and `scripts/md-to-pdf.mjs` need Chrome, which arrives with
174
+ `pa11y-ci` as a devDependency — `npm install` is the whole setup. The binary itself goes to a
175
+ shared `~/.cache/puppeteer`, not into `node_modules`.
176
+
177
+ `npm run cards` additionally refuses until `scripts/og-cards.config.mjs` is filled in, which
178
+ cannot happen before the design exists: it needs the real ramp, the two faces in
179
+ `public/fonts/` and a wordmark. That is deliberate — the kit ships the card *machinery* and
180
+ none of the look, so two sites built from it cannot unfurl the same card.
181
+
182
+ ---
183
+
184
+ ### The staging badge
185
+
186
+ Every non-production build shows a fixed badge. It is driven by `site.indexable`, so it cannot
187
+ be left on in production and cannot be turned on by hand.
188
+
189
+ **It reads the live DOM rather than printing the build variable.** A badge saying "STAGING ·
190
+ NOINDEX" from a constant only repeats what you already know; this one checks the actual
191
+ `<meta name="robots">` and the actual analytics tags, and goes to a pulsing alarm reading
192
+ **`NOT NOINDEX`** or **`ANALYTICS LIVE`** when the page disagrees with the environment. That is
193
+ the failure worth catching: a staging site quietly indexable, competing with production, with
194
+ nothing on the page looking wrong.
195
+
196
+ - It can never intercept a click — `pointer-events: none`
197
+ - Hidden from print, so a PDF of a staging page does not carry it
198
+ - `?nobadge=1` hides it for the session, for clean screenshots — `npm run shots -- --after`
199
+ appends it for you. Deliberately **not** a persistent dismissal: a badge you dismissed on
200
+ Tuesday is not there to warn you on Friday
201
+
202
+ ---
203
+
204
+ ## 1b. What actually changes between staging and production
205
+
206
+ **One switch decides all of it.** `PUBLIC_SITE_ENV` is set by the build script, `src/data/site.ts`
207
+ derives everything from it, and nothing below is toggled by hand. There is no second flag, and
208
+ adding one is the mistake this design exists to prevent.
209
+
210
+ Every row was measured from a real build of each environment, not recalled:
211
+
212
+ | | Staging | Production |
213
+ | --- | --- | --- |
214
+ | `<meta name="robots">` | `noindex, nofollow` | `index, follow, max-snippet:-1, …` |
215
+ | `X-Robots-Tag` header | `noindex, nofollow, noarchive` on **every** response | absent |
216
+ | `robots.txt` | `Disallow: /` | `Allow: /`, `Disallow: /api/`, plus the `Sitemap:` line |
217
+ | Sitemap files | none emitted | `sitemap-index.xml` + `sitemap-0.xml` |
218
+ | `lastmod` in the sitemap | — | from `src/data/lastmod.json` |
219
+ | Canonical host | `https://new.example.com` | `https://example.com` |
220
+ | Analytics (GA4/GTM) | **zero references in the HTML** | emitted only if both IDs are set |
221
+ | Cloudflare beacon | none | emitted only if the token is set |
222
+ | Environment badge | shown, fixed, bottom-left | **not in the markup at all** |
223
+ | Lead storage | `LEADS_STAGING` binding | `LEADS` binding |
224
+ | Lead tag on each record | `test` | `live` |
225
+ | Lead retention | 30 days | 180 days |
226
+ | Enquiry notification goes to | `email.notifyTest` — the developer | `email.notify` — the client |
227
+ | Build gates that run | `check-env`, `staging-headers` | `tells`, `astro check`, `check-env`, `check-sitemap` |
228
+
229
+ Two consequences worth stating plainly, because they surprise people:
230
+
231
+ - **Staging analytics is not "disabled", it is absent.** There is no snippet with a flag turned
232
+ off — the HTML contains no tag at all. A staging visit cannot pollute the client's data even
233
+ if someone pastes a container ID in by mistake, because the block that would render it never
234
+ runs.
235
+ - **Staging never emails the client.** Submissions store and notify, but to the developer
236
+ address. That is what makes it safe to test the form end to end without warning anyone.
237
+
238
+ ### Going live: what you change, and what changes itself
239
+
240
+ You change **two things**. Everything in the table above follows.
241
+
242
+ 1. `wrangler.jsonc` — add the production routes
243
+ 2. The Cloudflare Workers Builds command — `npm run build:staging` → `npm run build:production`
244
+
245
+ `check-env` compares those two and **fails the build if they disagree**, which is the guard
246
+ against the one-sided change: routes moved to production while the build command still says
247
+ staging ships a live site that is `noindex`, canonicalised to the staging host, writing leads
248
+ to the wrong namespace and emitting no analytics. It looks perfect and is invisible to Google.
249
+
250
+ Then confirm the switch actually happened, against the deployed site:
251
+
252
+ ```bash
253
+ curl -sI "https://$PROD/" | grep -i x-robots-tag # must return NOTHING
254
+ curl -s "https://$PROD/" | grep -c env-badge # must be 0
255
+ curl -s "https://$PROD/robots.txt" | grep -i sitemap
256
+ npm run verify -- "https://$PROD"
257
+ ```
258
+
259
+ **The badge is the fastest of these.** If you can see it on the live domain, the production
260
+ build never shipped — and that is a thirty-second check anyone on the team can do from a phone.
261
+
262
+ ## 2. Verification matrix
263
+
264
+ ```bash
265
+ npm run verify -- https://new.example.com # after every staging deploy
266
+ npm run verify -- https://example.com # again after cutover
267
+ npm run console -- https://new.example.com # console errors + failed requests
268
+ ```
269
+
270
+ **Most of this section is now that script**, and it exits non-zero. Routes, the 404, every
271
+ literal rule in `_redirects` plus whether its target actually resolves, **every URL the old site
272
+ served**, the security headers,
273
+ the staging noindex/analytics split, sitemap `lastmod` variance, every internal link and
274
+ `og:image`, the title/description/canonical sweep, page weight and render-blocking counts, and
275
+ the three form submissions the API is supposed to refuse — including that caught spam does not
276
+ land on the conversion URL.
277
+
278
+ It reads `public/_redirects` and the deployed sitemap rather than a list inside the script, so
279
+ it cannot drift from the site the way this document did: **the honeypot row below tested
280
+ `website=filled` while the code checked `company`, so for a long time it posted a complete
281
+ valid lead, stored it, emailed it, and reported success.** That is what a hand-run checklist
282
+ degrades into. Run the script; keep the commands below for when you need to see one by hand.
283
+
284
+ **What the script cannot see**, and you must still do:
285
+
286
+ | Check | Why it stays manual |
287
+ | --- | --- |
288
+ | One pageview per visit | Only visible in analytics Realtime |
289
+ | A valid submission stores **and** emails | Sending one creates a real lead and a real notification |
290
+ | The analytics container is the client's own | Fetch `gtm.js` and read it — see `analytics.md` |
291
+ | A redirect lands on the **right** page | The script proves it resolves, not that it is correct |
292
+ | It looks right on a phone | — |
293
+ | Console errors and failed requests | `npm run console` — a real browser, because a blocked script or a 404 asset is invisible to a status check |
294
+ | How fast it **feels** | `verify` reports weight and blocking counts, which are the inputs. Lighthouse on the deployed URL — mobile, simulated throttling, two samples per variant — is the number |
295
+ | Whether a page came out *worse* | `npm run shots` puts the pairs side by side; only a person can say which is better |
296
+
297
+ Run against staging first, then again against production after cutover. Every row, every time
298
+ — the rows people skip are the rows that fail.
299
+
300
+ ### Routes and redirects
301
+
302
+ `npm run redirects` proposes a map from `recon/urls.txt` against the new routes and writes
303
+ `recon/redirects.proposed`. It **never** touches `public/_redirects` — slug similarity is a
304
+ guess, and a wrong 301 is worse than a 404: the 404 shows up in the log and gets fixed, the
305
+ wrong redirect looks like it works. Paste the lines you agree with, then let `npm run verify`
306
+ confirm each one returns its declared status *and* that its target resolves.
307
+
308
+ ```bash
309
+ # every URL from the inventory returns 200. `npm run recon -- https://old-site.com`
310
+ # writes recon/urls.txt; `npm run verify` checks the live routes automatically.
311
+ while read -r u; do
312
+ case "$u" in \#*|'') continue;; esac
313
+ printf '%-60s %s\n' "$u" "$(curl -s -o /dev/null -w '%{http_code}' "https://$STAGING$u")"
314
+ done < recon/urls.txt
315
+
316
+ # unknown paths must return a real 404, not a 200 with a pretty page
317
+ curl -s -o /dev/null -w '%{http_code}\n' "https://$STAGING/definitely-not-a-page"
318
+
319
+ # each legacy URL 301s to its SPECIFIC equivalent and that lands 200
320
+ curl -sIL "https://$STAGING/old-path/" | grep -E '^(HTTP|location)'
321
+ ```
322
+
323
+ | Check | Passing |
324
+ | --- | --- |
325
+ | Routes | Every inventoried URL returns 200 |
326
+ | Redirects | 301 to the specific equivalent, which itself returns 200. Never to the homepage |
327
+ | 404 | A real 404 status, not 200 |
328
+ | Canonicals | Point at the host actually being served |
329
+ | Trailing slashes | Consistent, and form actions include the slash — a 308 drops the POST body |
330
+
331
+ ### Preserved paths
332
+
333
+ Other systems point at these. Changing them breaks the link silently.
334
+
335
+ ```bash
336
+ for p in robots.txt sitemap-index.xml sitemap_index.xml ads.txt BingSiteAuth.xml feed/ rss.xml; do
337
+ printf '%-22s %s\n' "$p" "$(curl -s -o /dev/null -w '%{http_code}' "https://$PROD/$p")"
338
+ done
339
+ curl -s "https://$PROD/robots.txt" | grep -i sitemap
340
+ ```
341
+
342
+ The old sitemap filename must resolve 200 or 301-to-200 — Search Console stores the URL that
343
+ was submitted, and reports a broken one days later in an email nobody opens.
344
+
345
+ ### Staging guards
346
+
347
+ ```bash
348
+ curl -s "https://$STAGING/" | grep -ciE 'googletagmanager|gtag|G-[A-Z0-9]{9}' # must be 0
349
+ curl -s "https://$STAGING/" | grep -i 'noindex' # must match
350
+ curl -s "https://$STAGING/robots.txt" # disallow all
351
+ curl -s "https://$STAGING/" | grep -c "$PROD" # must be 0
352
+ ```
353
+
354
+ Zero analytics references, `noindex`, disallow-all robots, no production URLs, and
355
+ notifications routed to the developer rather than the client.
356
+
357
+ ### Forms
358
+
359
+ ```bash
360
+ H="-H Origin:\ https://$STAGING"
361
+
362
+ # valid → 200, a stored record AND a delivered email
363
+ curl -s -X POST "https://$STAGING/api/contact/" -H "Origin: https://$STAGING" \
364
+ -d 'name=Test&phone=5550000000&email=you@example.com&service=Something else&message=runbook test'
365
+
366
+ # empty → 422 with field errors
367
+ curl -s -X POST "https://$STAGING/api/contact/" -H "Origin: https://$STAGING" -d ''
368
+
369
+ # honeypot → silent accept, no lead stored, and NOT the conversion URL
370
+ #
371
+ # ⚠ THE FIELD NAME MUST MATCH src/pages/api/contact.ts. This row read
372
+ # `website=filled` while the code checked `company` — so it posted a complete,
373
+ # valid lead with an unrecognised extra field, stored it, sent the notification,
374
+ # and reported success. A verification row that quietly does the opposite of
375
+ # what it claims is worse than an unchecked box. Grep the code, do not trust
376
+ # this line.
377
+ curl -s -o /dev/null -D- -X POST "https://$STAGING/api/contact/" -H "Origin: https://$STAGING" \
378
+ -H "Accept: text/html" \
379
+ -d 'name=Bot&phone=5550000000&email=b@example.com&message=x&company=filled' | grep -i '^location'
380
+ # Location must NOT contain sent= — that URL is the conversion
381
+
382
+ # cross-origin → 403
383
+ curl -s -o /dev/null -w '%{http_code}\n' -X POST "https://$STAGING/api/contact/" \
384
+ -H "Origin: https://evil.example" -d 'name=x'
385
+ ```
386
+
387
+ Then **confirm both ends**, not the 200:
388
+
389
+ ```bash
390
+ npx wrangler kv key list --binding LEADS_STAGING | tail -5
391
+ curl -s -H "Authorization: Bearer $LEADS_EXPORT_TOKEN" "https://$STAGING/api/leads.csv" | tail -3
392
+ ```
393
+
394
+ …and that the email actually arrived, in the provider's dashboard. A 200 from a transactional
395
+ API means accepted for delivery, not delivered.
396
+
397
+ **Then with JavaScript off** — the native POST must still store, still notify, and land on a
398
+ human-readable page.
399
+
400
+ ### Accessibility
401
+
402
+ ```bash
403
+ npm run a11y # one URL per template family, listed in .pa11yci.json
404
+
405
+ # against production, where a sitemap exists (staging emits none, deliberately)
406
+ npx pa11y-ci --sitemap "https://$PROD/sitemap-index.xml" --standard WCAG2AA
407
+ ```
408
+
409
+ **Add a URL to `.pa11yci.json` for every template family you create.** A homepage-only pass
410
+ misses everything the blog does differently.
411
+
412
+ Plus, by hand, on **one page per template family** — not every URL, and never only the
413
+ homepage:
414
+
415
+ - **Keyboard**: Tab through everything. Focus visible, order matches the visual order, menus
416
+ escapable, and the skip link *moves focus* (Tab after activating it — if you land back in
417
+ the nav, the target is missing `tabindex="-1"`)
418
+ - **Zoom**: 200% and 400%, plus a 320px viewport. No horizontal scroll, nothing clipped
419
+ - **Screen reader**: VoiceOver/Safari or NVDA/Firefox. Does the page make sense, not merely
420
+ does it speak
421
+ - **Forms**: submit empty and check the error is announced and focus moves to the first bad field
422
+
423
+ ### Visual, console and network
424
+
425
+ Automated checks confirm a page *responds*. They say nothing about whether it *renders*. Open
426
+ one page per template family in a browser — Chrome with the Claude extension does this and can
427
+ capture the screenshots for the handover.
428
+
429
+ - **Look at it** at a mobile and a desktop width. A broken layout returns 200 like any other
430
+ - **Console: zero errors.** A blocked third-party script or a CSP violation shows up nowhere else
431
+ - **Network: zero failed requests.** A 404 asset is invisible to a status-code sweep
432
+ - **Before/after screenshots** on a migration — `npm run shots`, below. Hard to defend a
433
+ rebuild when someone misremembers the old site
434
+
435
+ Still not a substitute for a real device — emulated widths do not model browser chrome or touch
436
+ latency.
437
+
438
+ ### The visual record
439
+
440
+ ```bash
441
+ npm run extract # captured HTML → markdown, once
442
+ npm run shots -- --before https://old-site.com # BEFORE you switch DNS
443
+ npm run shots -- --after https://new.example.com # staging, then again after cutover
444
+ ```
445
+
446
+ Full-page captures at 390px and 1440px, then `shots/index.html` puts the pairs side by side.
447
+ **Take the before pass while the old site is still up** — once DNS moves, it is gone, and the
448
+ Wayback Machine will not have every page.
449
+
450
+ Both sides read `recon/urls.txt`, so the sets cannot drift: a page that existed and no longer
451
+ does appears in the sheet as a 404 next to its old screenshot, which is the row worth looking
452
+ at. A path that 301s is followed and still filed under the old path, so the pair lines up.
453
+
454
+ The sheet and its PNGs go **with the handover**, not into the repo — `shots/` is gitignored,
455
+ and it is regenerable right up until the old site goes away.
456
+
457
+ ### Media and SEO
458
+
459
+ | Check | Passing |
460
+ | --- | --- |
461
+ | Images | Every one resolves and carries `width`/`height` |
462
+ | `og:image` | A format scrapers render — JPEG, not WebP |
463
+ | Duplicate covers | No two posts sharing a photo |
464
+ | Structured data | Validates, and states the same facts as the visible page |
465
+ | Titles/descriptions | Diffed against the old site, every difference deliberate |
466
+
467
+ ### Performance
468
+
469
+ Measured on the deployed site, mobile profile, **at least two runs per variant** — single runs
470
+ swing by a second on Speed Index.
471
+
472
+ ```bash
473
+ npx lighthouse "https://$PROD/" --preset=perf --form-factor=mobile --output=json --quiet \
474
+ --output-path=./lh-1.json
475
+ ```
476
+
477
+ Any deliberate delay — splash, overlay, gate — costed in LCP and reported, including when the
478
+ number undercuts the argument for it.
479
+
480
+ ---
481
+
482
+ ## 3. Go-live
483
+
484
+ In this order. Steps 1–3 happen days ahead, not on launch day.
485
+
486
+ 1. **Lower the DNS TTL to 300s** at least 24h before cutover, so a mistake is 5 minutes rather
487
+ than a day.
488
+ 2. **Confirm domain and DNS access works** — actually log in. This blocks go-live more often
489
+ than anything technical.
490
+ 3. **Move Search Console verification to DNS TXT** if it currently relies on an HTML file, and
491
+ confirm it still shows verified. File-based verification breaks the moment the file stops
492
+ resolving, and losing verification loses the property's history.
493
+ 4. **Full verification matrix against staging.** Every row.
494
+ 5. **Deploy production** — `npm run deploy:production`. Read the bindings table in the output;
495
+ it is the only visible signal that the right environment was built.
496
+ 6. **Cut DNS over.** Watch, do not assume:
497
+ ```bash
498
+ dig +short $PROD; dig +short www.$PROD
499
+ curl -sI "https://$PROD/" | head -3
500
+ ```
501
+ 7. **Remove the staging route** from the worker, or staging becomes an indexable duplicate.
502
+ 8. **Re-run the matrix against production**, including `robots.txt` (must now allow) and
503
+ `noindex` (must now be absent).
504
+
505
+ ```bash
506
+ npm run verify -- "https://$PROD"
507
+ npm run check:sitemap # after a production build: nothing listed AND noindex
508
+ ```
509
+ 9. **Submit the sitemap** in [Search Console](https://search.google.com/search-console) and
510
+ [Bing Webmaster Tools](https://www.bing.com/webmasters). If you kept the old filename, the
511
+ existing entry keeps working and there is nothing to resubmit.
512
+
513
+ Optionally nudge the rest:
514
+
515
+ ```bash
516
+ export INDEXNOW_KEY=… # 8–128 hex, saved as public/<key>.txt and DEPLOYED
517
+ PUBLIC_SITE_URL="https://$PROD" npm run indexnow
518
+ ```
519
+
520
+ **Run it after the deploy, never before** — IndexNow fetches the key file at the moment of
521
+ submission and rejects the whole batch with a 403 if it is not live yet. And it is Bing,
522
+ Yandex, Seznam and Naver: **Google does not participate**, so this is never the reason a
523
+ page is or is not in Google. The script prints both of those on every run.
524
+ 10. **Diff the zone against the capture.** The one step that catches a launch taking the
525
+ client's email with it:
526
+
527
+ ```bash
528
+ npm run dns -- "$PROD" --compare # anything LOST since recon/dns.json
529
+ ```
530
+
531
+ MX, SPF, DMARC, verification TXT and nameservers, compared against what was published
532
+ before you touched anything. A dead site gets a phone call; dead email is silent.
533
+
534
+ 11. **Send one real enquiry through the live form** and confirm the client received it in the
535
+ inbox they actually read.
536
+ 12. **Restore the DNS TTL** to something sane (3600s).
537
+ 13. **Point the uptime monitor at a real page and the form endpoint** — not just the homepage.
538
+ The endpoint is what breaks.
539
+
540
+ ---
541
+
542
+ ## 3a. www → apex redirect
543
+
544
+ **Symptom:** `curl -sI https://www.example.com/some-page/` returns **200** instead
545
+ of a 301. Every page exists on two hostnames.
546
+
547
+ It is not an emergency — each www page emits a canonical pointing at the apex, so Google
548
+ consolidates. But a canonical is a *hint* and a 301 is an *instruction*: until this exists,
549
+ every crawler counts the site twice, and any backlink built to the www form consolidates
550
+ weakly.
551
+
552
+ ### Why it cannot be fixed in this repo
553
+
554
+ Both hostnames are routed to the same worker in `wrangler.jsonc`, so a request to www is
555
+ byte-identical to one to the apex by the time our code sees it.
556
+
557
+ - **`public/_redirects` cannot do it.** Matching there is path-only. Pages supports absolute-URL
558
+ sources (`https://www.example.com/* → https://example.com/:splat`); **Workers Static Assets
559
+ does not.** Tested on 2026-08-15 against `wrangler dev` with a spoofed `Host` header: the
560
+ absolute rule returned 200 while a path rule on the same file returned its 301 correctly. Do
561
+ not re-try this — it looks like it should work and silently does nothing.
562
+ - **Middleware cannot do it either**, unless you turn on `run_worker_first`. Static assets are
563
+ served without invoking the worker, which is exactly why www serves a perfect copy. Turning
564
+ that on to catch a redirect routes every request through a worker to fix a handful.
565
+ - **Do NOT delete the www route from `wrangler.jsonc`.** That leaves www resolving in DNS with
566
+ nothing behind it — a hard failure for anyone who typed it, which is worse than a duplicate.
567
+
568
+ It has to be a zone-level rule, which runs in the dynamic-redirect phase *before* Worker routes.
569
+
570
+ ### Doing it in the dashboard
571
+
572
+ Cloudflare → the `example.com` zone → **Rules** → **Redirect Rules** → **Create rule**
573
+ → *Single Redirect*.
574
+
575
+ | Field | Value |
576
+ | --- | --- |
577
+ | Rule name | `www to apex` |
578
+ | If — custom filter expression | `(http.host eq "www.example.com")` |
579
+ | Then — Type | **Dynamic** (not Static) |
580
+ | Expression | `concat("https://example.com", http.request.uri.path)` |
581
+ | Status code | `301` |
582
+ | Preserve query string | **on** |
583
+
584
+ **Use Dynamic, not Static.** A static target sends every www URL to the homepage. Google reads
585
+ that as a soft 404 across every page at once — measurably worse than the duplicate being fixed.
586
+
587
+ ### Doing it over the API
588
+
589
+ The wrangler OAuth token **cannot** do this: it carries `zone (read)` and rulesets need
590
+ `Zone → Zone WAF/Rulesets → Edit`. Mint a scoped token for that one zone, use it, delete it.
591
+
592
+ ```bash
593
+ export CLOUDFLARE_API_TOKEN=… # Zone WAF/Rulesets:Edit, this zone only
594
+ ZONE=$(curl -s -H "Authorization: Bearer $CLOUDFLARE_API_TOKEN" \
595
+ "https://api.cloudflare.com/client/v4/zones?name=example.com" \
596
+ | python3 -c "import sys,json;print(json.load(sys.stdin)['result'][0]['id'])")
597
+
598
+ curl -s -X POST \
599
+ "https://api.cloudflare.com/client/v4/zones/$ZONE/rulesets/phases/http_request_dynamic_redirect/entrypoint/rules" \
600
+ -H "Authorization: Bearer $CLOUDFLARE_API_TOKEN" \
601
+ -H "Content-Type: application/json" \
602
+ --data '{
603
+ "description": "www to apex",
604
+ "expression": "(http.host eq \"www.example.com\")",
605
+ "action": "redirect",
606
+ "action_parameters": {
607
+ "from_value": {
608
+ "status_code": 301,
609
+ "target_url": { "expression": "concat(\"https://example.com\", http.request.uri.path)" },
610
+ "preserve_query_string": true
611
+ }
612
+ }
613
+ }'
614
+ ```
615
+
616
+ ### Verify — against the live host, not the dashboard's confirmation
617
+
618
+ ```bash
619
+ # must be 301, and Location must keep the path
620
+ curl -sI https://www.example.com/some-page/ | grep -iE '^HTTP|^location'
621
+
622
+ # the query string must survive
623
+ curl -sI "https://www.example.com/search/?q=test" | grep -i '^location'
624
+
625
+ # and the apex itself must NOT have started redirecting — that is an infinite loop
626
+ curl -so /dev/null -w '%{http_code}\n' https://example.com/some-page/
627
+ ```
628
+
629
+ Expected: `301` + `Location: https://example.com/some-page/`, the query preserved,
630
+ and the apex still `200`. **If the apex returns 301, remove the rule immediately** — the
631
+ expression matched too broadly and the site is in a redirect loop.
632
+
633
+ ---
634
+
635
+ ## 4. First week
636
+
637
+ | When | Watch | Acting on it |
638
+ | --- | --- | --- |
639
+ | Day 1 | Form submissions arriving, in KV **and** the client's inbox | Silence here is the expensive failure. Check before they do |
640
+ | Day 1 | `curl` the ten highest-traffic legacy URLs | A redirect that 301s to a 404 is invisible until traffic drops |
641
+ | Day 2–3 | Search Console → Pages → "Not indexed" | A spike means a redirect or canonical is wrong |
642
+ | Day 2–3 | Search Console → Sitemaps | "Couldn't fetch" means the filename changed |
643
+ | Week 1 | Analytics: is anything recording at all? | A missing tag looks identical to a quiet week |
644
+ | Week 1 | 404 log | Real 404s are URLs you missed in the inventory. Add redirects |
645
+ | Week 2–4 | Search Console → Performance, versus the old site | Some ranking movement is normal. A sustained drop is a redirect problem |
646
+ | Week 4 | Core Web Vitals field data (CrUX) | Lab numbers are a proxy; this is the real one |
647
+
648
+ **Export the leads on a schedule.** The repo is the backup for content and code; leads in KV
649
+ are the one thing not in git.
650
+
651
+ ```bash
652
+ curl -H "Authorization: Bearer $LEADS_EXPORT_TOKEN" "https://$PROD/api/leads.csv" -o leads.csv
653
+ ```
654
+
655
+ ---
656
+
657
+ ## 5. Rebuild test
658
+
659
+ The real test of the configuration, and worth doing once before handover: **delete the
660
+ deployment target and recreate it from the repo.** If that needs undocumented dashboard clicks,
661
+ the setup is not reproducible — write down what is missing, here.