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,1069 @@
1
+ /**
2
+ * Verify a DEPLOYED site. Exits non-zero if any check fails.
3
+ *
4
+ * npm run verify -- https://new.example.com # staging, after deploy
5
+ * npm run verify -- https://example.com # production, after cutover
6
+ *
7
+ * ── WHY THIS EXISTS ────────────────────────────────────────────────────────
8
+ * A green build proves the bundler ran. Every regression this kit documents
9
+ * was green: the environment mismatch, the sitemap/noindex pair, the manifest
10
+ * one generator deleted, the fallback card that went stale, the honeypot
11
+ * landing on the conversion URL. All of them are visible from outside, over
12
+ * HTTP, in about twenty seconds.
13
+ *
14
+ * docs/runbook.md §2 has carried these as curl commands since the beginning,
15
+ * with the instruction "every row, every time — the rows people skip are the
16
+ * rows that fail". That instruction is an admission. Go-live is the moment
17
+ * someone is tired, and a checklist run by hand at 11pm is not a check.
18
+ *
19
+ * ── NOTHING HERE CREATES DATA ──────────────────────────────────────────────
20
+ * The form checks are deliberately limited to the three submissions the API
21
+ * REFUSES: a honeypot hit (accepted, never stored), an empty body (422) and a
22
+ * cross-origin post (403). A valid submission would write a real lead and send
23
+ * a real notification, so this script never sends one. Test that by hand, once,
24
+ * and watch it arrive.
25
+ *
26
+ * ── WHAT IT CANNOT SEE ─────────────────────────────────────────────────────
27
+ * Printed at the end, deliberately. A check that stays silent about its blind
28
+ * spots reads as "everything is fine" when it means "everything I looked at".
29
+ */
30
+
31
+ import { readFileSync, existsSync } from 'node:fs';
32
+
33
+ import { discoverRoutes } from './lib/routes.mjs';
34
+ import { PRESERVED, preservedFromRecon } from './lib/preserved.mjs';
35
+ import { readInventory } from './lib/inventory.mjs';
36
+
37
+ const RESET = '';
38
+ const RED = '';
39
+ const GREEN = '';
40
+ const YELLOW = '';
41
+ const DIM = '';
42
+ const BOLD = '';
43
+
44
+ const origin = (process.argv[2] ?? '').replace(/\/$/, '');
45
+ if (!origin || !/^https?:\/\//.test(origin)) {
46
+ console.error(
47
+ 'usage: npm run verify -- https://example.com\n' +
48
+ ' node scripts/verify.mjs http://localhost:8788',
49
+ );
50
+ process.exit(1);
51
+ }
52
+
53
+ const results = [];
54
+ const notes = [];
55
+
56
+ function record(name, ok, detail = '', { warn = false } = {}) {
57
+ results.push({ name, ok, detail, warn });
58
+ const mark = ok ? `${GREEN}✓${RESET}` : warn ? `${YELLOW}!${RESET}` : `${RED}✗${RESET}`;
59
+ console.log(` ${mark} ${name}${detail ? `\n ${DIM}${detail}${RESET}` : ''}`);
60
+ }
61
+
62
+ const TIMEOUT = 15000;
63
+
64
+ async function req(path, options = {}) {
65
+ const url = path.startsWith('http') ? path : `${origin}${path}`;
66
+ const controller = new AbortController();
67
+ const timer = setTimeout(() => controller.abort(), TIMEOUT);
68
+ try {
69
+ return await fetch(url, { redirect: 'manual', signal: controller.signal, ...options });
70
+ } catch (error) {
71
+ return { status: 0, headers: new Headers(), error: String(error), text: async () => '' };
72
+ } finally {
73
+ clearTimeout(timer);
74
+ }
75
+ }
76
+
77
+ /** Bounded concurrency — a verification run should not look like an attack. */
78
+ async function pool(items, worker, limit = 8) {
79
+ const out = [];
80
+ let i = 0;
81
+ await Promise.all(
82
+ Array.from({ length: Math.min(limit, items.length) }, async () => {
83
+ while (i < items.length) {
84
+ const index = i++;
85
+ out[index] = await worker(items[index], index);
86
+ }
87
+ }),
88
+ );
89
+ return out;
90
+ }
91
+
92
+ const section = (title) => console.log(`\n${BOLD}── ${title} ${'─'.repeat(Math.max(0, 58 - title.length))}${RESET}`);
93
+
94
+ /* ── Which environment is this? ────────────────────────────────────────────
95
+ *
96
+ * Asked of the SITE, not of a local variable. The whole point is to compare
97
+ * what was deployed against what was intended, so reading our own build
98
+ * environment here would compare a value to itself.
99
+ */
100
+ section('Environment');
101
+
102
+ const homeRes = await req('/');
103
+ const home = homeRes.status ? await homeRes.text() : '';
104
+
105
+ if (!homeRes.status) {
106
+ record('origin reachable', false, homeRes.error ?? 'no response');
107
+ console.error(`\n${RED}Cannot reach ${origin}. Nothing else can be checked.${RESET}\n`);
108
+ process.exit(1);
109
+ }
110
+ record('origin reachable', homeRes.status === 200, `GET / → ${homeRes.status}`);
111
+
112
+ const hasNoindex = /<meta[^>]+name=["']robots["'][^>]+noindex/i.test(home);
113
+ const env = hasNoindex ? 'staging' : 'production';
114
+ console.log(` ${DIM}reading as ${BOLD}${env}${RESET}${DIM} (noindex ${hasNoindex ? 'present' : 'absent'} on /)${RESET}`);
115
+
116
+ const analyticsRefs = (home.match(/googletagmanager\.com|gtag\(|GTM-[A-Z0-9]+|G-[A-Z0-9]{8,}/g) ?? []).length;
117
+ if (env === 'staging') {
118
+ record('staging carries zero analytics references', analyticsRefs === 0,
119
+ analyticsRefs ? `found ${analyticsRefs} reference(s) — staging must emit no tag at all` : '');
120
+ } else {
121
+ notes.push(`analytics references on production: ${analyticsRefs} (0 is valid — IDs may be unset)`);
122
+ }
123
+
124
+ const robotsRes = await req('/robots.txt');
125
+ const robots = robotsRes.status === 200 ? await robotsRes.text() : '';
126
+ if (env === 'staging') {
127
+ const disallowAll = /^\s*Disallow:\s*\/\s*$/m.test(robots);
128
+ record('staging robots.txt is disallow-all', disallowAll,
129
+ robots ? '' : `GET /robots.txt → ${robotsRes.status}`);
130
+
131
+ /*
132
+ * Disallow-all AND noindex is self-defeating: a blocked crawler never fetches
133
+ * the page, so it never reads the noindex, and a linked staging URL can still
134
+ * be indexed as a bare URL. Behind Cloudflare Access this does not matter,
135
+ * which is why it is a note and not a failure — verify cannot tell whether
136
+ * Access is in front of it, because it is being run from inside.
137
+ */
138
+ if (disallowAll && hasNoindex) {
139
+ notes.push(
140
+ 'staging serves BOTH Disallow: / and noindex — the crawler never reads the noindex. ' +
141
+ 'Fine behind Cloudflare Access, a real gap without it. See docs/traps.md',
142
+ );
143
+ }
144
+ } else {
145
+ record('production robots.txt names a sitemap', /^\s*Sitemap:\s*http/im.test(robots),
146
+ 'nothing else discovers the sitemap without it');
147
+ }
148
+
149
+ /* A local preview serves a build made for another host, so the canonical
150
+ SHOULD differ and comparing them proves nothing. Warn rather than fail —
151
+ silently skipping would hide the check that catches a staging build shipped
152
+ to the production domain, which is the whole reason it exists. */
153
+ const isLocal = /^(localhost|127\.0\.0\.1|\[::1\])/.test(new URL(origin).host);
154
+ const canonical = /<link[^>]+rel=["']canonical["'][^>]+href=["']([^"']+)/i.exec(home)?.[1];
155
+ if (canonical) {
156
+ const canonicalHost = new URL(canonical).host;
157
+ const matches = canonicalHost === new URL(origin).host;
158
+ record('canonical points at this host', matches,
159
+ isLocal
160
+ ? `canonical ${canonicalHost} — expected on a local preview of a remote build; re-run against the deployed host`
161
+ : `canonical ${canonicalHost}, requested ${new URL(origin).host}` +
162
+ (matches ? '' : ' — a staging build on the production domain looks exactly like this'),
163
+ { warn: isLocal });
164
+ }
165
+
166
+ /* ── Routes ────────────────────────────────────────────────────────────────
167
+ *
168
+ * From the deployed sitemap where there is one. Staging emits none by design,
169
+ * so fall back to the routes this build actually emitted.
170
+ */
171
+ section('Routes');
172
+
173
+ const smIndex = await req('/sitemap-index.xml');
174
+ const { routes: discoveredRoutes, source: routeSource } = await discoverRoutes(origin, (url) =>
175
+ req(url),
176
+ );
177
+
178
+ /*
179
+ * Re-root every discovered route onto the origin under test.
180
+ *
181
+ * A sitemap holds ABSOLUTE URLs at the host the build was made for — it has to,
182
+ * that is what a crawler consumes. Fetching them literally means a local
183
+ * preview of a production build verifies example.com instead of localhost, and
184
+ * the route check passes because example.com really does return 200. It reports
185
+ * green having tested somebody else's website.
186
+ *
187
+ * Same correction as the canonical and og:image checks: no-op when the origins
188
+ * already match, which is every real run against a deployed site.
189
+ */
190
+ const routes = discoveredRoutes.map((url) => {
191
+ try {
192
+ const u = new URL(url);
193
+ return u.origin === new URL(origin).origin ? url : new URL(u.pathname + u.search, origin).href;
194
+ } catch {
195
+ return url;
196
+ }
197
+ });
198
+
199
+ if (routes.some((r, i) => r !== discoveredRoutes[i])) {
200
+ notes.push(
201
+ `routes came from a sitemap written for another host and were re-rooted onto ${origin} — ` +
202
+ 'against the real deployed host they are checked exactly as published',
203
+ );
204
+ }
205
+
206
+ if (!routes.length) {
207
+ record('routes discovered', false, 'no sitemap and no dist/ — build first, or check the origin');
208
+ } else {
209
+ console.log(` ${DIM}${routes.length} route(s) from ${routeSource}${RESET}`);
210
+ const statuses = await pool(routes, async (url) => ({ url, status: (await req(url)).status }));
211
+ const bad = statuses.filter((r) => r.status !== 200);
212
+ record('every route returns 200', bad.length === 0,
213
+ bad.map((b) => `${b.status || 'ERR'} ${b.url}`).join('\n '));
214
+ }
215
+
216
+ const missing = await req(`/definitely-not-a-real-page-${Date.now().toString(36)}/`);
217
+ record('unknown path returns a real 404', missing.status === 404,
218
+ `got ${missing.status}` + (missing.status === 200 ? ' — a pretty 404 page served as 200 is indexable' : ''));
219
+
220
+ /* ── Links inside the pages ────────────────────────────────────────────────
221
+ *
222
+ * Everything above checks URLs somebody ELSE points at — the sitemap, the
223
+ * redirect map, the inventory. None of it follows a link a visitor can
224
+ * actually click.
225
+ *
226
+ * That is the gap a rebuild falls into. Routes all return 200, redirects all
227
+ * resolve, and a nav change three weeks ago left `/pricing/` linked from three
228
+ * pages and existing on none. Nothing in a build, a sitemap or a status sweep
229
+ * sees it, because the broken thing is the href, not the route.
230
+ */
231
+ section('Links');
232
+
233
+ const LINK_PAGE_CAP = 150;
234
+ const pagesToScan = routes.slice(0, LINK_PAGE_CAP);
235
+ if (routes.length > LINK_PAGE_CAP) {
236
+ notes.push(
237
+ `link check read the first ${LINK_PAGE_CAP} of ${routes.length} pages — raise LINK_PAGE_CAP for a full sweep`,
238
+ );
239
+ }
240
+
241
+ /** target URL → the pages that link to it. Deduped, so each target is fetched once. */
242
+ const targets = new Map();
243
+ const ogImages = new Map();
244
+ /* Collected in the same pass — the HTML is already in memory, so the whole
245
+ meta sweep below costs one regex per page rather than a second crawl. */
246
+ const meta = new Map();
247
+ /* Weight and render-blocking, from the same HTML. See the Weight section. */
248
+ const perf = new Map();
249
+ const assetUrls = new Set();
250
+ let scanned = 0;
251
+
252
+ await pool(pagesToScan, async (pageUrl) => {
253
+ const res = await req(pageUrl);
254
+ if (res.status !== 200) return;
255
+ const html = await res.text();
256
+ scanned++;
257
+
258
+ const add = (raw) => {
259
+ if (!raw) return;
260
+ const href = raw.trim();
261
+ /* Not links to a page: protocol handlers, data URIs, and pure fragments. */
262
+ if (/^(mailto:|tel:|sms:|javascript:|data:|#)/i.test(href)) return;
263
+ let abs;
264
+ try {
265
+ abs = new URL(href, pageUrl);
266
+ } catch {
267
+ return;
268
+ }
269
+ if (abs.origin !== new URL(origin).origin) return; // external is not ours to fix
270
+ abs.hash = '';
271
+ const key = abs.href;
272
+ if (!targets.has(key)) targets.set(key, new Set());
273
+ targets.get(key).add(new URL(pageUrl).pathname);
274
+ };
275
+
276
+ for (const m of html.matchAll(/<a\b[^>]*\shref=["']([^"']+)["']/gi)) add(m[1]);
277
+ for (const m of html.matchAll(/<img\b[^>]*\ssrc=["']([^"']+)["']/gi)) add(m[1]);
278
+
279
+ const og = /<meta[^>]+property=["']og:image["'][^>]+content=["']([^"']+)["']/i.exec(html)?.[1];
280
+ if (og) ogImages.set(new URL(pageUrl).pathname, og);
281
+
282
+ /*
283
+ * Render-blocking, counted from the markup rather than from a browser.
284
+ * `build.md` §2 is explicit that a hand-rolled PerformanceObserver against
285
+ * one machine produces a confident number that disagrees with Lighthouse and
286
+ * nothing tells you it is wrong. So nothing here is a timing: these are
287
+ * counts and byte totals, which are the same on every machine and every
288
+ * connection, and Lighthouse remains the tool for how fast it feels.
289
+ */
290
+ const head = /<head\b[^>]*>([\s\S]*?)<\/head>/i.exec(html)?.[1] ?? '';
291
+
292
+ const blockingStyles = [...head.matchAll(/<link\b[^>]*>/gi)]
293
+ .filter((m) => /rel=["']stylesheet["']/i.test(m[0]))
294
+ /* media="print" and a non-matching media query do not block render. */
295
+ .filter((m) => {
296
+ const media = /\smedia=["']([^"']+)["']/i.exec(m[0])?.[1];
297
+ return !media || /^(all|screen)$/i.test(media.trim());
298
+ })
299
+ .map((m) => /\shref=["']([^"']+)["']/i.exec(m[0])?.[1])
300
+ .filter(Boolean);
301
+
302
+ /* A classic <script src> in <head> blocks the parser. `defer`, `async` and
303
+ type="module" (deferred by definition) do not. */
304
+ const blockingScripts = [...head.matchAll(/<script\b[^>]*\ssrc=["']([^"']+)["'][^>]*>/gi)]
305
+ .filter((m) => !/\s(defer|async)[\s>=]/i.test(m[0]) && !/type=["']module["']/i.test(m[0]))
306
+ .map((m) => m[1]);
307
+
308
+ const images = [...html.matchAll(/<img\b[^>]*>/gi)].map((m) => ({
309
+ src: /\ssrc=["']([^"']+)["']/i.exec(m[0])?.[1] ?? '',
310
+ lazy: /\sloading=["']lazy["']/i.test(m[0]),
311
+ })).filter((i) => i.src);
312
+
313
+ const sub = [...blockingStyles, ...blockingScripts, ...images.map((i) => i.src)];
314
+ for (const raw of sub) {
315
+ try {
316
+ const abs = new URL(raw, pageUrl);
317
+ if (abs.origin === new URL(origin).origin) assetUrls.add(abs.href);
318
+ } catch {
319
+ /* an unparseable src is the link check's finding, not this one's */
320
+ }
321
+ }
322
+
323
+ perf.set(new URL(pageUrl).pathname, {
324
+ htmlBytes: Buffer.byteLength(html),
325
+ blockingStyles,
326
+ blockingScripts,
327
+ images,
328
+ });
329
+
330
+ meta.set(new URL(pageUrl).pathname, {
331
+ title: /<title[^>]*>([^<]*)<\/title>/i.exec(html)?.[1]?.trim() ?? '',
332
+ description:
333
+ /<meta[^>]+name=["']description["'][^>]+content=["']([^"']*)["']/i.exec(html)?.[1]?.trim() ?? '',
334
+ canonical: /<link[^>]+rel=["']canonical["'][^>]+href=["']([^"']+)["']/i.exec(html)?.[1] ?? '',
335
+ h1Count: (html.match(/<h1[\s>]/gi) ?? []).length,
336
+ noindex: /<meta[^>]+name=["']robots["'][^>]+content=["'][^"']*\bnoindex\b/i.test(html),
337
+ });
338
+ });
339
+
340
+ console.log(` ${DIM}${targets.size} unique internal target(s) across ${scanned} page(s)${RESET}`);
341
+
342
+ const linkResults = await pool([...targets.keys()], async (url) => {
343
+ const res = await req(url, { method: 'HEAD' });
344
+ /* Some hosts refuse HEAD on dynamic routes; confirm with GET before calling
345
+ it broken, or the report is full of pages that work perfectly in a browser. */
346
+ if (res.status === 405 || res.status === 501 || res.status === 0) {
347
+ const get = await req(url);
348
+ return { url, status: get.status, location: get.headers.get('location') ?? '' };
349
+ }
350
+ return { url, status: res.status, location: res.headers.get('location') ?? '' };
351
+ });
352
+
353
+ const sourcesOf = (url) => [...(targets.get(url) ?? [])].sort();
354
+
355
+ const dead = linkResults.filter((r) => r.status >= 400 || r.status === 0);
356
+ record(
357
+ 'every internal link resolves',
358
+ dead.length === 0,
359
+ dead
360
+ .map(
361
+ (r) =>
362
+ `${String(r.status || 'ERR').padEnd(3)} ${new URL(r.url).pathname}\n` +
363
+ ` linked from: ${sourcesOf(r.url).join(', ')}`,
364
+ )
365
+ .join('\n '),
366
+ );
367
+
368
+ /*
369
+ * An internal link that redirects is not broken, but it is a stale href: it
370
+ * costs every visitor a round trip and it is usually the last trace of a route
371
+ * that moved. Cheap to fix while you know why it happened.
372
+ */
373
+ const hops = linkResults.filter((r) => r.status >= 300 && r.status < 400);
374
+ record(
375
+ 'no internal link goes through a redirect',
376
+ hops.length === 0,
377
+ hops
378
+ .map(
379
+ (r) =>
380
+ `${new URL(r.url).pathname} → ${r.location}\n` +
381
+ ` linked from: ${sourcesOf(r.url).join(', ')}`,
382
+ )
383
+ .join('\n '),
384
+ { warn: true },
385
+ );
386
+
387
+ /*
388
+ * og:image is the one asset nobody opens. A card pointing at a moved file
389
+ * unfurls blank in every share, and the page itself is perfect.
390
+ */
391
+ if (ogImages.size) {
392
+ /*
393
+ * og:image is emitted ABSOLUTE, against the host the build was made for —
394
+ * that is required, since a scraper has no base URL to resolve against. So
395
+ * on a local preview of a staging build it points at the staging host, and
396
+ * fetching it literally reports every card as broken.
397
+ *
398
+ * Re-root it on the origin under test, but only when it is the site's own
399
+ * host (it matches the canonical). A card genuinely served from a CDN keeps
400
+ * its own URL, because that one really does need to resolve where it says.
401
+ */
402
+ const canonicalOrigin = canonical ? new URL(canonical).origin : null;
403
+ const cards = await pool([...new Set(ogImages.values())], async (src) => {
404
+ const declared = new URL(src, origin);
405
+ const ownHost = canonicalOrigin && declared.origin === canonicalOrigin;
406
+ const tested = ownHost ? new URL(declared.pathname + declared.search, origin) : declared;
407
+ const status = (await req(tested.href, { method: 'HEAD' })).status;
408
+ return { src, tested: tested.href, rerooted: ownHost && declared.origin !== tested.origin, status };
409
+ });
410
+ const badCards = cards.filter((c) => c.status !== 200);
411
+ const rerooted = cards.some((c) => c.rerooted);
412
+ record(
413
+ `og:image resolves on ${ogImages.size} page(s)`,
414
+ badCards.length === 0,
415
+ badCards.map((c) => `${c.status || 'ERR'} ${c.tested}`).join('\n '),
416
+ );
417
+ if (rerooted) {
418
+ notes.push(
419
+ 'og:image is absolute against the build host and was re-rooted onto the origin under test — ' +
420
+ 'against the real deployed host it is checked exactly as a scraper would see it',
421
+ );
422
+ }
423
+
424
+ /*
425
+ * How many pages share ONE card.
426
+ *
427
+ * "All cards unique" is the check people write, and it passes while exactly
428
+ * one page sits on the stale default — which is the failure that actually
429
+ * happened. Count pages on the most-shared card instead: on a site with
430
+ * per-page cards, anything above one is a page that never got its own.
431
+ *
432
+ * A site that deliberately uses a single card for everything is fine and
433
+ * says so by having one card and one group — hence the warning, not a fail.
434
+ */
435
+ const byCard = new Map();
436
+ for (const [path, src] of ogImages) {
437
+ if (!byCard.has(src)) byCard.set(src, []);
438
+ byCard.get(src).push(path);
439
+ }
440
+ const shared = [...byCard.entries()].filter(([, paths]) => paths.length > 1);
441
+ if (byCard.size > 1 && shared.length) {
442
+ record(
443
+ 'no page is left on a shared social card',
444
+ false,
445
+ shared
446
+ .map(([src, paths]) => `${paths.length} pages share ${src.split('/').pop()}\n ${paths.join(', ')}`)
447
+ .join('\n '),
448
+ { warn: true },
449
+ );
450
+ } else if (byCard.size === 1 && ogImages.size > 1) {
451
+ notes.push(`all ${ogImages.size} pages share one og:image — deliberate, or nobody generated per-page cards`);
452
+ }
453
+ }
454
+
455
+ /* ── Meta ──────────────────────────────────────────────────────────────────
456
+ *
457
+ * Costs one pass over HTML the link check already pulled — no extra requests.
458
+ *
459
+ * These are the SEO regressions a rebuild produces silently. Nothing about a
460
+ * missing description is visible on the page; it surfaces weeks later as a
461
+ * Search Console list nobody opens, or as a SERP snippet Google wrote itself.
462
+ *
463
+ * ⚠ It checks that the fields EXIST, are UNIQUE and are the right SHAPE. It
464
+ * cannot tell you whether a description is any good. On a migration the real
465
+ * baseline is the old site's per-URL titles and descriptions from the SEO
466
+ * plugin export — see stacks.md §1.
467
+ */
468
+ section('Meta');
469
+
470
+ const indexable = [...meta.entries()].filter(([, m]) => !m.noindex);
471
+
472
+ if (!indexable.length) {
473
+ console.log(` ${DIM}every scanned page is noindex — nothing to check${RESET}`);
474
+ } else {
475
+ console.log(` ${DIM}${indexable.length} indexable page(s) of ${meta.size} scanned${RESET}`);
476
+
477
+ const missingTitle = indexable.filter(([, m]) => !m.title);
478
+ record('every page has a title', missingTitle.length === 0,
479
+ missingTitle.map(([p]) => p).join(', '));
480
+
481
+ const missingDesc = indexable.filter(([, m]) => !m.description);
482
+ record('every page has a meta description', missingDesc.length === 0,
483
+ missingDesc.map(([p]) => p).join(', ') +
484
+ (missingDesc.length ? ' — Google writes its own snippet when this is absent' : ''));
485
+
486
+ /*
487
+ * Duplicates are the migration failure. A template that forgets to override
488
+ * the default gives twenty pages one description, and each one looks correct
489
+ * in isolation — which is why this compares across pages rather than per page.
490
+ */
491
+ const dupes = (field) => {
492
+ const byValue = new Map();
493
+ for (const [path, m] of indexable) {
494
+ const v = m[field];
495
+ if (!v) continue;
496
+ if (!byValue.has(v)) byValue.set(v, []);
497
+ byValue.get(v).push(path);
498
+ }
499
+ return [...byValue.entries()].filter(([, paths]) => paths.length > 1);
500
+ };
501
+
502
+ for (const field of ['title', 'description']) {
503
+ const d = dupes(field);
504
+ record(`no two pages share a ${field}`, d.length === 0,
505
+ d.map(([v, paths]) => `${paths.join(', ')}\n "${v.slice(0, 70)}${v.length > 70 ? '…' : ''}"`)
506
+ .join('\n '));
507
+ }
508
+
509
+ /*
510
+ * Length is a WARNING, never a failure. The limits are pixel-width based and
511
+ * Google rewrites snippets anyway, so a long title is a judgement call and
512
+ * not a defect — but a 12-character title is usually a bug.
513
+ */
514
+ const longTitles = indexable.filter(([, m]) => m.title.length > 60);
515
+ const shortTitles = indexable.filter(([, m]) => m.title && m.title.length < 15);
516
+ record('titles are a sensible length', longTitles.length === 0 && shortTitles.length === 0,
517
+ [...longTitles.map(([p, m]) => `${p} — ${m.title.length} chars, truncates in the SERP`),
518
+ ...shortTitles.map(([p, m]) => `${p} — ${m.title.length} chars, probably unfinished`)].join('\n '),
519
+ { warn: true });
520
+
521
+ const longDesc = indexable.filter(([, m]) => m.description.length > 165);
522
+ record('descriptions are a sensible length', longDesc.length === 0,
523
+ longDesc.map(([p, m]) => `${p} — ${m.description.length} chars`).join('\n '),
524
+ { warn: true });
525
+
526
+ /*
527
+ * A canonical that points somewhere else is how a page removes itself from
528
+ * the index while looking perfectly healthy. Self-referential is the rule;
529
+ * the exception is a deliberate duplicate, which should be rare enough to
530
+ * read every hit.
531
+ */
532
+ const badCanonical = indexable.filter(([path, m]) => {
533
+ if (!m.canonical) return true;
534
+ try {
535
+ return new URL(m.canonical).pathname !== path;
536
+ } catch {
537
+ return true;
538
+ }
539
+ });
540
+ record('every canonical is self-referential', badCanonical.length === 0,
541
+ badCanonical
542
+ .map(([p, m]) => `${p} → ${m.canonical || '(none)'}`)
543
+ .join('\n '));
544
+
545
+ const badH1 = indexable.filter(([, m]) => m.h1Count !== 1);
546
+ record('exactly one h1 per page', badH1.length === 0,
547
+ badH1.map(([p, m]) => `${p} — ${m.h1Count} h1(s)`).join('\n '), { warn: true });
548
+ }
549
+
550
+ /* ── Weight and render-blocking ────────────────────────────────────────────
551
+ *
552
+ * `build.md` §2 says measure before defending a design opinion, and gives no
553
+ * tool, so nobody measured until a client asked why the site felt slow.
554
+ *
555
+ * ⚠ NOTHING HERE IS A TIMING, DELIBERATELY. The same section of build.md warns
556
+ * against hand-rolling a PerformanceObserver against one machine: it produces a
557
+ * confident number that disagrees with Lighthouse and there is nothing to tell
558
+ * you it is wrong. Bytes and counts do not have that problem — they are
559
+ * identical on every machine and every connection, they are the inputs a
560
+ * timing is made of, and they are the half a script can own honestly.
561
+ *
562
+ * Lighthouse on the deployed URL, mobile, simulated throttling, two samples per
563
+ * variant, remains the tool for how fast it FEELS. This tells you what it is
564
+ * carrying. Printed under "what this cannot see" so the two never get confused.
565
+ *
566
+ * ── THE BUDGETS ARE CHOSEN, NOT MEASURED ───────────────────────────────────
567
+ * A budget is a decision, so these are stated rather than derived: they are
568
+ * where a marketing site with one hero photograph normally lands, and the
569
+ * point of them is to notice the day a page doubles. Move them for the project
570
+ * and say why in the commit — a budget nobody edited is a budget nobody read.
571
+ */
572
+ const WEIGHT_BUDGET_KB = 1600; // whole page, uncompressed, including images
573
+ const BLOCKING_STYLE_BUDGET = 2;
574
+ const ASSET_CAP = 250;
575
+
576
+ section('Weight and render-blocking');
577
+
578
+ if (!perf.size) {
579
+ console.log(` ${DIM}no pages scanned — nothing to weigh${RESET}`);
580
+ } else {
581
+ /*
582
+ * Sizes come from HEAD with `accept-encoding: identity`, so the number is the
583
+ * UNCOMPRESSED byte count. That is the stable one: it does not move when a CDN
584
+ * changes its compression, and for images and fonts — which are already
585
+ * compressed and are most of the weight — it is the transfer size anyway.
586
+ * Only CSS and JS differ, and those are covered by the compression check below.
587
+ */
588
+ const assets = [...assetUrls].slice(0, ASSET_CAP);
589
+ if (assetUrls.size > ASSET_CAP) {
590
+ notes.push(`weight read the first ${ASSET_CAP} of ${assetUrls.size} assets — raise ASSET_CAP for a full total`);
591
+ }
592
+
593
+ /*
594
+ * ⚠ HEAD IS NOT ENOUGH, AND THE OBVIOUS FALLBACK IS A TRAP. Real CDNs
595
+ * routinely answer HEAD with no content-length at all — Cloudflare and
596
+ * Netlify both do — so a HEAD-then-GET fallback quietly downloads every image
597
+ * on the site on every run, turning a twenty-second check into tens of
598
+ * megabytes. Measured against a live CDN: HEAD returned null, GET returned
599
+ * the length in its headers.
600
+ *
601
+ * So: GET, read the header, and CANCEL THE BODY before it transfers. That is
602
+ * 11ms for a 1.6 MB image instead of the whole file. Range requests were the
603
+ * other candidate and are not reliable — the hosts tested answered 200 with
604
+ * no content-range rather than 206.
605
+ */
606
+ const sizes = new Map();
607
+ await pool(assets, async (url) => {
608
+ const head = await req(url, {
609
+ method: 'HEAD',
610
+ redirect: 'follow',
611
+ headers: { 'accept-encoding': 'identity' },
612
+ });
613
+ const declared = Number(head.headers?.get('content-length') ?? 0);
614
+ if (declared > 0) {
615
+ sizes.set(url, declared);
616
+ return;
617
+ }
618
+
619
+ const res = await req(url, { redirect: 'follow', headers: { 'accept-encoding': 'identity' } });
620
+ if (res.status !== 200) return;
621
+
622
+ const len = Number(res.headers?.get('content-length') ?? 0);
623
+ if (len > 0) {
624
+ await res.body?.cancel().catch(() => {});
625
+ sizes.set(url, len);
626
+ return;
627
+ }
628
+
629
+ /* Chunked, so the length is only knowable by reading it. Last resort. */
630
+ if (res.arrayBuffer) sizes.set(url, (await res.arrayBuffer()).byteLength);
631
+ });
632
+
633
+ const unmeasured = assets.length - sizes.size;
634
+ if (unmeasured > 0) {
635
+ notes.push(`${unmeasured} of ${assets.length} asset(s) had no measurable size — the weight totals are floors, not totals`);
636
+ }
637
+
638
+ const kb = (bytes) => `${Math.round(bytes / 1024)} KB`;
639
+ const sizeOf = (page, raw) => {
640
+ try {
641
+ return sizes.get(new URL(raw, `${origin}${page}`).href) ?? 0;
642
+ } catch {
643
+ return 0;
644
+ }
645
+ };
646
+
647
+ const weights = [...perf.entries()]
648
+ .map(([path, p]) => {
649
+ const refs = [...p.blockingStyles, ...p.blockingScripts, ...p.images.map((i) => i.src)];
650
+ const unique = [...new Set(refs)];
651
+ return {
652
+ path,
653
+ total: p.htmlBytes + unique.reduce((sum, r) => sum + sizeOf(path, r), 0),
654
+ html: p.htmlBytes,
655
+ };
656
+ })
657
+ .sort((a, b) => b.total - a.total);
658
+
659
+ const heaviest = weights[0];
660
+ const over = weights.filter((w) => w.total > WEIGHT_BUDGET_KB * 1024);
661
+
662
+ /* A warning, not a failure. The right weight is a design decision — a
663
+ photography-led site is legitimately heavier — and a gate that fails on a
664
+ judgement call is a gate somebody deletes rather than argues with. */
665
+ record(`every page under ${WEIGHT_BUDGET_KB} KB`, over.length === 0,
666
+ over.length
667
+ ? over.slice(0, 5).map((w) => `${w.path} — ${kb(w.total)}`).join('\n ')
668
+ : `heaviest: ${heaviest.path} at ${kb(heaviest.total)} (HTML ${kb(heaviest.html)})`,
669
+ { warn: true });
670
+
671
+ /*
672
+ * The heaviest image on the page, named. On a marketing site this is almost
673
+ * always the LCP element, and naming it is actionable without claiming a
674
+ * millisecond: it is the one asset where a better crop or an AVIF pays for
675
+ * itself. Marked `loading="lazy"` it is a genuine, silent regression —
676
+ * lazy defers the fetch until layout, so the largest paint waits for it.
677
+ */
678
+ const allImages = [...perf.entries()].flatMap(([path, p]) =>
679
+ p.images.map((i) => ({ path, ...i, bytes: sizeOf(path, i.src) })),
680
+ );
681
+ const biggest = allImages.sort((a, b) => b.bytes - a.bytes)[0];
682
+
683
+ if (biggest?.bytes) {
684
+ console.log(` ${DIM}heaviest image: ${biggest.src} — ${kb(biggest.bytes)} on ${biggest.path}${RESET}`);
685
+
686
+ /*
687
+ * DOCUMENT ORDER, NOT SIZE. Ranking by size and flagging the biggest was
688
+ * the first version and it was wrong in a way that would have got the check
689
+ * deleted: on a page with a modest hero and a large photograph near the
690
+ * bottom, the heaviest image is the gallery one and lazy is exactly right
691
+ * there. The first SUBSTANTIAL image in the markup is the better proxy for
692
+ * the one painted first — a logo or icon sits under the threshold, a hero
693
+ * does not.
694
+ *
695
+ * A warning, because without a browser this cannot know what is actually
696
+ * above the fold: an article whose first photograph sits halfway down is a
697
+ * legitimate hit. Read it, do not obey it.
698
+ */
699
+ const HERO_MIN_BYTES = 20 * 1024;
700
+ const lazyLeaders = [...perf.entries()]
701
+ .map(([path, p]) => {
702
+ const first = p.images
703
+ .map((i) => ({ ...i, bytes: sizeOf(path, i.src) }))
704
+ .find((i) => i.bytes >= HERO_MIN_BYTES);
705
+ return { path, first };
706
+ })
707
+ .filter((r) => r.first?.lazy);
708
+
709
+ record('the first substantial image is not lazy-loaded', lazyLeaders.length === 0,
710
+ lazyLeaders.length
711
+ ? lazyLeaders.map((r) => `${r.path} — ${r.first.src} (${kb(r.first.bytes)})`).join('\n ') +
712
+ '\n lazy defers the fetch until layout, so the largest paint waits for it.' +
713
+ '\n Correct if it is genuinely below the fold — this cannot tell.'
714
+ : `first image over ${Math.round(HERO_MIN_BYTES / 1024)} KB on each page loads eagerly`,
715
+ { warn: true });
716
+ } else {
717
+ notes.push('no images found with a measurable size — the heaviest-image check had nothing to rank');
718
+ }
719
+
720
+ const styleHeavy = [...perf.entries()].filter(([, p]) => p.blockingStyles.length > BLOCKING_STYLE_BUDGET);
721
+ record(`at most ${BLOCKING_STYLE_BUDGET} render-blocking stylesheet(s)`, styleHeavy.length === 0,
722
+ styleHeavy.map(([path, p]) => `${path} — ${p.blockingStyles.length}`).join('\n '),
723
+ { warn: true });
724
+
725
+ /*
726
+ * A blocking <script src> in <head> is the one that costs the most and gets
727
+ * added the most casually — a chat widget or a tag manager pasted where the
728
+ * vendor's snippet said to. A warning rather than a failure because a consent
729
+ * script sometimes genuinely has to run first, and that is the client's call.
730
+ */
731
+ const scriptBlocked = [...perf.entries()].filter(([, p]) => p.blockingScripts.length);
732
+ record('no render-blocking script in <head>', scriptBlocked.length === 0,
733
+ scriptBlocked
734
+ .slice(0, 5)
735
+ .map(([path, p]) => `${path} — ${p.blockingScripts.join(', ')}`)
736
+ .join('\n ') + (scriptBlocked.length ? '\n add defer, or move it to the end of <body>' : ''),
737
+ { warn: true });
738
+
739
+ /*
740
+ * Text served uncompressed is a configuration failure rather than a design
741
+ * one, so it fails. It is invisible from every other angle: the page looks
742
+ * identical, and the only symptom is that CSS and JS arrive three to four
743
+ * times larger than they need to.
744
+ */
745
+ const textAsset = [...sizes.keys()].find((u) => /\.(css|js)(\?|$)/i.test(u));
746
+ if (!textAsset) {
747
+ notes.push('no CSS or JS asset found to test compression against');
748
+ } else if (/^https?:\/\/(localhost|127\.0\.0\.1)/.test(origin)) {
749
+ notes.push('compression not checked — a local dev server does not compress, and the CDN is what serves it');
750
+ } else {
751
+ const res = await req(textAsset, { method: 'HEAD', headers: { 'accept-encoding': 'gzip, br' } });
752
+ const encoding = res.headers?.get('content-encoding') ?? '';
753
+ record('text assets are served compressed', /gzip|br|zstd|deflate/i.test(encoding),
754
+ `${textAsset} → content-encoding: ${encoding || '(none)'}`);
755
+ }
756
+ }
757
+
758
+ /* ── Coverage: did the migration keep every page? ─────────────────────────
759
+ *
760
+ * ⚠ THE CHECK THIS KIT WAS MISSING, AND IT IS THE ONE THAT MATTERS MOST.
761
+ *
762
+ * `SKILL.md`'s first non-negotiable is **"Preserve every URL. Inventory before
763
+ * designing routes."** `recon` builds that inventory. Nothing ever compared it
764
+ * against what the new site actually serves.
765
+ *
766
+ * Every check above asks whether what EXISTS resolves. The Routes section
767
+ * confirms every route the new site emits returns 200 — and a build with three
768
+ * pages where the old site had eighteen passes it cleanly, because all three of
769
+ * them do. Preserved paths does not cover it either: that list is `/feed/`,
770
+ * `robots.txt`, `ads.txt` and `/.well-known/*`, not pages.
771
+ *
772
+ * Found on a real migration. recon had inventoried 18 URLs, the build emitted
773
+ * 3, and verify reported green. The only thing reading the inventory was
774
+ * `redirects`, which proposes and never gates, and only if somebody runs it.
775
+ *
776
+ * A dropped page is unambiguous, so this FAILS rather than warns. The two
777
+ * things that would make it cry wolf are handled: URLs already 404 before the
778
+ * migration are tagged by `recon` and skipped, and a path that 301s to a real
779
+ * page counts as kept — a redirect is a decision, not a loss.
780
+ */
781
+ section('Coverage');
782
+
783
+ const inventory = readInventory(readFileSync);
784
+
785
+ if (!inventory) {
786
+ console.log(` ${DIM}no recon/urls.txt — greenfield build, so there is no old site to have lost${RESET}`);
787
+ notes.push('coverage was not checked — run `npm run recon` on a migration and this becomes a gate');
788
+ } else {
789
+ const COVERAGE_CAP = 300;
790
+ const toCheck = inventory.live.slice(0, COVERAGE_CAP);
791
+ if (inventory.live.length > COVERAGE_CAP) {
792
+ notes.push(
793
+ `coverage checked the first ${COVERAGE_CAP} of ${inventory.live.length} inventoried URL(s) — ` +
794
+ 'raise COVERAGE_CAP for a full sweep',
795
+ );
796
+ }
797
+
798
+ console.log(
799
+ ` ${DIM}${toCheck.length} URL(s) the old site served` +
800
+ (inventory.gone.length
801
+ ? `, plus ${inventory.gone.length} already 404 before the migration (skipped)`
802
+ : '') +
803
+ `${RESET}`,
804
+ );
805
+
806
+ /*
807
+ * Follow redirects rather than accepting any 3xx. A 301 pointing at a page
808
+ * that itself 404s is a loss wearing a redirect's clothes, and the question
809
+ * here is whether the CONTENT survived, not whether a rule exists.
810
+ */
811
+ const coverage = await pool(toCheck, async (path) => {
812
+ let res = await req(path, { method: 'HEAD', redirect: 'follow' });
813
+ if (res.status === 405 || res.status === 501 || res.status === 0) {
814
+ res = await req(path, { redirect: 'follow' });
815
+ }
816
+ return { path, status: res.status ?? 0, landed: res.url ?? '' };
817
+ });
818
+
819
+ const lost = coverage.filter((r) => r.status !== 200);
820
+ record('every page the old site served still resolves', lost.length === 0,
821
+ lost.slice(0, 20).map((r) => `${r.status || 'ERR'} ${r.path}`).join('\n ') +
822
+ (lost.length
823
+ ? `\n ${lost.length} of ${toCheck.length} inventoried URL(s) do not resolve here.` +
824
+ '\n Either the page was never built, or it needs a 301 in public/_redirects.'
825
+ : ''));
826
+
827
+ /*
828
+ * Landing on the homepage is the failure SKILL.md names explicitly: never
829
+ * redirect a legacy URL to the homepage when a specific equivalent exists,
830
+ * because search engines read it as a soft 404. It RESOLVES, so the check
831
+ * above passes it — which is exactly why it needs a line of its own.
832
+ */
833
+ const landedHome = coverage.filter((r) => {
834
+ if (r.path === '/' || r.status !== 200 || !r.landed) return false;
835
+ try {
836
+ return new URL(r.landed).pathname === '/';
837
+ } catch {
838
+ return false;
839
+ }
840
+ });
841
+ record('no old URL lands on the homepage', landedHome.length === 0,
842
+ landedHome.slice(0, 10).map((r) => `${r.path} → /`).join('\n ') +
843
+ (landedHome.length ? '\n a legacy URL pointed at the homepage reads as a soft 404' : ''),
844
+ { warn: true });
845
+ }
846
+
847
+ /* ── Preserved paths ───────────────────────────────────────────────────────
848
+ *
849
+ * `recon` reported which of these the OLD site served. Nothing ever confirmed
850
+ * they survived — the inventory said `/feed/` had to keep working and the only
851
+ * thing that would notice it had not was a subscriber, silently.
852
+ *
853
+ * Where recon output is present, check exactly what that migration had. A
854
+ * greenfield build has no old site, so fall back to probing the generic list
855
+ * and report it as information rather than a failure.
856
+ */
857
+ section('Preserved paths');
858
+
859
+ const fromRecon = preservedFromRecon(readFileSync);
860
+ const toCheck = fromRecon ?? PRESERVED.map(([p]) => p);
861
+
862
+ const preservedResults = await pool(toCheck, async (path) => {
863
+ const r = await req(path, { method: 'HEAD' });
864
+ return { path, status: r.status ?? 0, location: r.headers.get('location') ?? '' };
865
+ });
866
+
867
+ const resolves = (r) => r.status === 200 || (r.status >= 300 && r.status < 400);
868
+
869
+ if (fromRecon) {
870
+ console.log(` ${DIM}${toCheck.length} path(s) the old site served, from recon/preserved.md${RESET}`);
871
+ const lost = preservedResults.filter((r) => !resolves(r));
872
+ record('every preserved path still resolves', lost.length === 0,
873
+ lost.map((r) => `${r.status || 'ERR'} ${r.path}`).join('\n ') +
874
+ (lost.length ? '\n each of these worked on the old site and does not here' : ''));
875
+
876
+ /* A preserved path 301'd to the homepage is a soft 404 for whatever parses
877
+ it — same rule recon applies to the old site. */
878
+ const toHome = preservedResults.filter(
879
+ (r) => r.status >= 300 && r.status < 400 && /^(https?:\/\/[^/]+)?\/$/.test(r.location),
880
+ );
881
+ record('no preserved path redirects to the homepage', toHome.length === 0,
882
+ toHome.map((r) => `${r.path} → ${r.location}`).join('\n '), { warn: true });
883
+ } else {
884
+ const present = preservedResults.filter(resolves);
885
+ console.log(
886
+ ` ${DIM}no recon output — probed the generic list, ${present.length} of ${toCheck.length} present${RESET}`,
887
+ );
888
+ notes.push('preserved paths were checked against the generic list; run `npm run recon` on a migration for the real one');
889
+ }
890
+
891
+ /* ── Redirects ─────────────────────────────────────────────────────────────
892
+ *
893
+ * Parsed from public/_redirects, so the check cannot drift from the rules.
894
+ * A migration's traffic lives here: a rule that silently stopped matching
895
+ * looks identical to one that was never written.
896
+ */
897
+ section('Redirects');
898
+
899
+ if (!existsSync('public/_redirects')) {
900
+ record('public/_redirects present', false, 'no redirect map — expected on a migration');
901
+ } else {
902
+ const rules = readFileSync('public/_redirects', 'utf8')
903
+ .split('\n')
904
+ .map((l) => l.trim())
905
+ .filter((l) => l && !l.startsWith('#'))
906
+ .map((l) => l.split(/\s+/))
907
+ .filter((p) => p.length >= 2 && p[0].startsWith('/'))
908
+ /* Splats and placeholders need a concrete example to test; skip them
909
+ rather than request a literal '*' and report a false failure. */
910
+ .filter((p) => !p[0].includes('*') && !p[0].includes(':'));
911
+
912
+ console.log(` ${DIM}${rules.length} literal rule(s) — splat and placeholder rules are not testable without an example${RESET}`);
913
+
914
+ const checked = await pool(rules, async ([from, to, code]) => {
915
+ const expect = Number(code ?? 301);
916
+ const res = await req(from);
917
+ const location = res.headers.get('location') ?? '';
918
+ return { from, to, expect, status: res.status, location };
919
+ });
920
+
921
+ const wrongStatus = checked.filter((r) => r.status !== r.expect);
922
+ record('every rule returns its declared status', wrongStatus.length === 0,
923
+ wrongStatus.map((r) => `${r.from} → expected ${r.expect}, got ${r.status || 'ERR'}`).join('\n '));
924
+
925
+ const wrongTarget = checked.filter(
926
+ (r) => r.status === r.expect && !r.location.replace(origin, '').startsWith(r.to.split(/[?#]/)[0].replace(/\*$/, '')),
927
+ );
928
+ record('every rule lands on its declared target', wrongTarget.length === 0,
929
+ wrongTarget.map((r) => `${r.from} → ${r.location || '(no Location)'}, expected ${r.to}`).join('\n '));
930
+
931
+ /* A 301 to a 404 is worse than no redirect: it spends the crawl and loses
932
+ the signal, and reads as working in every status-only check.
933
+ Staging emits no sitemap deliberately, so a rule pointing at one is
934
+ expected to 404 there and only counts on production. */
935
+ const targets = [...new Set(checked.filter((r) => r.status === r.expect).map((r) => r.to))]
936
+ .filter((t) => !(env === 'staging' && /sitemap.*\.xml$/i.test(t)));
937
+ const deadTargets = (
938
+ await pool(targets, async (t) => ({ t, status: (await req(t)).status }))
939
+ ).filter((r) => r.status !== 200 && r.status !== 301 && r.status !== 308);
940
+ record('every redirect target resolves', deadTargets.length === 0,
941
+ deadTargets.map((d) => `${d.t} → ${d.status || 'ERR'}`).join('\n '));
942
+ }
943
+
944
+ /* ── Headers ───────────────────────────────────────────────────────────── */
945
+ section('Headers');
946
+
947
+ const h = homeRes.headers;
948
+ for (const [name, why] of [
949
+ ['referrer-policy', 'from public/_headers'],
950
+ ['permissions-policy', 'from public/_headers'],
951
+ ['content-security-policy', 'from public/_headers — frame-ancestors, base-uri, form-action, object-src'],
952
+ ]) {
953
+ record(`${name} present`, Boolean(h.get(name)), h.get(name) ? '' : why);
954
+ }
955
+
956
+ /* HSTS and X-Content-Type-Options arrive from the Cloudflare zone, not the
957
+ repo. Nothing in this project would notice them disappearing — which is
958
+ exactly why they are checked here and only warned about, since a local
959
+ wrangler dev has no zone in front of it. */
960
+ for (const name of ['strict-transport-security', 'x-content-type-options']) {
961
+ const present = Boolean(h.get(name));
962
+ record(`${name} present`, present,
963
+ present ? '' : 'set at the Cloudflare zone, not in this repo — re-check after any SSL/TLS change',
964
+ { warn: true });
965
+ }
966
+
967
+ const fontRes = await req('/fonts/');
968
+ if (fontRes.status !== 404) {
969
+ notes.push('checked /fonts/ for an immutable rule — verify a real font URL by hand');
970
+ }
971
+
972
+ /* ── Form ──────────────────────────────────────────────────────────────────
973
+ *
974
+ * Only the submissions the API refuses. See the header of this file.
975
+ */
976
+ section('Form (non-destructive submissions only)');
977
+
978
+ const formBody = (extra) =>
979
+ new URLSearchParams({ name: 'Verify', email: 'verify@example.com', phone: '5555550123', message: 'verification', ...extra });
980
+
981
+ const honeypot = await req('/api/contact/', {
982
+ method: 'POST',
983
+ headers: { origin, accept: 'text/html', 'content-type': 'application/x-www-form-urlencoded' },
984
+ body: formBody({ company: 'filled' }),
985
+ });
986
+ const honeypotLocation = honeypot.headers.get('location') ?? '';
987
+ record('caught spam does NOT land on the conversion URL',
988
+ !/[?&]sent=/.test(honeypotLocation),
989
+ `Location: ${honeypotLocation || '(none)'}` +
990
+ (/[?&]sent=/.test(honeypotLocation)
991
+ ? ' — any bot running JavaScript can now inflate the only conversion the site owns'
992
+ : ''));
993
+
994
+ const empty = await req('/api/contact/', {
995
+ method: 'POST',
996
+ headers: { origin, accept: 'application/json', 'content-type': 'application/json' },
997
+ body: '{}',
998
+ });
999
+ record('empty submission is rejected', empty.status === 422, `got ${empty.status}, expected 422`);
1000
+
1001
+ const crossOrigin = await req('/api/contact/', {
1002
+ method: 'POST',
1003
+ headers: { origin: 'https://not-this-site.example', accept: 'application/json', 'content-type': 'application/json' },
1004
+ body: JSON.stringify({ name: 'x', email: 'x@example.com', phone: '5555550123', message: 'x' }),
1005
+ });
1006
+ record('cross-origin submission is refused', crossOrigin.status === 403, `got ${crossOrigin.status}, expected 403`);
1007
+
1008
+ /* ── Sitemap quality ───────────────────────────────────────────────────── */
1009
+ if (env === 'production' && smIndex.status === 200) {
1010
+ section('Sitemap');
1011
+
1012
+ const dates = [];
1013
+ const noindexed = [];
1014
+ await pool(routes.slice(0, 60), async (url) => {
1015
+ const res = await req(url);
1016
+ if (res.status !== 200) return;
1017
+ const body = await res.text();
1018
+ if (/<meta[^>]+name=["']robots["'][^>]+noindex/i.test(body)) noindexed.push(url);
1019
+ });
1020
+
1021
+ /* A URL in the sitemap is a request to index it; the same URL serving
1022
+ noindex is a refusal. Search Console reports the pair as an ERROR against
1023
+ the whole submission, and nothing else compares the two lists. */
1024
+ record('no sitemap URL serves noindex', noindexed.length === 0,
1025
+ noindexed.join('\n '));
1026
+
1027
+ const smXml = await smIndex.text();
1028
+ for (const map of [...smXml.matchAll(/<loc>([^<]+)<\/loc>/g)].map((m) => m[1])) {
1029
+ const r = await req(map);
1030
+ if (r.status !== 200) continue;
1031
+ const body = await r.text();
1032
+ dates.push(...[...body.matchAll(/<lastmod>([^<]{10})/g)].map((m) => m[1]));
1033
+ }
1034
+ if (dates.length) {
1035
+ const distinct = new Set(dates).size;
1036
+ /* One date for everything is the failure mode, not a pass: it is what a
1037
+ build timestamp or a layout-as-source bug produces, and Google discounts
1038
+ lastmod on sites whose values are not consistently accurate. */
1039
+ record('lastmod values vary', distinct > 1 || dates.length === 1,
1040
+ `${distinct} distinct date(s) across ${dates.length} URL(s)` +
1041
+ (distinct === 1 && dates.length > 1 ? ' — one date for every page carries no signal' : ''));
1042
+ } else {
1043
+ notes.push('no <lastmod> in the sitemap — run `npm run lastmod` and commit src/data/lastmod.json');
1044
+ }
1045
+ }
1046
+
1047
+ /* ── Summary ───────────────────────────────────────────────────────────── */
1048
+ const failed = results.filter((r) => !r.ok && !r.warn);
1049
+ const warned = results.filter((r) => !r.ok && r.warn);
1050
+
1051
+ console.log(`\n${BOLD}── What this cannot see ${'─'.repeat(36)}${RESET}`);
1052
+ for (const line of [
1053
+ 'One pageview per visit — a double-count is only visible in Realtime',
1054
+ 'That a valid submission stores AND emails — send one by hand, once',
1055
+ 'Whether the analytics container is the client\'s own — fetch it and read it',
1056
+ 'Whether a redirect target is the RIGHT page, only that it resolves',
1057
+ 'How fast it FEELS — weight and blocking counts are the inputs, never a timing. ' +
1058
+ 'Lighthouse on the deployed URL, mobile, simulated throttling, 2+ samples per variant',
1059
+ ...notes,
1060
+ ]) {
1061
+ console.log(` ${DIM}·${RESET} ${DIM}${line}${RESET}`);
1062
+ }
1063
+
1064
+ console.log('');
1065
+ if (failed.length) {
1066
+ console.error(`${RED}✗ ${failed.length} check(s) failed${RESET}${warned.length ? `, ${warned.length} warning(s)` : ''} against ${origin}\n`);
1067
+ process.exit(1);
1068
+ }
1069
+ console.log(`${GREEN}✓ ${results.length - warned.length} check(s) passed${RESET}${warned.length ? `, ${YELLOW}${warned.length} warning(s)${RESET}` : ''} against ${origin}\n`);