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,903 @@
|
|
|
1
|
+
# Traps
|
|
2
|
+
|
|
3
|
+
Failures that were **silent** — clean build, clean types, clean deploy, wrong result. Read this
|
|
4
|
+
before debugging anything strange.
|
|
5
|
+
|
|
6
|
+
The first section ships with the template and describes traps in *this* code. **The second is
|
|
7
|
+
yours to fill in during the build** — it is the highest-value page in the repo six months later.
|
|
8
|
+
|
|
9
|
+
---
|
|
10
|
+
|
|
11
|
+
## In this codebase
|
|
12
|
+
|
|
13
|
+
### Astro scoped styles do not reach a class passed *into* a component
|
|
14
|
+
|
|
15
|
+
A class handed to `<Icon class="menu__arrow" />` is not written in the parent's own template,
|
|
16
|
+
so Astro never stamps its scoping attribute on it and the parent's `.menu__arrow { … }` rule
|
|
17
|
+
matches nothing. Same for elements JavaScript creates at runtime.
|
|
18
|
+
|
|
19
|
+
*Symptom:* a rule that is definitely in the CSS bundle and has no effect — a toggle rendering
|
|
20
|
+
both icon states at once.
|
|
21
|
+
|
|
22
|
+
*Fix:* `:global()` — `.menu__list :global(.menu__arrow) { … }`, or put it in `global.css`.
|
|
23
|
+
Several already-correct uses are commented as such; do not "tidy" them away.
|
|
24
|
+
|
|
25
|
+
### Component scripts do not re-run after a client-side navigation
|
|
26
|
+
|
|
27
|
+
With the view-transitions router a module script runs on first load only.
|
|
28
|
+
|
|
29
|
+
*Fix:* initialise from the router's page-load event, not at module scope.
|
|
30
|
+
|
|
31
|
+
### …and a persisted element's handlers outlive the elements they captured
|
|
32
|
+
|
|
33
|
+
`transition:persist` on the header keeps its handlers alive forever — still referencing DOM
|
|
34
|
+
that page-load replaced. The mobile menu lives *outside* the persisted header, so it is a new
|
|
35
|
+
element every navigation and the old handler keeps mutating the previous page's detached one.
|
|
36
|
+
|
|
37
|
+
*Symptom:* works on first load, silently dead after one navigation, no error.
|
|
38
|
+
|
|
39
|
+
*Fix:* never capture a non-persisted element in a long-lived closure. Look it up at call time.
|
|
40
|
+
|
|
41
|
+
### `trailingSlash: 'always'` breaks form POSTs
|
|
42
|
+
|
|
43
|
+
`POST /api/contact` 308-redirects to `/api/contact/` and the redirected request loses its body
|
|
44
|
+
on the Workers runtime. The endpoint looks broken while being fine.
|
|
45
|
+
|
|
46
|
+
*Fix:* the form posts to `/api/contact/`, slash included. That slash in `ContactForm.astro`'s
|
|
47
|
+
`action` is load-bearing.
|
|
48
|
+
|
|
49
|
+
### Astro's CSRF protection rejects `Origin`-less POSTs with 403
|
|
50
|
+
|
|
51
|
+
It applies to form content types but **not** `application/json` — so the enhanced path tests
|
|
52
|
+
fine while the no-JS path looks broken.
|
|
53
|
+
|
|
54
|
+
*Fix:* send `-H "Origin: https://<host>"` when testing with curl. Browsers always do.
|
|
55
|
+
|
|
56
|
+
### The Cloudflare adapter adds a `SESSION` KV binding with no id
|
|
57
|
+
|
|
58
|
+
Left unconfigured, `@astrojs/cloudflare` writes `{"binding": "SESSION"}` with no id into the
|
|
59
|
+
generated `dist/server/wrangler.json`, and wrangler *creates* that namespace on deploy. Works
|
|
60
|
+
exactly once: the namespace outlives the worker, so recreating the deployment fails on a name
|
|
61
|
+
the previous incarnation left behind.
|
|
62
|
+
|
|
63
|
+
*Fix:* `session: { driver: sessionDrivers.null() }` in `astro.config.mjs`.
|
|
64
|
+
|
|
65
|
+
*Verify after any adapter upgrade* — every binding must have an `id`:
|
|
66
|
+
|
|
67
|
+
```bash
|
|
68
|
+
node -e "console.log(JSON.parse(require('fs').readFileSync('dist/server/wrangler.json','utf8')).kv_namespaces)"
|
|
69
|
+
```
|
|
70
|
+
|
|
71
|
+
### `overflow-x: hidden` on `body` breaks viewport IntersectionObservers
|
|
72
|
+
|
|
73
|
+
It makes body a scroll container, so a viewport-rooted observer never fires and scroll reveals
|
|
74
|
+
never run. *Fix:* `overflow-x: clip`.
|
|
75
|
+
|
|
76
|
+
### `justify-content: center` clips overflowing content unreachably
|
|
77
|
+
|
|
78
|
+
Centred flex content taller than its container overflows in *both* directions and the top
|
|
79
|
+
cannot be scrolled to — exactly what a landscape phone produces.
|
|
80
|
+
|
|
81
|
+
*Fix:* `margin: auto` on the child. Auto margins collapse to zero once content exceeds the box.
|
|
82
|
+
|
|
83
|
+
### `100vh` is taller than the visible area on mobile
|
|
84
|
+
|
|
85
|
+
It excludes collapsing browser chrome, so a full-height panel hides its own bottom CTA.
|
|
86
|
+
*Fix:* `100dvh`.
|
|
87
|
+
|
|
88
|
+
### A fixed header covers a full-screen panel
|
|
89
|
+
|
|
90
|
+
*Fix:* hide the header while the panel is open and give the panel its own close control — and
|
|
91
|
+
make that control sticky, because `position: absolute` inside a scrolling panel scrolls away.
|
|
92
|
+
|
|
93
|
+
### An mtime "skip if unchanged" guard must skip the work, not the bookkeeping
|
|
94
|
+
|
|
95
|
+
`optimize-media.mjs` returns early on a warm rebuild. If it returns *before* recording output
|
|
96
|
+
in the manifest, pages ship with no `og:image` and the error surfaces nowhere near the cause.
|
|
97
|
+
|
|
98
|
+
### Analytics IDs copied from another project
|
|
99
|
+
|
|
100
|
+
`src/data/site.ts` emits nothing unless both IDs are set, precisely so an unset ID cannot fall
|
|
101
|
+
back to someone else's container. A copied GTM ID sends a real business's traffic to a
|
|
102
|
+
different property, builds green, deploys clean, and reports nothing wrong.
|
|
103
|
+
|
|
104
|
+
### `aria-hidden` on a wrapper that still contains focusable children
|
|
105
|
+
|
|
106
|
+
Keyboard focus lands on controls the screen reader has been told do not exist. Visually
|
|
107
|
+
correct, so nothing flags it. *Fix:* `inert`, which removes both.
|
|
108
|
+
|
|
109
|
+
### A skip link that scrolls but does not move focus
|
|
110
|
+
|
|
111
|
+
`href="#main"` moves the scroll position without moving focus unless the target carries
|
|
112
|
+
`tabindex="-1"`. The page jumps, so it looks like it worked; the next Tab returns to the nav.
|
|
113
|
+
|
|
114
|
+
*Fix:* `tabindex="-1"` on the target, and test by Tabbing *after* activating it.
|
|
115
|
+
|
|
116
|
+
### A live region injected at announce time announces nothing
|
|
117
|
+
|
|
118
|
+
`role="status"` must be in the DOM *before* its content changes. `ContactForm.astro` renders
|
|
119
|
+
the empty region on load and writes into it — that ordering is deliberate.
|
|
120
|
+
|
|
121
|
+
### DNS negative caching outlives the fix
|
|
122
|
+
|
|
123
|
+
A newly pointed subdomain serves 200 with a valid certificate while your own machine reports
|
|
124
|
+
"could not resolve host" — the OS cached the NXDOMAIN from before the record existed.
|
|
125
|
+
|
|
126
|
+
*Diagnosis:* if `dig` succeeds while `getaddrinfo` fails, it is your cache, not the origin.
|
|
127
|
+
|
|
128
|
+
### `fetch()` to object storage fails CORS even when assets serve perfectly
|
|
129
|
+
|
|
130
|
+
Buckets send no `Access-Control-Allow-Origin` by default; `<img>` does not care. Test with an
|
|
131
|
+
image tag, not `fetch`.
|
|
132
|
+
|
|
133
|
+
---
|
|
134
|
+
|
|
135
|
+
## Found on this build
|
|
136
|
+
|
|
137
|
+
> Add every non-obvious failure here as it happens, in this shape. The bar is that it was
|
|
138
|
+
> **silent** — if a compiler, linter or obvious error message caught it, leave it out.
|
|
139
|
+
>
|
|
140
|
+
> ```markdown
|
|
141
|
+
> ### One-line statement of the failure
|
|
142
|
+
>
|
|
143
|
+
> What happened, in the words you used while confused by it.
|
|
144
|
+
>
|
|
145
|
+
> *Symptom:* what you actually observe. This is what makes it findable later.
|
|
146
|
+
>
|
|
147
|
+
> *Fix:* the change, and why it is the right one rather than a workaround.
|
|
148
|
+
> ```
|
|
149
|
+
|
|
150
|
+
<!-- entries go here -->
|
|
151
|
+
|
|
152
|
+
|
|
153
|
+
### A scoped selector never matches an attribute on `<html>`
|
|
154
|
+
|
|
155
|
+
`[data-reveal-ready] .thing { … }` inside a component's `<style>` compiles to
|
|
156
|
+
`[data-astro-cid-…][data-reveal-ready] .thing[data-astro-cid-…]`. Astro adds the
|
|
157
|
+
scope id to **every compound selector**, and `<html>` has no component scope, so
|
|
158
|
+
the rule ships and matches nothing.
|
|
159
|
+
|
|
160
|
+
**Symptom:** the animation simply does not happen. Clean build, correct CSS in
|
|
161
|
+
the bundle, no error. Fixing only half the selectors is worse — the hidden state
|
|
162
|
+
applies and the reveal never does, so the element stays permanently invisible.
|
|
163
|
+
|
|
164
|
+
**Fix:** `:global([data-reveal-ready]) .thing`.
|
|
165
|
+
|
|
166
|
+
This is the fifth variant of the same trap in this build: a scoped style never
|
|
167
|
+
reaches a class passed *into* a component, never reaches a child rendered by
|
|
168
|
+
another component, `> *` never matches such a child, `> :global(*)` compiles to
|
|
169
|
+
the same broken thing, and now this.
|
|
170
|
+
|
|
171
|
+
### A reveal safety net that cancels every reveal
|
|
172
|
+
|
|
173
|
+
The scroll-reveal guard revealed **everything** after 2.5s so nothing could be
|
|
174
|
+
stranded invisible. That included content far below the fold — so anyone who
|
|
175
|
+
took longer than 2.5s to scroll, which is anyone reading, arrived to find the
|
|
176
|
+
animation had already played.
|
|
177
|
+
|
|
178
|
+
**Symptom:** "the animations don't work", while every animation is working
|
|
179
|
+
perfectly, on time, to an empty room.
|
|
180
|
+
|
|
181
|
+
**Fix:** rescue only what is currently in the viewport, and fall back to
|
|
182
|
+
revealing everything only if the observer never fired at all — which is the
|
|
183
|
+
failure the guard actually exists for.
|
|
184
|
+
|
|
185
|
+
### A the booking vendor plugin that renders nothing because of a missing attribute
|
|
186
|
+
|
|
187
|
+
`/schedule/` was blank. The embed had been carried over from the WordPress site
|
|
188
|
+
before the booking vendor's snippet gained `locale="en"`. Without it the plugin loads,
|
|
189
|
+
fetches its configuration, fetches the sessions — and renders nothing into its
|
|
190
|
+
container.
|
|
191
|
+
|
|
192
|
+
**Symptom:** no console error, no failed request, a populated network tab and
|
|
193
|
+
an empty div.
|
|
194
|
+
|
|
195
|
+
**Fix:** diff the embed against a freshly generated snippet from the the booking vendor
|
|
196
|
+
dashboard rather than debugging the plugin. Any third-party embed carried
|
|
197
|
+
across a migration deserves the same check.
|
|
198
|
+
|
|
199
|
+
### A third-party widget that changes your own CSS
|
|
200
|
+
|
|
201
|
+
Enabling the the booking vendor webchat took three pages from 0 accessibility errors to
|
|
202
|
+
failing. It injects a stylesheet into the page. Separately, its fixed overlay
|
|
203
|
+
makes axe report colour-contrast violations on elements it covers, which look
|
|
204
|
+
identical to real failures — `htmlcs` reported nothing and the computed colours
|
|
205
|
+
were 17:1.
|
|
206
|
+
|
|
207
|
+
**Fix:** a `.btn.btn` specificity guard for the CSS, and measure before
|
|
208
|
+
believing a contrast failure that only one runner reports.
|
|
209
|
+
|
|
210
|
+
### A component's inline `style` beating the stylesheet — and looking responsive
|
|
211
|
+
|
|
212
|
+
`Wordmark.astro` sized itself with `style="inline-size:14rem"`, because an SVG
|
|
213
|
+
with a viewBox and no width has no useful intrinsic size and a stylesheet that
|
|
214
|
+
fails to load would otherwise leave it at the 300px default. `BandHeader` then
|
|
215
|
+
set `inline-size: min(16rem, 42vw)` on the class it passes in. The inline
|
|
216
|
+
attribute wins, so that rule never did anything.
|
|
217
|
+
|
|
218
|
+
**What hid it:** the reset's `max-width: 100%`. The mark ran to its 14rem and
|
|
219
|
+
was then clamped by whatever space the layout had left, so it *did* shrink on a
|
|
220
|
+
phone — it looked responsive while actually being squeezed. Nothing measured
|
|
221
|
+
what the author wrote: at 390px the mark was 171px, which is neither `16rem`
|
|
222
|
+
nor `42vw` nor `14rem`.
|
|
223
|
+
|
|
224
|
+
**Fix:** `inline-size: var(--wordmark-size, 14rem)` in the inline style. The
|
|
225
|
+
default survives a missing stylesheet, and a caller can now set the size from
|
|
226
|
+
CSS — where it can be media-queried — by setting the custom property on a
|
|
227
|
+
wrapper it owns. No `:global()` needed either, because custom properties
|
|
228
|
+
inherit.
|
|
229
|
+
|
|
230
|
+
**Rule of thumb:** if a component writes an inline `style`, every property in
|
|
231
|
+
it is unoverridable from CSS. Make each one a custom property with the current
|
|
232
|
+
value as the fallback.
|
|
233
|
+
|
|
234
|
+
### Grid rows stretch, so a second row does not sit under the first
|
|
235
|
+
|
|
236
|
+
Converting the band header from flex to grid moved `BOOK NOW` from just under
|
|
237
|
+
the nav to the vertical middle of the photograph. `align-items: start` was
|
|
238
|
+
already set and was not enough: it aligns each item within *its* row, while the
|
|
239
|
+
auto-sized rows themselves still stretch to fill a container taller than their
|
|
240
|
+
content.
|
|
241
|
+
|
|
242
|
+
**Symptom:** an element that looks correctly aligned within its own row, in the
|
|
243
|
+
wrong place on the page. No overflow, no error.
|
|
244
|
+
|
|
245
|
+
**Fix:** `align-content: start` on the grid container.
|
|
246
|
+
|
|
247
|
+
### `margin: 0` on a `.container` un-centres it
|
|
248
|
+
|
|
249
|
+
`.container` centres itself with `margin-inline: auto`. A `<ul>` or `<ol>` that
|
|
250
|
+
is also the container needs its browser default margin cleared — and
|
|
251
|
+
`margin: 0` clears the auto margins with it. The block then sits hard against
|
|
252
|
+
the left edge while every other section on the page stays centred, which reads
|
|
253
|
+
as a broken grid rather than as a margin bug. `padding: 0` does the same thing
|
|
254
|
+
to the container's gutter.
|
|
255
|
+
|
|
256
|
+
**Symptom:** correct at narrow widths, where the container is full-bleed
|
|
257
|
+
anyway, and visibly wrong past `--width-max`. Nobody sees it on a laptop.
|
|
258
|
+
|
|
259
|
+
**Fix:** `margin-block: 0` and `padding-block: 0`. Zero the axis you meant.
|
|
260
|
+
|
|
261
|
+
**Twice now** — the homepage path cards and the What We Offer tiles.
|
|
262
|
+
|
|
263
|
+
### A progressive-enhancement hook applied too late is a layout shift
|
|
264
|
+
|
|
265
|
+
The header band's phone menu is gated on `[data-band-js]`, set by script, so
|
|
266
|
+
that with JavaScript off nothing collapses and the full nav stays reachable.
|
|
267
|
+
Correct — but the attribute was set from `astro:page-load`, in a bundled module.
|
|
268
|
+
|
|
269
|
+
Measured on a phone against the deployed site: the band rendered in its
|
|
270
|
+
no-JavaScript state — five nav labels wrapped under the wordmark, no menu
|
|
271
|
+
button — and collapsed **2.1 seconds later**. Layout shift 0.183, against a
|
|
272
|
+
0.1 Core Web Vitals budget. What a visitor saw was the desktop layout
|
|
273
|
+
rearranging itself under their thumb.
|
|
274
|
+
|
|
275
|
+
**Symptom:** nothing is wrong in any screenshot, because every screenshot is
|
|
276
|
+
taken after the script has run. It only exists between first paint and the
|
|
277
|
+
bundle executing, which is exactly the window automated checks skip.
|
|
278
|
+
|
|
279
|
+
**Fix:** `<script is:inline>` placed after the markup it configures, so it runs
|
|
280
|
+
while the parser is still on the element and the state is there at first paint.
|
|
281
|
+
Keep element-level listeners on the element (they die with it) and register
|
|
282
|
+
document-level ones once behind a `window` flag, or a client-side navigation
|
|
283
|
+
adds a fresh set every time.
|
|
284
|
+
|
|
285
|
+
**The general rule:** if a class or attribute changes layout, it has to be
|
|
286
|
+
applied before first paint. `astro:page-load` is for behaviour, not for layout.
|
|
287
|
+
|
|
288
|
+
### `grid-template-rows: 0fr` animates only a container with ONE child
|
|
289
|
+
|
|
290
|
+
The open/close animation on a collapsible section needs a container whose
|
|
291
|
+
single child occupies the row being animated. Put it on a `<ul>` whose `<li>`s
|
|
292
|
+
are the grid items and each one auto-places into its own IMPLICIT row —
|
|
293
|
+
implicit rows are not the track in `grid-template-rows`, so the transition runs
|
|
294
|
+
against nothing and the panel snaps open.
|
|
295
|
+
|
|
296
|
+
**Fix:** wrap the list in one element, animate the wrapper, and give the child
|
|
297
|
+
`min-block-size: 0; overflow: clip`. `clip`, not `hidden` — `hidden` makes it a
|
|
298
|
+
scroll container mid-animation.
|
|
299
|
+
|
|
300
|
+
**Why this technique at all:** it is the only height animation that works in
|
|
301
|
+
every engine this site ships to. `interpolate-size: allow-keywords` with
|
|
302
|
+
`height: auto` is Chromium-only; `::details-content` has no Firefox; a
|
|
303
|
+
JS-measured pixel height works but re-measures on every rotation and font-size
|
|
304
|
+
change and gets the first frame wrong if a webfont lands mid-animation.
|
|
305
|
+
|
|
306
|
+
**Verified, not assumed** — driven in WebKit and Firefox as well as Chromium,
|
|
307
|
+
checking that the height passes through intermediate values rather than
|
|
308
|
+
jumping. A jump and an animation end at the same number; only the frames
|
|
309
|
+
between them tell you which you have.
|
|
310
|
+
|
|
311
|
+
### `<ClientRouter>` does not re-run `is:inline` scripts after a swap
|
|
312
|
+
|
|
313
|
+
An inline script placed after its markup runs while the parser is on the
|
|
314
|
+
element — which is exactly why it is inline, and it removes the flash of an
|
|
315
|
+
unenhanced layout on first paint. It runs **once**. The router replaces the
|
|
316
|
+
body on every client-side navigation and does not execute it again, so every
|
|
317
|
+
page reached by clicking a link keeps the un-enhanced markup.
|
|
318
|
+
|
|
319
|
+
**Symptom, and why it is worse than it looks:** it presents as "sometimes the
|
|
320
|
+
wrong layout flashes". It is not a flash. On a hard refresh the page is right;
|
|
321
|
+
on any navigation it is permanently wrong until the next refresh. Here the
|
|
322
|
+
phone menu stayed expanded across the band with its button still `hidden` —
|
|
323
|
+
no way to navigate at all on a phone.
|
|
324
|
+
|
|
325
|
+
**Fix:** name the setup function, call it immediately AND from
|
|
326
|
+
`astro:page-load`, and guard it with an attribute on the element so it is
|
|
327
|
+
idempotent. Register that listener on `document`, which the router does not
|
|
328
|
+
replace, behind a `window` flag so repeat executions cannot stack it.
|
|
329
|
+
|
|
330
|
+
**And do not use `document.currentScript` to find the element.** It is null on
|
|
331
|
+
every run except the first, so `currentScript.previousElementSibling` throws
|
|
332
|
+
precisely in the case you added the second run to fix. Look the element up.
|
|
333
|
+
|
|
334
|
+
### A header that clips or sits under the page, and only on some pages
|
|
335
|
+
|
|
336
|
+
Two separate faults, one symptom — a dropdown that opens and shows some of its
|
|
337
|
+
items.
|
|
338
|
+
|
|
339
|
+
`overflow: hidden` on the band clipped the panel to the band's own height. The
|
|
340
|
+
interior band is 11rem and the widest menu is seven items: the band ended at
|
|
341
|
+
176px, the panel at 434px, and six of the seven links were cut off and
|
|
342
|
+
unclickable. **The homepage was fine**, because its band is tall enough to
|
|
343
|
+
contain a panel — which is exactly why it survived review.
|
|
344
|
+
|
|
345
|
+
Removing the clip exposed the second: both the band and sections like
|
|
346
|
+
`.rd-tile` and `.about__wall` are positioned, so with no `z-index` anywhere,
|
|
347
|
+
SOURCE ORDER decided who painted on top and the later element won. Five of
|
|
348
|
+
seven links reachable on /about-us/ and /classes/, all seven on pages whose
|
|
349
|
+
first section happens not to be positioned.
|
|
350
|
+
|
|
351
|
+
**Fix:** no clip on the header, and give it `z-index: var(--z-header)`. A
|
|
352
|
+
header must paint above the page; leaving it to source order makes correctness
|
|
353
|
+
depend on what the page below happens to contain.
|
|
354
|
+
|
|
355
|
+
**Test for it like this:** count links whose centre point actually hits the
|
|
356
|
+
panel via `document.elementFromPoint`. Checking that the panel is "visible", or
|
|
357
|
+
that the links exist, finds neither fault — the panel was visible and the links
|
|
358
|
+
were in the DOM.
|
|
359
|
+
|
|
360
|
+
### `align-content: start` makes an auto margin do nothing
|
|
361
|
+
|
|
362
|
+
Three cards over a photograph on the homepage, each with a title, a body and a
|
|
363
|
+
"→" link. The grid stretched all three to a common height, so the CARDS were
|
|
364
|
+
level — but each card's own link sat directly under its own copy, and the three
|
|
365
|
+
arrows landed at three different heights.
|
|
366
|
+
|
|
367
|
+
The obvious fix, `margin-block-start: auto` on the link, does nothing at all.
|
|
368
|
+
The card was `display: grid` with `align-content: start`: its rows are sized to
|
|
369
|
+
their content and packed against the top, so the link's own grid area has no
|
|
370
|
+
spare height in it. An auto margin can only absorb slack that exists inside the
|
|
371
|
+
item's area, and there is none — all the leftover space is below the last row,
|
|
372
|
+
outside every area.
|
|
373
|
+
|
|
374
|
+
**Fix:** give the card an explicit `grid-template-rows: auto 1fr auto` and put
|
|
375
|
+
`align-self: start` on the middle item. The `1fr` row takes the slack, the
|
|
376
|
+
last row is pushed to the floor, and the arrows line up whatever the copy does.
|
|
377
|
+
|
|
378
|
+
Same shape of mistake as `justify-content: center` on an overflowing box: the
|
|
379
|
+
declaration is about distributing free space in the CONTAINER, and the thing
|
|
380
|
+
you actually want is to give one CHILD the space.
|
|
381
|
+
|
|
382
|
+
### A traced icon fills in solid, and the artwork looks nothing like the file
|
|
383
|
+
|
|
384
|
+
Four hand-drawn brush marks were traced from PNG. Three came out perfectly; the
|
|
385
|
+
spiral rendered as a solid black disc.
|
|
386
|
+
|
|
387
|
+
potrace expresses the gaps between the spiral's rings as inner contours with
|
|
388
|
+
opposite winding. SVG's default `fill-rule` is `nonzero`, under which those
|
|
389
|
+
contours fill rather than cut. **Set `fill-rule="evenodd"` on the path.**
|
|
390
|
+
|
|
391
|
+
The same job carried a second trap. One of the four source PNGs was CREAM, cut
|
|
392
|
+
for use on a dark photograph. Traced by luminance it
|
|
393
|
+
returns an empty path; used as an image it renders as a pale ghost on the cream
|
|
394
|
+
ground, which is what the client saw and reported as "not all showing up
|
|
395
|
+
correctly". **Trace the ALPHA channel, not the luminance**: that takes the
|
|
396
|
+
shape and discards the colour, so the mark inherits `currentColor` like every
|
|
397
|
+
other icon.
|
|
398
|
+
|
|
399
|
+
### Verifying a build with `python -m http.server` invents bugs
|
|
400
|
+
|
|
401
|
+
Screenshots of the built site showed the nav dropdowns hanging open over the
|
|
402
|
+
hero, images missing, and — intermittently, about one load in four — the
|
|
403
|
+
desktop two-column layout collapsing to one column at 1100px while
|
|
404
|
+
`matchMedia('(min-width: 60rem)')` still reported true.
|
|
405
|
+
|
|
406
|
+
None of it was real. `http.server` is single-threaded and drops connections
|
|
407
|
+
under a browser's parallel fetches; `requestfailed` fired on
|
|
408
|
+
`_astro/index.*.css` and the page rendered with a stylesheet missing. The
|
|
409
|
+
"missing images" were the same thing plus `full_page` screenshots re-rendering
|
|
410
|
+
after a viewport resize and catching lazy images mid-flight.
|
|
411
|
+
|
|
412
|
+
**Check the harness before believing the symptom.** Log `requestfailed` and
|
|
413
|
+
`response.status >= 400`, and assert something cheap that proves the CSS
|
|
414
|
+
arrived — `getComputedStyle(document.body).backgroundColor` is not
|
|
415
|
+
`rgba(0, 0, 0, 0)` — before trusting any measurement. For screenshots, freeze
|
|
416
|
+
transitions and await `img.decode()`. Better still, verify against the deployed
|
|
417
|
+
site, which is what `docs/runbook.md` asks for.
|
|
418
|
+
|
|
419
|
+
### "It's inside a cross-origin iframe" is not the same as "it cannot be changed"
|
|
420
|
+
|
|
421
|
+
The client asked twice for the the booking vendor booking panel's white ground to match
|
|
422
|
+
the cream page. The white comes from `html, body, #root { background-color:
|
|
423
|
+
#ffffff }` inside vendor.com's own document — cross-origin, unreachable, and
|
|
424
|
+
that was reported back as "the only lever is the booking vendor's branding settings."
|
|
425
|
+
|
|
426
|
+
Wrong conclusion from a correct fact. **We cannot edit what the frame draws;
|
|
427
|
+
we can change how it composites onto the page.**
|
|
428
|
+
|
|
429
|
+
```css
|
|
430
|
+
.vendor iframe {
|
|
431
|
+
background: var(--white); /* multiply's identity — NOT cream */
|
|
432
|
+
mix-blend-mode: multiply;
|
|
433
|
+
}
|
|
434
|
+
```
|
|
435
|
+
|
|
436
|
+
Multiply against the cream backdrop turns the frame's white to exactly
|
|
437
|
+
`--cream` and leaves dark text, borders and grey panels alone, because
|
|
438
|
+
`white × anything = anything`. Colours inside shift by the backdrop's tint —
|
|
439
|
+
here a 3% cut in blue, invisible on a white/grey/near-black widget.
|
|
440
|
+
|
|
441
|
+
Three conditions, all of which fail silently:
|
|
442
|
+
|
|
443
|
+
- **The backdrop must be light.** Multiply darkens. Inside `.on-dark` or
|
|
444
|
+
`.on-accent` the whole widget goes black.
|
|
445
|
+
- **The element's own background must be WHITE**, not the target colour. It is
|
|
446
|
+
what paints before the frame loads, and cream multiplied by cream lands a
|
|
447
|
+
shade dark.
|
|
448
|
+
- **Opt out under `forced-colors: active`.** High-contrast mode replaces
|
|
449
|
+
colours wholesale and a blend on top of that gives mud.
|
|
450
|
+
|
|
451
|
+
Test in WebKit specifically — blend modes on iframes have a history of being
|
|
452
|
+
unreliable there. Chromium, WebKit and Firefox were all checked here before it
|
|
453
|
+
shipped, and all three agreed.
|
|
454
|
+
|
|
455
|
+
Related: the same page's *class timetable* needed none of this. That the booking vendor
|
|
456
|
+
product renders into our own DOM rather than a frame, so it already inherited
|
|
457
|
+
the page background. Check which kind of embed you have before reaching for
|
|
458
|
+
anything.
|
|
459
|
+
|
|
460
|
+
### `overflow: hidden` on a dialog silently eats the last thing in it
|
|
461
|
+
|
|
462
|
+
The "New Here?" pop-up carries a heading and three offers. On a 375x667 phone
|
|
463
|
+
the panel is 724px of content in a 635px dialog — and `.promo` had
|
|
464
|
+
`overflow: hidden`, put there to clip the photograph to the rounded corners.
|
|
465
|
+
|
|
466
|
+
The third offer was not scrolled past. It was **gone**, with nothing on screen
|
|
467
|
+
to suggest it existed. Measured clipped on iPhone SE, 360x640 and 390x600 —
|
|
468
|
+
which is a real share of phones, and the offer being lost was the $85 private
|
|
469
|
+
session.
|
|
470
|
+
|
|
471
|
+
**Fix:** `overflow: auto` plus `overscroll-behavior: contain`. `auto` still
|
|
472
|
+
clips to the border radius, so the reason `hidden` was there is unaffected, and
|
|
473
|
+
the overflow becomes reachable instead of deleted.
|
|
474
|
+
|
|
475
|
+
This is the same shape as the `justify-content: center` entry above: a
|
|
476
|
+
declaration chosen for appearance quietly deciding what a visitor can reach.
|
|
477
|
+
Both are invisible on a desktop and both need a short viewport to show up.
|
|
478
|
+
|
|
479
|
+
**Test for it like this:** compare the last child's `getBoundingClientRect()
|
|
480
|
+
.bottom` against the container's, at 667px tall and below. Checking that the
|
|
481
|
+
element "renders" or is "visible" finds nothing — it is in the DOM, it has
|
|
482
|
+
layout, and it is painted; it is just outside the box that clips it.
|
|
483
|
+
|
|
484
|
+
### Working CSS that quietly stopped running, and nothing said so
|
|
485
|
+
|
|
486
|
+
`global.css` carried a complete page transition — 180ms fade-and-lift out,
|
|
487
|
+
320ms fade-and-rise in, a reduced-motion opt-out, a named header held still.
|
|
488
|
+
It had not run for weeks.
|
|
489
|
+
|
|
490
|
+
It was written for `<ClientRouter />`, which swaps the document without a page
|
|
491
|
+
load. The swap was later switched off from a completely different layer:
|
|
492
|
+
`Base.astro` stamps `data-astro-reload` on every internal link, because the
|
|
493
|
+
the booking vendor embeds do not re-initialise after a swap. Correct fix, and it silently
|
|
494
|
+
orphaned a block of CSS two files away.
|
|
495
|
+
|
|
496
|
+
**Nothing catches this.** The build is clean, the rules are valid, the selectors
|
|
497
|
+
are real, the file is imported, and the page looks right — because the missing
|
|
498
|
+
thing is an animation nobody sees the absence of. It does not even show up as
|
|
499
|
+
dead code: `::view-transition-old(root)` is a legitimate selector that a
|
|
500
|
+
browser will happily match, one day, if anything ever triggers a transition.
|
|
501
|
+
|
|
502
|
+
**Turning it back on was one line** — `@view-transition { navigation: auto }`,
|
|
503
|
+
the native cross-document API, which animates real navigations and therefore
|
|
504
|
+
does not reintroduce the embed bug.
|
|
505
|
+
|
|
506
|
+
Two rules inside the block had also gone stale without complaint: a named
|
|
507
|
+
`site-header` that no element carries any more (the redesign replaced that
|
|
508
|
+
header with one that is deliberately new on every navigation), and Astro's
|
|
509
|
+
SPA-fallback selectors.
|
|
510
|
+
|
|
511
|
+
**The general shape:** when you disable a feature, grep for what else assumed
|
|
512
|
+
it. A behaviour switched off in JavaScript leaves its CSS looking alive. The
|
|
513
|
+
tell is a commit that fixes something in one file and makes another file
|
|
514
|
+
meaningless without touching it.
|
|
515
|
+
|
|
516
|
+
### An absolutely-positioned icon inside a WRAPPING flex container
|
|
517
|
+
|
|
518
|
+
The magnifying glass on `/search/` and on the 404 sat below and to the left of
|
|
519
|
+
its input on a phone, beside the Search button, instead of inside the field.
|
|
520
|
+
|
|
521
|
+
```css
|
|
522
|
+
.search__form { position: relative; display: flex; flex-wrap: wrap; }
|
|
523
|
+
.search__form input { flex: 1 1 16rem; }
|
|
524
|
+
.search__icon { position: absolute; inset-block-start: 50%; translate: 0 -50%; }
|
|
525
|
+
```
|
|
526
|
+
|
|
527
|
+
Every line is reasonable. The fault is that the icon's containing block is the
|
|
528
|
+
FORM. Below about 430px the input's `16rem` basis leaves no room for the button,
|
|
529
|
+
which wraps to a second row — and **50% of a two-row form is the gap between the
|
|
530
|
+
two rows**, which is precisely where the icon went.
|
|
531
|
+
|
|
532
|
+
**It is correct at every width where the button still fits beside the input**,
|
|
533
|
+
which is every width anyone had looked at. Nothing fails, nothing overflows, no
|
|
534
|
+
test notices; the icon is simply somewhere else.
|
|
535
|
+
|
|
536
|
+
**Fix:** give the icon a containing block that can only ever be one row. Wrap
|
|
537
|
+
the icon and the input together in a span that carries the `position: relative`
|
|
538
|
+
and the flex basis, and let the input be `flex: 1; min-inline-size: 0` inside
|
|
539
|
+
it. Now 50% is the middle of the input whatever the button does.
|
|
540
|
+
|
|
541
|
+
**Better still, do what `SearchDialog.astro` already does** and make the icon a
|
|
542
|
+
flex SIBLING of the input — `display: grid; flex: none` — inside a non-wrapping
|
|
543
|
+
row. No absolute positioning, so there is no containing block to get wrong. That
|
|
544
|
+
component has the same three elements and has never broken.
|
|
545
|
+
|
|
546
|
+
**The general shape:** `position: absolute` with a percentage offset is a bet on
|
|
547
|
+
the height of an ancestor. If that ancestor can reflow — a wrapping flex row, a
|
|
548
|
+
grid that changes track count — the bet is only good at some widths. Check what
|
|
549
|
+
the containing block actually is, and whether it can grow a row.
|
|
550
|
+
|
|
551
|
+
### One generator silently deleting another's manifest entries
|
|
552
|
+
|
|
553
|
+
Every page shipped with the same Open Graph card for three days, including
|
|
554
|
+
through go-live. The 21 unique per-page cards were still sitting in
|
|
555
|
+
`public/img/social/`; nothing referenced them.
|
|
556
|
+
|
|
557
|
+
`scripts/og-cards.mjs` writes 23 `social/og-<slug>` entries into
|
|
558
|
+
`src/data/image-manifest.json`. `scripts/optimize-media.mjs` — `npm run media`
|
|
559
|
+
— rebuilt that same file from `const manifest = {}` plus whatever it found in
|
|
560
|
+
`media/source/`. The cards are not in `media/source/`, so every run wiped them.
|
|
561
|
+
|
|
562
|
+
**Running `npm run media` to add one unrelated photograph deleted all 23.** The
|
|
563
|
+
files stayed on disk, the manifest stayed valid, `Seo.astro`'s lookup returned
|
|
564
|
+
undefined and quietly fell back to `og-default.jpg`, and the build stayed green.
|
|
565
|
+
Nothing anywhere compares the file listing to the manifest.
|
|
566
|
+
|
|
567
|
+
**Fix:** `optimize-media.mjs` now reads the existing manifest and carries over
|
|
568
|
+
`social/og-*` keys it does not own, so the two scripts can run in either order.
|
|
569
|
+
|
|
570
|
+
**The general shape:** when two generators write to one file, the one that
|
|
571
|
+
rebuilds from scratch silently owns it. Either it preserves what it does not
|
|
572
|
+
manage, or they must run in a fixed order that nothing enforces. A shared
|
|
573
|
+
artefact with two authors and no merge is a data-loss bug waiting for someone
|
|
574
|
+
to run the wrong command.
|
|
575
|
+
|
|
576
|
+
**Test for it like this:** compare the directory listing to the manifest keys.
|
|
577
|
+
`ls public/img/social | wc -l` against the count of `social/` keys — 24 files,
|
|
578
|
+
1 key was the whole story and takes a second to check.
|
|
579
|
+
|
|
580
|
+
### A hand-made fallback goes stale, and nothing regenerates it
|
|
581
|
+
|
|
582
|
+
`og-default.jpg` was a JPEG in `media/source/brand/`, passed through by
|
|
583
|
+
`optimize-media.mjs`. Every other social card was *generated* by
|
|
584
|
+
`og-cards.mjs`. The redesign landed on 13 August; the fallback had been made on
|
|
585
|
+
the 11th. It kept the old wordmark and the old tagline, and went on serving
|
|
586
|
+
them from a live site.
|
|
587
|
+
|
|
588
|
+
Nothing could have caught it. The file existed, the manifest entry was valid,
|
|
589
|
+
the build was green, and the only page using it — `/sitemap/` — was one nobody
|
|
590
|
+
opens. It surfaced when a share-preview tool was pointed at that URL by hand.
|
|
591
|
+
|
|
592
|
+
**The rule: if it can go stale, generate it.** A fallback is the *least*
|
|
593
|
+
visited asset and therefore the last one anyone checks, which is exactly why it
|
|
594
|
+
must not depend on someone remembering. `og-cards.mjs` now emits `og-default`
|
|
595
|
+
alongside the other 23, from the same type and ink, so changing the brand
|
|
596
|
+
changes the fallback.
|
|
597
|
+
|
|
598
|
+
**And the check that missed it:** the SEO audit two hours earlier had verified
|
|
599
|
+
"23 unique cards, one per page." It tested for *duplicates*. Since only one page
|
|
600
|
+
used the fallback, that page was unique and passed. A verification shaped around
|
|
601
|
+
the failure you expect will not find the one you did not.
|
|
602
|
+
|
|
603
|
+
---
|
|
604
|
+
|
|
605
|
+
### `_redirects` cannot match on hostname in Workers Static Assets
|
|
606
|
+
|
|
607
|
+
`www.example.com` served a full 200 copy of every page because both
|
|
608
|
+
hostnames route to the same worker. The obvious fix looks like the Pages syntax:
|
|
609
|
+
|
|
610
|
+
```
|
|
611
|
+
https://www.example.com/* https://example.com/:splat 301
|
|
612
|
+
```
|
|
613
|
+
|
|
614
|
+
**It does nothing.** Absolute-URL sources are a Cloudflare *Pages* feature;
|
|
615
|
+
Workers Static Assets matches on path only. Tested against `wrangler dev` with a
|
|
616
|
+
spoofed `Host` header — the absolute rule returned 200 while a path rule in the
|
|
617
|
+
same file returned its 301 correctly. No error, no warning, no log line.
|
|
618
|
+
|
|
619
|
+
Middleware does not help either: static assets are served *without* invoking the
|
|
620
|
+
worker, which is why www serves a perfect copy in the first place. Catching it
|
|
621
|
+
would mean `run_worker_first`, routing every request through a worker to fix a
|
|
622
|
+
handful.
|
|
623
|
+
|
|
624
|
+
Host-level redirects are a **Cloudflare Single Redirect**, which runs in the
|
|
625
|
+
dynamic-redirect phase before Worker routes. See `docs/runbook.md` §3a.
|
|
626
|
+
|
|
627
|
+
---
|
|
628
|
+
|
|
629
|
+
### CI shallow-clones, so anything read from git history is empty in production
|
|
630
|
+
|
|
631
|
+
Sitemap `lastmod` was first computed at build time with `git log` per file. It
|
|
632
|
+
produced correct, varied dates locally and **nothing at all** in production:
|
|
633
|
+
Cloudflare Workers Builds shallow-clones, so `git log` returns the single
|
|
634
|
+
grafted commit for every file.
|
|
635
|
+
|
|
636
|
+
The module's own guard caught it — a shallow repo means every page would claim
|
|
637
|
+
the same date, which is the failure the feature exists to avoid, so it emitted
|
|
638
|
+
no `lastmod` rather than a plausible lie. That was the right call and it still
|
|
639
|
+
left a feature that silently did nothing in production. Confirmed by diffing the
|
|
640
|
+
deployed sitemap against the local build: 0 `lastmod` elements versus 23.
|
|
641
|
+
|
|
642
|
+
**The rule: a build must not depend on repository history it may not be given.**
|
|
643
|
+
The dates are now computed by `npm run lastmod` and committed as data, like
|
|
644
|
+
every other fact in this project.
|
|
645
|
+
|
|
646
|
+
Two related judgements worth keeping:
|
|
647
|
+
|
|
648
|
+
- **Never stamp the build time.** It claims every page changed on every deploy.
|
|
649
|
+
Google uses `lastmod` where a site's values are consistently accurate and
|
|
650
|
+
discounts them where they are not, so a plausible wrong date does not buy a
|
|
651
|
+
faster re-crawl — it spends the credibility that would have earned one.
|
|
652
|
+
- **Layout and nav changes must not move a page's date.** Treating
|
|
653
|
+
`Base.astro` and `nav.ts` as sources for every route is literally true — a
|
|
654
|
+
footer link does change all 23 documents — but it collapsed every date to the
|
|
655
|
+
same day, which is indistinguishable from the build-stamp failure and carries
|
|
656
|
+
no prioritisation signal. Google asks for the last *significant content*
|
|
657
|
+
change and says explicitly not to bump for boilerplate.
|
|
658
|
+
|
|
659
|
+
### A `> img` selector stops matching once an image gains a `<picture>`
|
|
660
|
+
|
|
661
|
+
**Symptom:** an image loses its styling — sizing, `object-fit`, a border radius —
|
|
662
|
+
on the commit that turned AVIF on. Nothing errors, the image still loads, and
|
|
663
|
+
the CSS rule looks correct in the stylesheet.
|
|
664
|
+
|
|
665
|
+
`<Img />` wraps its `<img>` in `<picture>` whenever the manifest carries an
|
|
666
|
+
`avifSrcset`. Any selector written as a direct child of the layout parent —
|
|
667
|
+
`.gallery > img`, `.card > img:first-child` — now has a `<picture>` in between
|
|
668
|
+
and matches nothing.
|
|
669
|
+
|
|
670
|
+
`picture { display: contents }` is set in `Img.astro`, which fixes **layout** —
|
|
671
|
+
without it a grid or flex parent starts laying out the wrapper instead of the
|
|
672
|
+
image, and `aspect-ratio` and `height: 100%` quietly stop applying. It does not
|
|
673
|
+
fix **selectors**: `display: contents` removes the box, not the element.
|
|
674
|
+
|
|
675
|
+
**Fix:** drop the `>` — `.gallery img` — or target a class on the image itself.
|
|
676
|
+
Search for it before turning AVIF on:
|
|
677
|
+
|
|
678
|
+
```bash
|
|
679
|
+
grep -rnE '>\s*img|>\s*\.?[a-z-]*img' src/styles src/components src/pages
|
|
680
|
+
```
|
|
681
|
+
|
|
682
|
+
### A failed optional dependency does not fail the install
|
|
683
|
+
|
|
684
|
+
**Symptom:** `astro build` dies with **"Unable to load your Astro config"** and a
|
|
685
|
+
stack trace ending in `workerd/lib/main.js`. The config is fine. The same
|
|
686
|
+
commit built cleanly an hour ago and builds cleanly on a retry.
|
|
687
|
+
|
|
688
|
+
The real message is a few lines further down: *the package
|
|
689
|
+
`@cloudflare/workerd-linux-64` could not be found, and is needed by workerd*.
|
|
690
|
+
workerd ships its ~127 MB binary as a **per-platform `optionalDependency`**, and
|
|
691
|
+
a failed optional dependency **does not fail `npm install` or `npm ci`** — that
|
|
692
|
+
is what optional means. npm reports success, with the full package count and no
|
|
693
|
+
warning, and the gap only surfaces when something tries to use it.
|
|
694
|
+
|
|
695
|
+
So the install says 587 packages added, and the build fails on a config file
|
|
696
|
+
nobody touched.
|
|
697
|
+
|
|
698
|
+
**Fix:** re-run — it is usually transient. To stop it being mysterious, assert
|
|
699
|
+
the binary right after install rather than letting the build discover it:
|
|
700
|
+
|
|
701
|
+
```bash
|
|
702
|
+
node -e 'require.resolve("@cloudflare/workerd-linux-64/bin/workerd")'
|
|
703
|
+
```
|
|
704
|
+
|
|
705
|
+
The kit's own CI does this (`.github/workflows/kit.yml`), because a check that
|
|
706
|
+
fails at the step that caused it is worth more than one that fails twenty lines
|
|
707
|
+
into an unrelated tool.
|
|
708
|
+
|
|
709
|
+
**If it repeats rather than passing on retry**, the lockfile genuinely lacks that
|
|
710
|
+
platform: run `npm install` on the target platform and commit the result. Verify
|
|
711
|
+
with `npm ci --os=linux --cpu=x64` on any machine — npm 10+ can resolve another
|
|
712
|
+
platform's tree without being on it.
|
|
713
|
+
|
|
714
|
+
### Moving the apex takes the client's email with it
|
|
715
|
+
|
|
716
|
+
**Symptom:** none, for days. The site launches, everything looks right, and the
|
|
717
|
+
client says nothing because from where they sit nothing happened. Then someone
|
|
718
|
+
mentions an invoice that never arrived.
|
|
719
|
+
|
|
720
|
+
A rebuild moves the apex — or the nameservers — to the new host. Every other
|
|
721
|
+
record in that zone belongs to somebody else's service, and **MX is the one that
|
|
722
|
+
takes the business down with it.** Mail to the domain starts bouncing at the
|
|
723
|
+
sender's end, so the people who find out are the ones trying to reach the
|
|
724
|
+
client, and none of them can tell the client.
|
|
725
|
+
|
|
726
|
+
A dead website gets a phone call in minutes. Dead email is silent, and the
|
|
727
|
+
silence looks like a quiet week.
|
|
728
|
+
|
|
729
|
+
**Fix:** capture the zone before touching it, and diff it after.
|
|
730
|
+
|
|
731
|
+
```bash
|
|
732
|
+
npm run dns -- example.com # before. commit recon/dns.json
|
|
733
|
+
npm run dns -- example.com --compare # after cutover: what stopped resolving
|
|
734
|
+
```
|
|
735
|
+
|
|
736
|
+
The same applies to SPF, DKIM and DMARC, with a slower symptom: losing those
|
|
737
|
+
does not bounce mail, it degrades deliverability, so it surfaces weeks later as
|
|
738
|
+
"our emails started going to spam".
|
|
739
|
+
|
|
740
|
+
**And check CAA before the cutover, not during.** A CAA record that names no CA
|
|
741
|
+
your host issues through blocks certificate issuance — the deploy succeeds, DNS
|
|
742
|
+
cuts over, and the site serves a TLS error that nothing in the repo can fix.
|
|
743
|
+
|
|
744
|
+
### `Disallow: /` and `noindex` cancel each other out
|
|
745
|
+
|
|
746
|
+
**Symptom:** a staging URL appears in Google — no snippet, just the URL and
|
|
747
|
+
often "No information is available for this page". The page has carried
|
|
748
|
+
`<meta name="robots" content="noindex">` since the day it was built.
|
|
749
|
+
|
|
750
|
+
The two controls do different jobs and the combination is self-defeating:
|
|
751
|
+
|
|
752
|
+
- `robots.txt` `Disallow: /` stops the crawler **fetching** the page
|
|
753
|
+
- `noindex` stops it **indexing** the page — but only if it fetches it and reads
|
|
754
|
+
the tag
|
|
755
|
+
|
|
756
|
+
Block crawling and Googlebot never sees the noindex. It can still index the URL
|
|
757
|
+
from a link elsewhere: a client sharing the staging link in a chat that unfurls,
|
|
758
|
+
a Search Console submission, one backlink. What gets indexed is a bare URL that
|
|
759
|
+
competes with production for the client's own name.
|
|
760
|
+
|
|
761
|
+
**Fix, in order of how well it works:**
|
|
762
|
+
|
|
763
|
+
1. **Put staging behind Cloudflare Access.** Nothing crawls what it cannot
|
|
764
|
+
reach, the question stops existing, and it is free at this scale on a stack
|
|
765
|
+
you are already using. This is the answer.
|
|
766
|
+
2. If it must be public: **allow crawling and serve `noindex`.** Counter-
|
|
767
|
+
intuitive and correct — the tag only works when it is read.
|
|
768
|
+
3. Add `X-Robots-Tag: noindex` as well, which is the only one of the three that
|
|
769
|
+
covers **non-HTML**. A PDF has no `<meta>`; a staging PDF is indexable no
|
|
770
|
+
matter what the pages around it say.
|
|
771
|
+
|
|
772
|
+
**Never rely on `Disallow` alone to keep something out of the index.** It is a
|
|
773
|
+
crawling instruction, not an indexing one, and Google's own documentation says
|
|
774
|
+
so plainly.
|
|
775
|
+
|
|
776
|
+
### A duplicate path in `_headers` replaces the earlier block, it does not merge
|
|
777
|
+
|
|
778
|
+
**Symptom:** security headers stop being sent. The file still contains them, the
|
|
779
|
+
build still reports the same number of parsed rules, and nothing anywhere says a
|
|
780
|
+
rule was dropped.
|
|
781
|
+
|
|
782
|
+
Adding a second `/*` block to add one header removed `Referrer-Policy`,
|
|
783
|
+
`Permissions-Policy` and the CSP from every response. The later block wins
|
|
784
|
+
outright — `_headers` matches a path to **one** rule set, not to all that match.
|
|
785
|
+
|
|
786
|
+
```
|
|
787
|
+
/*
|
|
788
|
+
X-Robots-Tag: noindex ← this block
|
|
789
|
+
|
|
790
|
+
/*
|
|
791
|
+
Referrer-Policy: … ← silently replaces the one above
|
|
792
|
+
```
|
|
793
|
+
|
|
794
|
+
**Fix:** merge into the existing block for that path, never append a second one.
|
|
795
|
+
|
|
796
|
+
The related one, same file: **an inline `#` is not a comment.** Only a line that
|
|
797
|
+
*starts* with `#` is. A trailing note goes out as part of the header value —
|
|
798
|
+
`X-Robots-Tag: noindex, nofollow, noarchive # staging only` was sent verbatim
|
|
799
|
+
to every crawler, and reading the file is exactly how you fail to notice.
|
|
800
|
+
|
|
801
|
+
Both are invisible in the repo and visible in one command:
|
|
802
|
+
|
|
803
|
+
```bash
|
|
804
|
+
curl -sI https://example.com/ | grep -iE 'referrer|permissions|content-security|x-robots'
|
|
805
|
+
```
|
|
806
|
+
|
|
807
|
+
`npm run verify` checks the three security headers for this reason.
|
|
808
|
+
|
|
809
|
+
### A frontmatter date renders one day early, west of Greenwich
|
|
810
|
+
|
|
811
|
+
**Symptom:** a post dated `2026-08-21` in frontmatter displays as **20 August
|
|
812
|
+
2026** on the built site. Clean build, clean types, correct in the markdown,
|
|
813
|
+
correct on the developer's machine in London, wrong on the one in Los Angeles.
|
|
814
|
+
Nothing anywhere reports it, and the same commit renders differently depending
|
|
815
|
+
on who ran the build.
|
|
816
|
+
|
|
817
|
+
Two steps, each reasonable:
|
|
818
|
+
|
|
819
|
+
1. Astro's frontmatter parser turns an unquoted `2026-08-21` into a **Date**,
|
|
820
|
+
not a string — `2026-08-21T00:00:00.000Z`, midnight **UTC**.
|
|
821
|
+
2. `toLocaleDateString(locale, { … })` with no `timeZone` formats that instant
|
|
822
|
+
in the **build machine's** zone. Anywhere with a negative UTC offset, midnight
|
|
823
|
+
UTC is still the previous evening.
|
|
824
|
+
|
|
825
|
+
Measured: US Pacific and US Eastern both shift it, London and Tokyo do not. This
|
|
826
|
+
kit's provenance is US local-business rebuilds, so the wrong half is the common
|
|
827
|
+
half — and CI runners are frequently US-based regardless of where the client is.
|
|
828
|
+
|
|
829
|
+
**Fix:** pin the zone at the format, `timeZone: 'UTC'`. The locale still decides
|
|
830
|
+
the order and the wording; only the zone is pinned.
|
|
831
|
+
|
|
832
|
+
```js
|
|
833
|
+
date.toLocaleDateString(business.locale, {
|
|
834
|
+
month: 'long', day: 'numeric', year: 'numeric',
|
|
835
|
+
timeZone: 'UTC', // ← without this, the build machine decides
|
|
836
|
+
});
|
|
837
|
+
```
|
|
838
|
+
|
|
839
|
+
Better still where the value is a **calendar date** rather than an instant —
|
|
840
|
+
an effective date, a published date — keep it a string end to end and never let
|
|
841
|
+
a `Date` into the middle. `src/content.config.ts` does this for the legal
|
|
842
|
+
collection: it normalises whatever the parser produced back to `YYYY-MM-DD` at
|
|
843
|
+
the schema boundary, which removes the class rather than handling it.
|
|
844
|
+
|
|
845
|
+
Found while building the legal collection, in this template's own
|
|
846
|
+
`formatDate` — so every site built from it that ran a build in a US timezone
|
|
847
|
+
published every blog date a day early.
|
|
848
|
+
|
|
849
|
+
### Extracted text runs together where the source HTML had no whitespace
|
|
850
|
+
|
|
851
|
+
**Symptom:** on a migrated page, a heading and the paragraph under it are glued
|
|
852
|
+
into one word — `AreasWe cover Irvine.` — or a sentence runs straight into its
|
|
853
|
+
link text or a URL: `Call ustoday`, `see the guidehttps://…`. Build clean, types
|
|
854
|
+
clean, page renders. It reads correctly until you actually read it, and it
|
|
855
|
+
happens on **some** paragraphs and not others, which is what makes it look like
|
|
856
|
+
a content problem rather than a converter one.
|
|
857
|
+
|
|
858
|
+
The cause is one line that appears in every hand-rolled extractor:
|
|
859
|
+
|
|
860
|
+
```js
|
|
861
|
+
html.replace(/<[^>]+>/g, '') // strips tags, joins the text either side
|
|
862
|
+
```
|
|
863
|
+
|
|
864
|
+
Whether it breaks depends entirely on whether the ORIGINAL markup happened to
|
|
865
|
+
have a newline between the tags — and page builders emit minified HTML, so
|
|
866
|
+
often it does not:
|
|
867
|
+
|
|
868
|
+
```
|
|
869
|
+
<p>Book a survey</p>\n<p>Call us</p> → "Book a survey\nCall us" ✓ fine
|
|
870
|
+
<h2>Areas</h2><p>We cover Irvine.</p> → "AreasWe cover Irvine." ✗ glued
|
|
871
|
+
```
|
|
872
|
+
|
|
873
|
+
So the same extractor is correct on the pages whose source was pretty-printed
|
|
874
|
+
and wrong on the pages whose source was not. Collapsing whitespace afterwards
|
|
875
|
+
(`\s+ → ' '`) hides the good case and leaves the bad one untouched.
|
|
876
|
+
|
|
877
|
+
**Fix:** turn block-level *closing* tags into breaks **before** stripping
|
|
878
|
+
anything, and give inline elements a space:
|
|
879
|
+
|
|
880
|
+
```js
|
|
881
|
+
text = html
|
|
882
|
+
.replace(/<\/(p|h[1-6]|li|tr|div|section|article|blockquote)>/gi, '\n\n')
|
|
883
|
+
.replace(/<br\s*\/?>/gi, '\n')
|
|
884
|
+
.replace(/<\/(a|strong|em|span|b|i)>/gi, '$& ') // inline: keep a boundary
|
|
885
|
+
.replace(/<[^>]+>/g, '')
|
|
886
|
+
.replace(/[ \t]+/g, ' ')
|
|
887
|
+
.replace(/\n{3,}/g, '\n\n')
|
|
888
|
+
.trim();
|
|
889
|
+
```
|
|
890
|
+
|
|
891
|
+
Better still, do not hand-roll it: a real HTML-to-markdown converter gets the
|
|
892
|
+
whitespace rules right, and this is the only part of extraction where writing
|
|
893
|
+
your own reliably costs a day of proofreading.
|
|
894
|
+
|
|
895
|
+
**How to catch it after the fact**, since it is invisible to every build gate —
|
|
896
|
+
grep the extracted content for a lower-case letter followed immediately by a
|
|
897
|
+
capital or a scheme:
|
|
898
|
+
|
|
899
|
+
```bash
|
|
900
|
+
grep -rnE '[a-z](https?://|[A-Z][a-z])' src/content/ | grep -vE 'iPhone|YouTube|JavaScript|WordPress'
|
|
901
|
+
```
|
|
902
|
+
|
|
903
|
+
Expect a few false positives from camelCase and brand names; read them.
|