create-website-build-kit 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (91) hide show
  1. package/README.md +54 -0
  2. package/index.mjs +149 -0
  3. package/package.json +42 -0
  4. package/template/.dev.vars.example +3 -0
  5. package/template/.github/workflows/gates.yml +58 -0
  6. package/template/.node-version +1 -0
  7. package/template/.pa11yci.json +24 -0
  8. package/template/BUILD-STATE.md +47 -0
  9. package/template/CLAUDE.md +153 -0
  10. package/template/astro.config.mjs +150 -0
  11. package/template/docs/analytics.md +86 -0
  12. package/template/docs/content.md +138 -0
  13. package/template/docs/handover.md +182 -0
  14. package/template/docs/handover.pdf +0 -0
  15. package/template/docs/runbook.md +661 -0
  16. package/template/docs/traps.md +903 -0
  17. package/template/gitignore +31 -0
  18. package/template/package-lock.json +8159 -0
  19. package/template/package.json +53 -0
  20. package/template/public/_headers +61 -0
  21. package/template/public/_redirects +39 -0
  22. package/template/public/site.webmanifest +13 -0
  23. package/template/scripts/a11y-evidence.mjs +258 -0
  24. package/template/scripts/check-console.mjs +125 -0
  25. package/template/scripts/check-env.mjs +99 -0
  26. package/template/scripts/check-reflow.mjs +148 -0
  27. package/template/scripts/check-sitemap.mjs +113 -0
  28. package/template/scripts/dns-snapshot.mjs +267 -0
  29. package/template/scripts/extract.mjs +317 -0
  30. package/template/scripts/indexnow.mjs +154 -0
  31. package/template/scripts/lastmod.mjs +147 -0
  32. package/template/scripts/lib/inventory.mjs +104 -0
  33. package/template/scripts/lib/preserved.mjs +42 -0
  34. package/template/scripts/lib/routes.mjs +92 -0
  35. package/template/scripts/md-to-pdf.mjs +335 -0
  36. package/template/scripts/og-cards.config.mjs +114 -0
  37. package/template/scripts/og-cards.mjs +487 -0
  38. package/template/scripts/optimize-media.mjs +380 -0
  39. package/template/scripts/recon.mjs +480 -0
  40. package/template/scripts/redirects.mjs +298 -0
  41. package/template/scripts/shots.mjs +447 -0
  42. package/template/scripts/staging-headers.mjs +102 -0
  43. package/template/scripts/tells.mjs +268 -0
  44. package/template/scripts/verify.mjs +1069 -0
  45. package/template/src/components/ContactForm.astro +405 -0
  46. package/template/src/components/CtaBand.astro +82 -0
  47. package/template/src/components/EnvBadge.astro +146 -0
  48. package/template/src/components/Footer.astro +210 -0
  49. package/template/src/components/Header.astro +530 -0
  50. package/template/src/components/Icon.astro +56 -0
  51. package/template/src/components/Img.astro +129 -0
  52. package/template/src/components/PageHero.astro +88 -0
  53. package/template/src/components/Seo.astro +119 -0
  54. package/template/src/components/StructuredData.astro +173 -0
  55. package/template/src/content/blog/.gitkeep +5 -0
  56. package/template/src/content/legal/.gitkeep +0 -0
  57. package/template/src/content.config.ts +81 -0
  58. package/template/src/data/areas.ts +31 -0
  59. package/template/src/data/business.ts +121 -0
  60. package/template/src/data/categories.ts +37 -0
  61. package/template/src/data/fonts.ts +25 -0
  62. package/template/src/data/image-manifest.json +1 -0
  63. package/template/src/data/lastmod.json +1 -0
  64. package/template/src/data/nav.ts +49 -0
  65. package/template/src/data/services.ts +39 -0
  66. package/template/src/data/site.ts +136 -0
  67. package/template/src/env.d.ts +28 -0
  68. package/template/src/layouts/Base.astro +223 -0
  69. package/template/src/lib/brevo.ts +96 -0
  70. package/template/src/lib/hast-media.mjs +55 -0
  71. package/template/src/lib/lastmod.mjs +47 -0
  72. package/template/src/lib/lead.ts +92 -0
  73. package/template/src/lib/legal-routes.mjs +31 -0
  74. package/template/src/lib/legal.ts +75 -0
  75. package/template/src/lib/posts.ts +64 -0
  76. package/template/src/lib/runtime.ts +33 -0
  77. package/template/src/pages/404.astro +51 -0
  78. package/template/src/pages/[slug].astro +111 -0
  79. package/template/src/pages/accessibility.astro +128 -0
  80. package/template/src/pages/api/contact.ts +191 -0
  81. package/template/src/pages/api/leads.csv.ts +82 -0
  82. package/template/src/pages/contact.astro +112 -0
  83. package/template/src/pages/index.astro +84 -0
  84. package/template/src/pages/robots.txt.ts +38 -0
  85. package/template/src/pages/rss.xml.ts +27 -0
  86. package/template/src/styles/global.css +463 -0
  87. package/template/src/styles/project.css +14 -0
  88. package/template/src/styles/prose.css +182 -0
  89. package/template/src/styles/tokens.css +218 -0
  90. package/template/tsconfig.json +5 -0
  91. package/template/wrangler.jsonc +63 -0
@@ -0,0 +1,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.