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,447 @@
1
+ /**
2
+ * Before/after screenshots of a migration, at a mobile and a desktop width.
3
+ *
4
+ * npm run shots -- --before https://old-site.com # the site being replaced
5
+ * npm run shots -- --after https://new.example.com # after cutover, or on staging
6
+ * npm run shots -- --after # PUBLIC_SITE_URL
7
+ * npm run shots -- --after --only=/pricing/ # one route, substring match
8
+ * npm run shots -- --after --limit=80 # default is 40
9
+ *
10
+ * Writes shots/<side>/ as PNGs, a manifest per side, and shots/index.html —
11
+ * the pairs side by side, which is the thing you actually hand over.
12
+ *
13
+ * ── WHY THIS EXISTS ────────────────────────────────────────────────────────
14
+ * `docs/runbook.md` §go-live and `stacks.md` §1d both ask for a visual record
15
+ * of a migration, and nothing in the kit produced one. Six months later a
16
+ * client remembers the old site as better than it was, and there is no defence
17
+ * — plus the case that actually costs money: a page that came out WORSE and
18
+ * nobody noticed at the time, because nobody put the two next to each other.
19
+ *
20
+ * ── BOTH SIDES READ THE SAME ROUTE LIST ────────────────────────────────────
21
+ * `recon/urls.txt` — the inventory of the OLD site, which is the only list that
22
+ * makes a pair a pair. Capturing the new site from its own sitemap would drift:
23
+ * you would photograph the pages you built and never the ones you dropped,
24
+ * which is exactly the failure this is for. A path that 301s is followed and
25
+ * still filed under the OLD path, so the pair lines up.
26
+ *
27
+ * Greenfield builds have no recon output. There, routes come from the sitemap
28
+ * or dist/ and only the `after` side exists — still worth having for handover.
29
+ *
30
+ * ── THE HARNESS IS CHECKED BEFORE THE IMAGE IS BELIEVED ────────────────────
31
+ * `docs/traps.md` — a screenshot run against a flaky server produced dropped
32
+ * stylesheets, nav dropdowns hanging open and images missing, none of it real,
33
+ * and all of it plausible enough to debug for an afternoon. So every capture
34
+ * asserts the cheap thing that proves CSS arrived, records failed requests,
35
+ * freezes transitions, scrolls the page to force lazy images and awaits
36
+ * `img.decode()` before the shutter. A shot that fails those is reported and
37
+ * NOT filed, because a broken screenshot in a handover is worse than none.
38
+ *
39
+ * Captured with `prefers-reduced-motion: reduce` and `prefers-color-scheme:
40
+ * light`, so two runs are comparable. A site with a dark mode needs a second
41
+ * pass by hand; `features.md` covers why one theme's screenshots mislead.
42
+ */
43
+
44
+ import { mkdirSync, readFileSync, writeFileSync } from 'node:fs';
45
+
46
+ import puppeteer from 'puppeteer';
47
+
48
+ import { discoverRoutes } from './lib/routes.mjs';
49
+ import { readInventory } from './lib/inventory.mjs';
50
+
51
+ const RESET = '';
52
+ const RED = '';
53
+ const GREEN = '';
54
+ const YELLOW = '';
55
+ const DIM = '';
56
+ const BOLD = '';
57
+
58
+ const VIEWPORTS = [
59
+ { name: 'mobile', width: 390, height: 844, scale: 2 },
60
+ { name: 'desktop', width: 1440, height: 900, scale: 1 },
61
+ ];
62
+
63
+ const OUT = 'shots';
64
+ const MAX_TEXTURE = 16384;
65
+ const DEFAULT_LIMIT = 40;
66
+
67
+ /* Paths in recon/urls.txt that are not pages. A PDF opens in a viewer and a
68
+ sitemap renders as a wall of XML; neither is a screenshot of anything. */
69
+ const NOT_A_PAGE = /\.(pdf|xml|txt|json|zip|gz|csv|ico|png|jpe?g|gif|webp|avif|svg|mp4|webm|woff2?)$/i;
70
+
71
+ const args = process.argv.slice(2);
72
+ const only = (args.find((a) => a.startsWith('--only=')) ?? '').replace('--only=', '');
73
+ const limit = Number((args.find((a) => a.startsWith('--limit=')) ?? '').replace('--limit=', '')) || DEFAULT_LIMIT;
74
+ const host = args.find((a) => !a.startsWith('--'));
75
+ const side = args.includes('--before') ? 'before' : args.includes('--after') ? 'after' : null;
76
+
77
+ if (!side) {
78
+ console.error(
79
+ 'Say which side this is: --before (the old site) or --after (the new one).\n' +
80
+ ' npm run shots -- --before https://old-site.com\n' +
81
+ ' npm run shots -- --after https://new.example.com',
82
+ );
83
+ process.exit(1);
84
+ }
85
+
86
+ const BASE = (host ?? process.env.PUBLIC_SITE_URL ?? 'http://localhost:8788').replace(/\/$/, '');
87
+
88
+ /*
89
+ * The old site's inventory, when there is one. Comment lines carry the counts
90
+ * recon printed; the paths are everything else.
91
+ */
92
+ function routesFromRecon() {
93
+ /*
94
+ * Parsed by lib/inventory.mjs, not here. Keeping every line starting with `/`
95
+ * was correct only while the file held bare paths: against a real inventory
96
+ * of ABSOLUTE URLs it found zero and this script silently reported the
97
+ * migration as greenfield with no before side — on exactly the migration it
98
+ * exists for, and with nothing anywhere saying so.
99
+ *
100
+ * Already-dead URLs are skipped. There is nothing to photograph on a page
101
+ * that was 404 before the migration began.
102
+ */
103
+ const inventory = readInventory(readFileSync);
104
+ if (!inventory) return null;
105
+ const paths = inventory.live.filter((p) => !NOT_A_PAGE.test(p));
106
+ return paths.length ? paths : null;
107
+ }
108
+
109
+ const fromRecon = routesFromRecon();
110
+ let routes = fromRecon;
111
+ let source = 'recon/urls.txt — the old site, so both sides match';
112
+
113
+ if (!routes) {
114
+ const discovered = await discoverRoutes(BASE);
115
+ routes = discovered.routes.map((u) => new URL(u).pathname);
116
+ source = `${discovered.source}; no recon/urls.txt, so this is a greenfield build with no before side`;
117
+ }
118
+
119
+ const matched = routes.filter((r) => !only || only.split(',').some((f) => r.includes(f.trim())));
120
+
121
+ if (!matched.length) {
122
+ console.error(
123
+ only
124
+ ? `No route matches --only=${only}. ${routes.length} known from ${source}.`
125
+ : `No routes for ${BASE}.\n Run \`npm run recon -- https://old-site.com\` first, or build.`,
126
+ );
127
+ process.exit(1);
128
+ }
129
+
130
+ const ROUTES = matched.slice(0, limit);
131
+ const dropped = matched.length - ROUTES.length;
132
+
133
+ console.log(`${BOLD}── Screenshots: ${side} ${'─'.repeat(Math.max(0, 43 - side.length))}${RESET}`);
134
+ console.log(` ${DIM}${ROUTES.length} route(s) from ${source}${RESET}`);
135
+ console.log(` ${DIM}against ${BASE}, at ${VIEWPORTS.map((v) => `${v.width}px`).join(' and ')}${RESET}`);
136
+ if (dropped) {
137
+ console.log(
138
+ ` ${YELLOW}!${RESET} ${dropped} route(s) beyond --limit=${limit} were NOT captured` +
139
+ `\n ${DIM}raise it, or narrow with --only= — a partial set that looks complete is the problem${RESET}`,
140
+ );
141
+ }
142
+ console.log('');
143
+
144
+ /* One slug per path, and never two paths sharing one. `/a/b/` and `/a-b/`
145
+ flatten to the same string, which would silently overwrite a pair. */
146
+ const taken = new Set();
147
+ const slugFor = (route) => {
148
+ const base = route === '/' ? 'home' : route.replace(/^\/|\/$/g, '').replace(/[^a-z0-9]+/gi, '-').toLowerCase();
149
+ let slug = base || 'home';
150
+ for (let n = 2; taken.has(slug); n++) slug = `${base}-${n}`;
151
+ taken.add(slug);
152
+ return slug;
153
+ };
154
+
155
+ /*
156
+ * Every route gets its slug BEFORE anything is captured. Assigning them inside
157
+ * the viewport loop made the name depend on whether the mobile pass succeeded:
158
+ * one failed capture and the desktop shot of the same page filed itself under
159
+ * `-2`, which unpairs it from the other side without failing anything.
160
+ */
161
+ const SLUGS = new Map(ROUTES.map((route) => [route, slugFor(route)]));
162
+
163
+ const dir = `${OUT}/${side}`;
164
+ mkdirSync(dir, { recursive: true });
165
+
166
+ /* Freeze anything that moves. A carousel mid-slide or a fade half-run makes two
167
+ captures of the same page differ, and then the diff is the harness. */
168
+ const FREEZE = `*, *::before, *::after {
169
+ animation-duration: 0s !important; animation-delay: 0s !important;
170
+ transition-duration: 0s !important; transition-delay: 0s !important;
171
+ caret-color: transparent !important; scroll-behavior: auto !important;
172
+ }`;
173
+
174
+ /* Lazy images load on approach, so a fullPage shot of a page never scrolled
175
+ catches them blank. Walk it, then come back. */
176
+ const SETTLE = async () => {
177
+ await new Promise((resolve) => {
178
+ let y = 0;
179
+ const step = () => {
180
+ window.scrollTo(0, y);
181
+ y += window.innerHeight;
182
+ if (y < document.body.scrollHeight + window.innerHeight) setTimeout(step, 120);
183
+ else {
184
+ window.scrollTo(0, 0);
185
+ setTimeout(resolve, 400);
186
+ }
187
+ };
188
+ step();
189
+ });
190
+ await Promise.all([...document.images].map((img) => img.decode().catch(() => {})));
191
+ return {
192
+ background: getComputedStyle(document.body).backgroundColor,
193
+ height: document.documentElement.scrollHeight,
194
+ };
195
+ };
196
+
197
+ const browser = await puppeteer.launch({ args: ['--no-sandbox'] });
198
+ const captured = [];
199
+ const failures = [];
200
+ const skipped = [];
201
+ const downscaled = [];
202
+
203
+ try {
204
+ for (const viewport of VIEWPORTS) {
205
+ const page = await browser.newPage();
206
+ const vp = {
207
+ width: viewport.width,
208
+ height: viewport.height,
209
+ deviceScaleFactor: viewport.scale,
210
+ isMobile: viewport.name === 'mobile',
211
+ hasTouch: viewport.name === 'mobile',
212
+ };
213
+ await page.setViewport(vp);
214
+ await page.emulateMediaFeatures([
215
+ { name: 'prefers-reduced-motion', value: 'reduce' },
216
+ { name: 'prefers-color-scheme', value: 'light' },
217
+ ]);
218
+
219
+ for (const route of ROUTES) {
220
+ const slug = SLUGS.get(route);
221
+ const file = `${slug}--${viewport.name}.png`;
222
+ const broken = [];
223
+
224
+ const onFailed = (req) => {
225
+ const reason = req.failure()?.errorText ?? '';
226
+ if (!/ERR_ABORTED/.test(reason)) broken.push(`${reason} ${req.url()}`);
227
+ };
228
+ const onResponse = (res) => {
229
+ if (res.status() >= 400) broken.push(`${res.status()} ${res.url()}`);
230
+ };
231
+ page.on('requestfailed', onFailed);
232
+ page.on('response', onResponse);
233
+
234
+ try {
235
+ /* ?nobadge=1 only on our own build — runbook.md §staging badge. On the
236
+ old site it is a cache-busting query string and nothing else. */
237
+ const url = BASE + route + (side === 'after' ? '?nobadge=1' : '');
238
+ const res = await page.goto(url, { waitUntil: 'networkidle0', timeout: 60000 });
239
+ const status = res?.status() ?? 0;
240
+
241
+ /*
242
+ * A 4xx is a fact about the site, not a failed capture. recon lists
243
+ * Wayback paths that were ALREADY dead on the old site, so failing the
244
+ * run on those makes a before-pass go red on a migration where nothing
245
+ * is wrong — and a check that goes red for a non-reason gets switched
246
+ * off. Reported, never filed, never the exit code.
247
+ */
248
+ if (status >= 400) {
249
+ skipped.push({ route, viewport: viewport.name, status });
250
+ console.log(` ${YELLOW}–${RESET} ${viewport.name.padEnd(7)} ${route} ${DIM}HTTP ${status}${RESET}`);
251
+ page.off('requestfailed', onFailed);
252
+ page.off('response', onResponse);
253
+ continue;
254
+ }
255
+
256
+ const state = await page.evaluate(SETTLE);
257
+
258
+ /*
259
+ * The cheap assertion that proves the stylesheet arrived. A transparent
260
+ * body means the page rendered unstyled, and an unstyled screenshot
261
+ * filed as evidence is how a harness problem becomes a design problem.
262
+ */
263
+ if (state.background === 'rgba(0, 0, 0, 0)') {
264
+ throw new Error('body has no background — stylesheet did not arrive');
265
+ }
266
+
267
+ /*
268
+ * Chrome cannot rasterise past ~16384 device pixels. Past it a fullPage
269
+ * capture comes back CUT OFF or blank, with no error and a plausible
270
+ * file size — a long page silently missing its footer. Drop to 1x for
271
+ * that page rather than filing a truncated one, and say so.
272
+ */
273
+ let scale = viewport.scale;
274
+ if (state.height * scale > MAX_TEXTURE) {
275
+ scale = 1;
276
+ downscaled.push(`${route} (${viewport.name}, ${state.height}px)`);
277
+ await page.setViewport({ ...vp, deviceScaleFactor: 1 });
278
+ }
279
+
280
+ await page.screenshot({ path: `${dir}/${file}`, fullPage: true });
281
+ if (scale !== viewport.scale) await page.setViewport(vp);
282
+ captured.push({ route, slug, viewport: viewport.name, file, status, height: state.height, scale, broken });
283
+
284
+ const note = broken.length ? ` ${YELLOW}${broken.length} failed request(s)${RESET}` : '';
285
+ console.log(` ${GREEN}✓${RESET} ${viewport.name.padEnd(7)} ${route}${note}`);
286
+ } catch (e) {
287
+ failures.push({ route, viewport: viewport.name, reason: e.message });
288
+ console.log(` ${RED}✗${RESET} ${viewport.name.padEnd(7)} ${route} ${DIM}${e.message}${RESET}`);
289
+ }
290
+
291
+ page.off('requestfailed', onFailed);
292
+ page.off('response', onResponse);
293
+ }
294
+
295
+ await page.close();
296
+ }
297
+ } finally {
298
+ await browser.close();
299
+ }
300
+
301
+ writeFileSync(
302
+ `${dir}/manifest.json`,
303
+ JSON.stringify(
304
+ {
305
+ side,
306
+ origin: BASE,
307
+ capturedAt: new Date().toISOString(),
308
+ routeSource: source,
309
+ viewports: VIEWPORTS,
310
+ captured,
311
+ failures,
312
+ skipped,
313
+ notCaptured: dropped,
314
+ },
315
+ null,
316
+ 2,
317
+ ) + '\n',
318
+ );
319
+
320
+ writeSheet();
321
+
322
+ if (skipped.length) {
323
+ const routes = [...new Set(skipped.map((s) => `${s.route} (${s.status})`))];
324
+ console.log(`\n ${YELLOW}!${RESET} ${routes.length} route(s) did not respond with a page:`);
325
+ for (const r of routes) console.log(` ${DIM}${r}${RESET}`);
326
+ console.log(
327
+ side === 'after'
328
+ ? ` ${DIM}On the new site that means the URL did not survive. \`npm run verify\` is the gate for it.${RESET}`
329
+ : ` ${DIM}Expected on a before pass — recon lists paths that were already dead. They still hold backlinks.${RESET}`,
330
+ );
331
+ }
332
+
333
+ if (downscaled.length) {
334
+ console.log(
335
+ `\n ${YELLOW}!${RESET} ${downscaled.length} page(s) too tall for a 2x capture, taken at 1x:` +
336
+ downscaled.map((d) => `\n ${DIM}${d}${RESET}`).join(''),
337
+ );
338
+ }
339
+
340
+ const withBroken = captured.filter((c) => c.broken.length).length;
341
+ if (withBroken) {
342
+ const urls = [...new Set(captured.flatMap((c) => c.broken))].slice(0, 5);
343
+ console.log(`\n ${YELLOW}!${RESET} ${withBroken} capture(s) had failed requests — the image may be missing an asset:`);
344
+ for (const u of urls) {
345
+ /* Browsers request /favicon.ico unprompted when no icon is declared. Say
346
+ what it means rather than leaving someone hunting for the reference. */
347
+ const hint = /\/favicon\.ico$/.test(u) ? ' ← the browser asks for this by itself' : '';
348
+ console.log(` ${DIM}${u}${hint}${RESET}`);
349
+ }
350
+ console.log(` ${DIM}\`npm run console\` reports all of them.${RESET}`);
351
+ }
352
+
353
+ console.log(
354
+ failures.length
355
+ ? `\n${RED}✗ ${failures.length} capture(s) failed${RESET} — ${captured.length} written to ${dir}/\n`
356
+ : `\n${GREEN}✓ ${captured.length} screenshot(s) → ${dir}/${RESET}\n ${DIM}pairs: ${OUT}/index.html${RESET}\n`,
357
+ );
358
+
359
+ process.exit(failures.length ? 1 : 0);
360
+
361
+ /*
362
+ * The contact sheet. Reads whichever manifests exist, so running one side
363
+ * produces a usable page and running the second fills in the other column.
364
+ *
365
+ * Deliberately plain: this is a working artefact, not a page the site ships,
366
+ * and a styled one would be a design decision living in the template.
367
+ */
368
+ function writeSheet() {
369
+ const load = (s) => {
370
+ try {
371
+ return JSON.parse(readFileSync(`${OUT}/${s}/manifest.json`, 'utf8'));
372
+ } catch {
373
+ return null;
374
+ }
375
+ };
376
+
377
+ const before = load('before');
378
+ const after = load('after');
379
+ const sides = [before, after].filter(Boolean);
380
+ /* Skipped routes belong in the sheet. A page that 404s on the new site is
381
+ the single most important row there is, and leaving it out because nothing
382
+ was captured would hide exactly what the sheet is for. */
383
+ const routes = [
384
+ ...new Set(sides.flatMap((m) => [...m.captured, ...(m.skipped ?? [])].map((c) => c.route))),
385
+ ].sort();
386
+
387
+ const cell = (manifest, side, route, viewport) => {
388
+ const shot = manifest?.captured.find((c) => c.route === route && c.viewport === viewport);
389
+ if (shot) return `<a href="${side}/${shot.file}"><img src="${side}/${shot.file}" alt="${side}, ${route}, ${viewport}"></a>`;
390
+ const gone = manifest?.skipped?.find((f) => f.route === route && f.viewport === viewport);
391
+ if (gone) return `<p class="none">HTTP ${gone.status} — no page here</p>`;
392
+ const failed = manifest?.failures.find((f) => f.route === route && f.viewport === viewport);
393
+ return `<p class="none">${failed ? `not captured — ${failed.reason}` : 'not captured'}</p>`;
394
+ };
395
+
396
+ const rows = routes
397
+ .map((route) => {
398
+ const shots = VIEWPORTS.map(
399
+ (v) => `<div class="pair" style="--shot: ${v.width}px">
400
+ <h3>${v.name} · ${v.width}px</h3>
401
+ <div class="two">
402
+ <figure><figcaption>before</figcaption>${cell(before, 'before', route, v.name)}</figure>
403
+ <figure><figcaption>after</figcaption>${cell(after, 'after', route, v.name)}</figure>
404
+ </div>
405
+ </div>`,
406
+ ).join('\n');
407
+ return `<section><h2><code>${route}</code></h2>${shots}</section>`;
408
+ })
409
+ .join('\n');
410
+
411
+ const origins = sides
412
+ .map((m) => `${m.side}: ${m.origin} — ${m.capturedAt.slice(0, 10)}, ${m.captured.length} shot(s)`)
413
+ .join('<br>');
414
+
415
+ writeFileSync(
416
+ `${OUT}/index.html`,
417
+ `<!doctype html>
418
+ <html lang="en"><head><meta charset="utf-8">
419
+ <meta name="viewport" content="width=device-width,initial-scale=1">
420
+ <meta name="robots" content="noindex">
421
+ <title>Before / after</title>
422
+ <style>
423
+ body { font: 15px/1.5 system-ui, sans-serif; margin: 0 auto; padding: 2rem 1rem; max-width: 80rem; color: #1a1a1a; background: #fff; }
424
+ header p { color: #555; }
425
+ section { border-top: 1px solid #ddd; padding-top: 1.5rem; margin-top: 2.5rem; }
426
+ h2 { font-size: 1rem; }
427
+ h3 { font-size: .8rem; text-transform: uppercase; letter-spacing: .05em; color: #666; font-weight: 600; }
428
+ .two { display: grid; grid-template-columns: 1fr 1fr; gap: 1rem; align-items: start; }
429
+ figure { margin: 0; }
430
+ figcaption { font-size: .75rem; color: #666; margin-bottom: .35rem; }
431
+ /* Cap each shot at the width it was taken. A 390px capture stretched across
432
+ a 600px column stops reading as a phone, which is half of what the pair is
433
+ for. */
434
+ img { width: 100%; max-width: var(--shot); height: auto; border: 1px solid #ddd; display: block; }
435
+ .none { border: 1px dashed #ccc; color: #888; padding: 2rem 1rem; text-align: center; font-size: .8rem; margin: 0; }
436
+ @media (max-width: 40rem) { .two { grid-template-columns: 1fr; } }
437
+ </style></head><body>
438
+ <header>
439
+ <h1>Before / after</h1>
440
+ <p>${origins || 'nothing captured yet'}</p>
441
+ <p>Full-page, animations frozen, captured in light mode with reduced motion. Click any shot for the full-size PNG.</p>
442
+ </header>
443
+ ${rows}
444
+ </body></html>
445
+ `,
446
+ );
447
+ }
@@ -0,0 +1,102 @@
1
+ /**
2
+ * Add `X-Robots-Tag: noindex` to every response on a NON-PRODUCTION build.
3
+ *
4
+ * Runs inside `build:staging`, after the adapter has written `_headers`.
5
+ *
6
+ * ── WHY A HEADER AND NOT JUST THE META TAG ─────────────────────────────────
7
+ * `<meta name="robots">` lives in `<head>`. A PDF has no head. Nor does an
8
+ * image, a CSV export, an `.ics` file or anything else the site serves that is
9
+ * not HTML — so on staging every one of those was indexable while the pages
10
+ * around them were not. The header is the only one of the three controls that
11
+ * covers a whole response regardless of its type.
12
+ *
13
+ * ── IT DOES NOT REPLACE ACCESS CONTROL ─────────────────────────────────────
14
+ * A crawler still has to FETCH the response to read a header, exactly as it has
15
+ * to fetch a page to read the meta tag. So this closes the non-HTML gap and
16
+ * changes nothing about the larger point: `Disallow: /` stops the fetch, which
17
+ * stops both. The only thing that reliably keeps staging out of an index is not
18
+ * letting anyone reach it — Cloudflare Access. See docs/runbook.md §1.
19
+ *
20
+ * ── WHY IT REFUSES ON PRODUCTION ───────────────────────────────────────────
21
+ * Writing `noindex` into a production deploy is the single most expensive
22
+ * mistake this kit can make, and it is silent: the site builds, deploys, looks
23
+ * perfect, and quietly leaves the index over the following weeks. So this reads
24
+ * the environment itself rather than trusting the script it is called from.
25
+ */
26
+
27
+ import { existsSync, readFileSync, writeFileSync } from 'node:fs';
28
+
29
+ const RESET = '[0m';
30
+ const RED = '[31m';
31
+ const GREEN = '[32m';
32
+ const DIM = '[2m';
33
+
34
+ const env = process.env.PUBLIC_SITE_ENV ?? 'development';
35
+
36
+ if (env === 'production') {
37
+ console.error(
38
+ `${RED}✗${RESET} refusing to write noindex headers into a PRODUCTION build.\n` +
39
+ ' This script is for staging only. Nothing was written.',
40
+ );
41
+ process.exit(1);
42
+ }
43
+
44
+ const file = ['dist/client/_headers', 'dist/_headers'].find(existsSync);
45
+
46
+ if (!file) {
47
+ console.error(
48
+ `${RED}✗${RESET} no _headers in dist/. Run this after \`astro build\`, not before —\n` +
49
+ ' the Cloudflare adapter writes that file during the build.',
50
+ );
51
+ process.exit(1);
52
+ }
53
+
54
+ if (readFileSync(file, 'utf8').includes('X-Robots-Tag')) {
55
+ console.log(`${DIM}· X-Robots-Tag already present in ${file}${RESET}`);
56
+ process.exit(0);
57
+ }
58
+
59
+ /*
60
+ * ⚠ MERGED INTO THE EXISTING `/*` BLOCK, NEVER APPENDED AS A SECOND ONE.
61
+ *
62
+ * A duplicate path in `_headers` does not combine — the later block REPLACES
63
+ * the earlier. Appending `/*` with only X-Robots-Tag therefore silently dropped
64
+ * Referrer-Policy, Permissions-Policy and the CSP from every response, while
65
+ * the build still reported "Parsed 5 valid header rules" and the file still
66
+ * visibly contained all of them.
67
+ *
68
+ * `npm run verify` caught it: three header checks that had been green went red.
69
+ * Nothing else would have.
70
+ *
71
+ * noarchive as well as noindex, so a cached copy is not shown even where the
72
+ * URL is already known; nofollow so a crawler that does reach staging does not
73
+ * walk it and find the rest.
74
+ */
75
+ const lines = readFileSync(file, 'utf8').split('\n');
76
+ const at = lines.findIndex((l) => l.trim() === '/*');
77
+
78
+ if (at === -1) {
79
+ console.error(
80
+ `${RED}✗${RESET} no \`/*\` block in ${file} to merge into.\n` +
81
+ ' public/_headers should carry the security headers under `/*`.',
82
+ );
83
+ process.exit(1);
84
+ }
85
+
86
+ /* The note goes on its own line. `_headers` does not strip an inline `#`, so a
87
+ trailing comment is sent as part of the header VALUE — the first version of
88
+ this shipped `noindex, nofollow, noarchive # staging only …` to every
89
+ crawler. Only visible by reading the response, never by reading the file. */
90
+ lines.splice(
91
+ at + 1,
92
+ 0,
93
+ ' # staging only — added by scripts/staging-headers.mjs',
94
+ ' X-Robots-Tag: noindex, nofollow, noarchive',
95
+ );
96
+ writeFileSync(file, lines.join('\n'));
97
+
98
+ console.log(`${GREEN}✓${RESET} X-Robots-Tag: noindex on every response (${env} build)`);
99
+ console.log(
100
+ `${DIM} Covers non-HTML, which the meta tag cannot. It is not access control —\n` +
101
+ ` a crawler still has to fetch the response to read it. See runbook.md §1.${RESET}`,
102
+ );