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.
- package/README.md +54 -0
- package/index.mjs +149 -0
- package/package.json +42 -0
- package/template/.dev.vars.example +3 -0
- package/template/.github/workflows/gates.yml +58 -0
- package/template/.node-version +1 -0
- package/template/.pa11yci.json +24 -0
- package/template/BUILD-STATE.md +47 -0
- package/template/CLAUDE.md +153 -0
- package/template/astro.config.mjs +150 -0
- package/template/docs/analytics.md +86 -0
- package/template/docs/content.md +138 -0
- package/template/docs/handover.md +182 -0
- package/template/docs/handover.pdf +0 -0
- package/template/docs/runbook.md +661 -0
- package/template/docs/traps.md +903 -0
- package/template/gitignore +31 -0
- package/template/package-lock.json +8159 -0
- package/template/package.json +53 -0
- package/template/public/_headers +61 -0
- package/template/public/_redirects +39 -0
- package/template/public/site.webmanifest +13 -0
- package/template/scripts/a11y-evidence.mjs +258 -0
- package/template/scripts/check-console.mjs +125 -0
- package/template/scripts/check-env.mjs +99 -0
- package/template/scripts/check-reflow.mjs +148 -0
- package/template/scripts/check-sitemap.mjs +113 -0
- package/template/scripts/dns-snapshot.mjs +267 -0
- package/template/scripts/extract.mjs +317 -0
- package/template/scripts/indexnow.mjs +154 -0
- package/template/scripts/lastmod.mjs +147 -0
- package/template/scripts/lib/inventory.mjs +104 -0
- package/template/scripts/lib/preserved.mjs +42 -0
- package/template/scripts/lib/routes.mjs +92 -0
- package/template/scripts/md-to-pdf.mjs +335 -0
- package/template/scripts/og-cards.config.mjs +114 -0
- package/template/scripts/og-cards.mjs +487 -0
- package/template/scripts/optimize-media.mjs +380 -0
- package/template/scripts/recon.mjs +480 -0
- package/template/scripts/redirects.mjs +298 -0
- package/template/scripts/shots.mjs +447 -0
- package/template/scripts/staging-headers.mjs +102 -0
- package/template/scripts/tells.mjs +268 -0
- package/template/scripts/verify.mjs +1069 -0
- package/template/src/components/ContactForm.astro +405 -0
- package/template/src/components/CtaBand.astro +82 -0
- package/template/src/components/EnvBadge.astro +146 -0
- package/template/src/components/Footer.astro +210 -0
- package/template/src/components/Header.astro +530 -0
- package/template/src/components/Icon.astro +56 -0
- package/template/src/components/Img.astro +129 -0
- package/template/src/components/PageHero.astro +88 -0
- package/template/src/components/Seo.astro +119 -0
- package/template/src/components/StructuredData.astro +173 -0
- package/template/src/content/blog/.gitkeep +5 -0
- package/template/src/content/legal/.gitkeep +0 -0
- package/template/src/content.config.ts +81 -0
- package/template/src/data/areas.ts +31 -0
- package/template/src/data/business.ts +121 -0
- package/template/src/data/categories.ts +37 -0
- package/template/src/data/fonts.ts +25 -0
- package/template/src/data/image-manifest.json +1 -0
- package/template/src/data/lastmod.json +1 -0
- package/template/src/data/nav.ts +49 -0
- package/template/src/data/services.ts +39 -0
- package/template/src/data/site.ts +136 -0
- package/template/src/env.d.ts +28 -0
- package/template/src/layouts/Base.astro +223 -0
- package/template/src/lib/brevo.ts +96 -0
- package/template/src/lib/hast-media.mjs +55 -0
- package/template/src/lib/lastmod.mjs +47 -0
- package/template/src/lib/lead.ts +92 -0
- package/template/src/lib/legal-routes.mjs +31 -0
- package/template/src/lib/legal.ts +75 -0
- package/template/src/lib/posts.ts +64 -0
- package/template/src/lib/runtime.ts +33 -0
- package/template/src/pages/404.astro +51 -0
- package/template/src/pages/[slug].astro +111 -0
- package/template/src/pages/accessibility.astro +128 -0
- package/template/src/pages/api/contact.ts +191 -0
- package/template/src/pages/api/leads.csv.ts +82 -0
- package/template/src/pages/contact.astro +112 -0
- package/template/src/pages/index.astro +84 -0
- package/template/src/pages/robots.txt.ts +38 -0
- package/template/src/pages/rss.xml.ts +27 -0
- package/template/src/styles/global.css +463 -0
- package/template/src/styles/project.css +14 -0
- package/template/src/styles/prose.css +182 -0
- package/template/src/styles/tokens.css +218 -0
- package/template/tsconfig.json +5 -0
- 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.
|