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,480 @@
1
+ /**
2
+ * Inventory the OLD site, before designing a single route.
3
+ *
4
+ * npm run recon -- https://old-site.com
5
+ * npm run recon -- https://old-site.com --no-wayback # faster, current URLs only
6
+ *
7
+ * Writes recon/urls.txt, recon/preserved.md and recon/integrations.md.
8
+ *
9
+ * ── WHY THIS EXISTS ────────────────────────────────────────────────────────
10
+ * "The inventory" is referenced by runbook.md §2, build.md §2, kickoff.md and
11
+ * stacks.md §1, §1b and §1d — it is the input to the redirect map, the go-live
12
+ * route check and week-one 404 triage. Nothing in the kit produced it. Every
13
+ * migration rebuilt it by hand, from prose, at the point in the project where
14
+ * getting it wrong is least visible and most expensive.
15
+ *
16
+ * A URL missed here is a page that 404s after cutover with its backlinks
17
+ * pointing at nothing, and you find out from a Search Console email weeks
18
+ * later. This is the cheapest possible moment to be thorough.
19
+ *
20
+ * ── WHAT IT CANNOT SEE ─────────────────────────────────────────────────────
21
+ * Printed at the end, and it matters more here than anywhere else: an
22
+ * integration injected by JavaScript after load, anything behind a login, a
23
+ * tag fired only through GTM, and any page absent from both the sitemap and
24
+ * the Wayback Machine. This narrows the interview with the client. It does not
25
+ * replace it — see the three questions in stacks.md §1b.
26
+ */
27
+
28
+ import { mkdirSync, writeFileSync } from 'node:fs';
29
+
30
+ import { GONE_TAG } from './lib/inventory.mjs';
31
+
32
+ const RESET = '';
33
+ const RED = '';
34
+ const GREEN = '';
35
+ const YELLOW = '';
36
+ const DIM = '';
37
+ const BOLD = '';
38
+
39
+ const argv = process.argv.slice(2);
40
+ const target = argv.find((a) => !a.startsWith('--'));
41
+ const useWayback = !argv.includes('--no-wayback');
42
+
43
+ if (!target) {
44
+ console.error('usage: npm run recon -- https://old-site.com [--no-wayback]');
45
+ process.exit(1);
46
+ }
47
+
48
+ const ORIGIN = (target.startsWith('http') ? target : `https://${target}`).replace(/\/$/, '');
49
+ const HOST = new URL(ORIGIN).hostname;
50
+ const OUT = 'recon';
51
+
52
+ const section = (t) => console.log(`\n${BOLD}── ${t} ${'─'.repeat(Math.max(0, 56 - t.length))}${RESET}`);
53
+ const notes = [];
54
+
55
+ async function req(url, options = {}) {
56
+ const controller = new AbortController();
57
+ const timer = setTimeout(() => controller.abort(), 20000);
58
+ try {
59
+ return await fetch(url, { redirect: 'follow', signal: controller.signal, ...options });
60
+ } catch {
61
+ return null;
62
+ } finally {
63
+ clearTimeout(timer);
64
+ }
65
+ }
66
+
67
+ const text = async (url) => {
68
+ const r = await req(url);
69
+ return r?.ok ? await r.text() : '';
70
+ };
71
+
72
+ /** Bounded concurrency — this is someone's live site, not a load test. */
73
+ async function pool(items, worker, limit = 6) {
74
+ const out = [];
75
+ let i = 0;
76
+ await Promise.all(
77
+ Array.from({ length: Math.min(limit, items.length) }, async () => {
78
+ while (i < items.length) {
79
+ const index = i++;
80
+ out[index] = await worker(items[index], index);
81
+ }
82
+ }),
83
+ );
84
+ return out;
85
+ }
86
+
87
+ /* ── 1. The sitemap, and WHICH name it uses ────────────────────────────────
88
+ *
89
+ * robots.txt is the source of truth: it is what every crawler follows and what
90
+ * Search Console was pointed at. The filename itself is load-bearing — Yoast
91
+ * and Rank Math emit /sitemap_index.xml, WordPress core /wp-sitemap.xml, and
92
+ * @astrojs/sitemap /sitemap-index.xml. Underscore to hyphen reads as identical
93
+ * and is not, so the new build has to emit or redirect the OLD name.
94
+ */
95
+ section('Sitemap');
96
+
97
+ const robots = await text(`${ORIGIN}/robots.txt`);
98
+ const declared = [...robots.matchAll(/^\s*Sitemap:\s*(\S+)/gim)].map((m) => m[1]);
99
+
100
+ /*
101
+ * Probed WITHOUT following redirects. With `follow`, every alias reports 200 —
102
+ * a site that 301s /sitemap.xml to /sitemap_index.xml looks like it serves
103
+ * both natively, and the one fact this section exists to establish (which
104
+ * filename is canonical) is exactly the one that gets masked.
105
+ */
106
+ const CANDIDATES = ['/sitemap_index.xml', '/sitemap-index.xml', '/wp-sitemap.xml', '/sitemap.xml'];
107
+ const probed = await pool(CANDIDATES, async (p) => {
108
+ const r = await req(`${ORIGIN}${p}`, { method: 'HEAD', redirect: 'manual' });
109
+ return { path: p, status: r?.status ?? 0, location: r?.headers.get('location') ?? '' };
110
+ });
111
+
112
+ const native = probed.filter((p) => p.status === 200).map((p) => p.path);
113
+ const sitemapUrls = [...new Set([...declared, ...native.map((p) => ORIGIN + p)])];
114
+
115
+ if (declared.length) {
116
+ console.log(` ${GREEN}✓${RESET} robots.txt declares: ${declared.join(', ')}`);
117
+ } else {
118
+ console.log(` ${YELLOW}!${RESET} robots.txt names no sitemap`);
119
+ notes.push('robots.txt declares no sitemap — confirm what was submitted to Search Console');
120
+ }
121
+ for (const p of probed) {
122
+ const mark = p.status === 200 ? `${GREEN}✓${RESET}` : `${DIM}·${RESET}`;
123
+ const via = p.location ? ` ${DIM}→ ${p.location}${RESET}` : '';
124
+ console.log(` ${mark} ${p.path.padEnd(22)} ${p.status || '—'}${via}`);
125
+ }
126
+
127
+ /*
128
+ * robots.txt wins over anything probed. It is what crawlers follow and what
129
+ * was almost certainly submitted to Search Console; a path that merely answers
130
+ * 200 may be an alias nobody has ever pointed at.
131
+ */
132
+ const canonicalSitemap =
133
+ (declared[0] && new URL(declared[0]).pathname) ?? native[0] ?? null;
134
+
135
+ if (canonicalSitemap) {
136
+ console.log(
137
+ `\n ${BOLD}The new build must answer on ${canonicalSitemap}${RESET}` +
138
+ `${declared.length ? ` ${DIM}(declared in robots.txt)${RESET}` : ` ${DIM}(probed — robots.txt names none)${RESET}`}\n` +
139
+ ` ${DIM}Search Console stores the URL that was submitted. Emit at that path, or 301 it.${RESET}`,
140
+ );
141
+ }
142
+
143
+ /* ── 2. Every URL ──────────────────────────────────────────────────────── */
144
+ section('URLs');
145
+
146
+ const locsIn = (xml) => [...xml.matchAll(/<loc>([^<]+)<\/loc>/g)].map((m) => m[1].trim());
147
+
148
+ async function harvest(url, seen = new Set()) {
149
+ if (seen.has(url)) return [];
150
+ seen.add(url);
151
+ const xml = await text(url);
152
+ if (!xml) return [];
153
+
154
+ const locs = locsIn(xml);
155
+ const isIndex = /<sitemapindex/i.test(xml);
156
+ if (!isIndex) return locs;
157
+
158
+ const nested = await pool(locs, (child) => harvest(child, seen));
159
+ return nested.flat();
160
+ }
161
+
162
+ const fromSitemap = [...new Set((await pool(sitemapUrls, (u) => harvest(u))).flat())];
163
+ console.log(` ${fromSitemap.length} URL(s) from the sitemap`);
164
+
165
+ let fromWayback = [];
166
+ if (useWayback) {
167
+ /*
168
+ * The Wayback Machine surfaces URLs that are GONE from the current sitemap
169
+ * but still have inbound links — deleted posts, retired service pages,
170
+ * campaign landing pages. Those are exactly the ones that 404 after cutover
171
+ * with nobody watching, because they are invisible to any crawl of the live
172
+ * site. kickoff.md reaches for this when the origin is dead; it is worth as
173
+ * much when the origin is healthy.
174
+ */
175
+ /*
176
+ * ⚠ A FAILED REQUEST MUST NOT REPORT AS "0 ARCHIVED URLS".
177
+ *
178
+ * The CDX API is frequently slow and occasionally refuses outright. Folding
179
+ * that into an empty result would print `0 URL(s) from the Wayback Machine`,
180
+ * which reads as a finding — this site has no history — when it means the
181
+ * lookup did not happen. The whole value of this step is the URLs nothing
182
+ * else can see, so a silent zero is the one outcome that must be impossible.
183
+ */
184
+ const cdxUrl =
185
+ `https://web.archive.org/cdx/search/cdx?url=${encodeURIComponent(HOST)}%2F*` +
186
+ `&output=text&fl=original&collapse=urlkey&filter=statuscode:200&limit=5000`;
187
+
188
+ const controller = new AbortController();
189
+ const timer = setTimeout(() => controller.abort(), 90000);
190
+ let cdx = null;
191
+ try {
192
+ const res = await fetch(cdxUrl, { signal: controller.signal });
193
+ cdx = res.ok ? await res.text() : null;
194
+ if (!res.ok) notes.push(`Wayback CDX returned ${res.status} — the archive was NOT consulted`);
195
+ } catch {
196
+ notes.push('Wayback CDX timed out or refused — the archive was NOT consulted. Re-run, or use --no-wayback deliberately');
197
+ } finally {
198
+ clearTimeout(timer);
199
+ }
200
+
201
+ if (cdx === null) {
202
+ console.log(
203
+ ` ${RED}✗${RESET} Wayback Machine unreachable — this is NOT "no archived URLs".\n` +
204
+ ` ${DIM}Deleted pages that still hold backlinks are missing from this inventory.${RESET}`,
205
+ );
206
+ }
207
+
208
+ fromWayback = [...new Set((cdx ?? '').split('\n').map((l) => l.trim()).filter(Boolean))]
209
+ .filter((u) => {
210
+ try {
211
+ return new URL(u).hostname.replace(/^www\./, '') === HOST.replace(/^www\./, '');
212
+ } catch {
213
+ return false;
214
+ }
215
+ })
216
+ /* Assets are not routes. A redirect map for .jpg files is noise. */
217
+ .filter((u) => !/\.(jpe?g|png|gif|svg|webp|css|js|ico|woff2?|ttf|pdf|zip|mp4)(\?|$)/i.test(u))
218
+ /*
219
+ * Neither is platform plumbing. The archive is full of /wp-includes/,
220
+ * /cdn-cgi/ and xmlrpc.php — they were never pages, nobody links to them,
221
+ * and a redirect map padded with them buries the URLs that matter.
222
+ * /wp-admin/ and /wp-login.php specifically must 404 on the new site: a
223
+ * 301 from an admin path tells a scanner the site moved.
224
+ */
225
+ .filter(
226
+ (u) =>
227
+ !/\/(cdn-cgi|wp-includes|wp-json|wp-admin|xmlrpc\.php|wp-login\.php|feed\/?$)/i.test(
228
+ new URL(u).pathname,
229
+ ),
230
+ );
231
+ if (cdx !== null) console.log(` ${fromWayback.length} URL(s) from the Wayback Machine`);
232
+ } else {
233
+ notes.push('--no-wayback: URLs that were deleted before this crawl are not in the inventory');
234
+ }
235
+
236
+ const paths = (urls) =>
237
+ urls
238
+ .map((u) => {
239
+ try {
240
+ return new URL(u).pathname;
241
+ } catch {
242
+ return null;
243
+ }
244
+ })
245
+ .filter(Boolean);
246
+
247
+ const sitemapPaths = new Set(paths(fromSitemap));
248
+ const extraPaths = [...new Set(paths(fromWayback))].filter((p) => !sitemapPaths.has(p));
249
+
250
+ /* Which of the extras are actually dead now — those are the redirect map. */
251
+ const extraStatus = await pool(extraPaths.slice(0, 400), async (p) => {
252
+ const r = await req(`${ORIGIN}${p}`, { method: 'HEAD' });
253
+ return { path: p, status: r?.status ?? 0 };
254
+ });
255
+ const gone = new Set(extraStatus.filter((r) => r.status === 404).map((r) => r.path));
256
+ if (extraPaths.length > 400) {
257
+ console.log(` ${YELLOW}!${RESET} ${extraPaths.length} extra paths, only the first 400 status-checked`);
258
+ notes.push(`${extraPaths.length - 400} Wayback paths were not status-checked — raise the cap if this is a large site`);
259
+ }
260
+
261
+ const all = [...sitemapPaths, ...extraPaths].sort();
262
+
263
+ /*
264
+ * ⚠ TAG THE ALREADY-DEAD ONES IN THE FILE, not only in this terminal.
265
+ *
266
+ * `gone` was computed here from the start and printed once, so the fact died
267
+ * with the scrollback. It matters later: `verify` now checks that every URL the
268
+ * old site served still resolves, and a URL that was ALREADY 404 before the
269
+ * migration began is not a page the migration lost. Without the tag that check
270
+ * goes red on every healthy migration, and a check that goes red for a
271
+ * non-reason is a check that gets switched off.
272
+ *
273
+ * Read by scripts/lib/inventory.mjs, which is the only thing that parses this
274
+ * file. The paths stay one-per-line and still `grep '^/'` cleanly.
275
+ */
276
+ const goneList = [...gone].sort();
277
+
278
+ mkdirSync(OUT, { recursive: true });
279
+ writeFileSync(
280
+ `${OUT}/urls.txt`,
281
+ `# Inventory of ${ORIGIN}\n` +
282
+ `# ${sitemapPaths.size} live in the sitemap` +
283
+ (useWayback ? `, ${extraPaths.length} more from the Wayback Machine (${gone.size} now 404)\n` : '\n') +
284
+ `# Every one of these needs a 200 or a specific 301 after cutover. runbook.md §2.\n` +
285
+ `# Lines tagged \`${GONE_TAG}\` were already 404 before the migration —\n` +
286
+ `# redirect targets that still hold backlinks, not pages to rebuild.\n` +
287
+ all.map((p) => (gone.has(p) ? `${p} ${GONE_TAG}` : p)).join('\n') +
288
+ '\n',
289
+ );
290
+
291
+ console.log(` ${GREEN}✓${RESET} ${all.length} path(s) → ${OUT}/urls.txt`);
292
+ if (goneList.length) {
293
+ console.log(
294
+ ` ${YELLOW}!${RESET} ${goneList.length} already 404 on the old site — these still hold backlinks\n` +
295
+ ` ${DIM}${goneList.slice(0, 5).join(' ')}${goneList.length > 5 ? ` … +${goneList.length - 5}` : ''}${RESET}`,
296
+ );
297
+ }
298
+
299
+ /* ── 3. Paths other systems point at ──────────────────────────────────── */
300
+ section('Preserved paths');
301
+
302
+ const PRESERVE = PRESERVED;
303
+
304
+ /* Manual redirects again: "serves a feed" and "301s to a feed" are different
305
+ facts, and only the first means the path must be reproduced. */
306
+ const preserved = await pool(PRESERVE, async ([path, why]) => {
307
+ const r = await req(`${ORIGIN}${path}`, { method: 'HEAD', redirect: 'manual' });
308
+ return { path, why, status: r?.status ?? 0, location: r?.headers.get('location') ?? '' };
309
+ });
310
+
311
+ const present = preserved.filter((p) => p.status === 200 || (p.status >= 300 && p.status < 400));
312
+
313
+ /*
314
+ * A machine-readable path redirected to the HOMEPAGE is a soft 404 for whatever
315
+ * parses it: an aggregator asking for a feed gets a marketing page, and Google
316
+ * reads the pattern as a site-wide soft 404. It is the single most common
317
+ * migration mistake, because it looks like a tidy catch-all rule and returns a
318
+ * perfectly healthy 301.
319
+ */
320
+ const toHomepage = present.filter(
321
+ (p) => p.status >= 300 && p.status < 400 && p.location && /^(https?:\/\/[^/]+)?\/$/.test(p.location),
322
+ );
323
+
324
+ for (const p of preserved) {
325
+ const live = p.status === 200 || (p.status >= 300 && p.status < 400);
326
+ const soft = toHomepage.includes(p);
327
+ const mark = soft ? `${RED}✗${RESET}` : live ? `${YELLOW}!${RESET}` : `${DIM}·${RESET}`;
328
+ const via = p.location ? ` ${DIM}→ ${p.location}${RESET}` : '';
329
+ console.log(` ${mark} ${p.path.padEnd(38)} ${p.status || '—'}${via}`);
330
+ }
331
+ console.log(` ${DIM}${present.length} present — each must resolve on the new site${RESET}`);
332
+
333
+ if (toHomepage.length) {
334
+ console.log(
335
+ `\n ${RED}${toHomepage.length} path(s) redirect to the homepage${RESET}\n` +
336
+ ` ${DIM}${toHomepage.map((p) => p.path).join(', ')}${RESET}\n` +
337
+ ` ${DIM}A feed or data file 301'd to a marketing page is a soft 404 for anything\n` +
338
+ ` parsing it. Point each at its real equivalent, or let it 404 honestly.${RESET}`,
339
+ );
340
+ notes.push(`${toHomepage.length} preserved path(s) currently 301 to the homepage — see above`);
341
+ }
342
+
343
+ /* Verification tokens live in the homepage <head> and vanish silently. */
344
+ const home = await text(`${ORIGIN}/`);
345
+ const metas = [
346
+ ...home.matchAll(/<meta[^>]+name=["'](google-site-verification|msvalidate\.01|facebook-domain-verification|yandex-verification)["'][^>]*content=["']([^"']+)/gi),
347
+ ].map((m) => [m[1], m[2]]);
348
+
349
+ const googleHtml = [...home.matchAll(/google[0-9a-f]{16}\.html/gi)].map((m) => m[0]);
350
+
351
+ if (metas.length) {
352
+ console.log(`\n ${BOLD}Verification tokens in <head> — carry these across:${RESET}`);
353
+ for (const [name, content] of metas) console.log(` ${name.padEnd(32)} ${content}`);
354
+ } else {
355
+ notes.push('No verification <meta> on the homepage — check for HTML-file or DNS TXT verification instead');
356
+ }
357
+
358
+ writeFileSync(
359
+ `${OUT}/preserved.md`,
360
+ `# Paths and identifiers that must not change\n\n` +
361
+ `From \`${ORIGIN}\`. See stacks.md §1d — losing one of these is silent.\n\n` +
362
+ `## Sitemap\n\n` +
363
+ (canonicalSitemap
364
+ ? `The old site's canonical sitemap is **${canonicalSitemap}**` +
365
+ (native.filter((n) => n !== canonicalSitemap).length
366
+ ? ` (aliases also answering 200: ${native.filter((n) => n !== canonicalSitemap).join(', ')})`
367
+ : '') +
368
+ `. The new build must emit at that path or 301 to it.\n` +
369
+ `\`@astrojs/sitemap\` emits \`/sitemap-index.xml\` — underscore becomes a hyphen, which reads as identical.\n\n`
370
+ : `No sitemap found. Confirm with the client what was submitted to Search Console.\n\n`) +
371
+ (declared.length ? `robots.txt declares: ${declared.map((d) => `\`${d}\``).join(', ')}\n\n` : '') +
372
+ `## Present on the old site\n\n` +
373
+ (present.length
374
+ ? '| Path | Now | Why it matters |\n| --- | --- | --- |\n' +
375
+ present.map((p) => `| \`${p.path}\` | ${p.status}${p.location ? ` → \`${p.location}\`` : ''} | ${p.why} |`).join('\n') + '\n\n'
376
+ : '_None of the usual ones respond._\n\n') +
377
+ `## Verification\n\n` +
378
+ (metas.length
379
+ ? '| Method | Token |\n| --- | --- |\n' +
380
+ metas.map(([n, c]) => `| \`${n}\` | \`${c}\` |`).join('\n') + '\n\n'
381
+ : '_No verification meta on the homepage._\n\n') +
382
+ (googleHtml.length ? `HTML-file verification referenced: ${googleHtml.join(', ')}\n\n` : '') +
383
+ `> Verification drops the moment the file or tag stops resolving, and losing it loses the\n` +
384
+ `> property's data access. Carry it byte-for-byte, or move to DNS TXT and confirm BEFORE launch.\n`,
385
+ );
386
+ console.log(` ${GREEN}✓${RESET} ${OUT}/preserved.md`);
387
+
388
+ /* ── 4. What is bolted on ─────────────────────────────────────────────── */
389
+ section('Integrations');
390
+
391
+ /* Sample rather than crawl everything: a contact page carries the form and the
392
+ spam vendor, the homepage carries the tags. Anything JS-injected after load
393
+ is invisible here by construction — that is what the client interview is for. */
394
+ const sample = [
395
+ '/',
396
+ ...['/contact/', '/contact-us/', '/about/', '/blog/', '/services/'].filter(() => true),
397
+ ].filter((p, i, a) => a.indexOf(p) === i);
398
+
399
+ const pages = await pool(sample, async (p) => ({ path: p, html: await text(`${ORIGIN}${p}`) }));
400
+ const corpus = pages.map((p) => p.html).join('\n');
401
+
402
+ const ids = [...new Set([...corpus.matchAll(/\b(G-[A-Z0-9]{8,}|GTM-[A-Z0-9]{6,}|AW-[0-9]{9,}|UA-[0-9]{4,}-[0-9]+)\b/g)].map((m) => m[1]))];
403
+
404
+ const VENDORS = [
405
+ 'calendly', 'cal.com', 'hubspot', 'klaviyo', 'mailchimp', 'intercom', 'crisp', 'tawk',
406
+ 'drift', 'zendesk', 'trustpilot', 'birdeye', 'yotpo', 'stripe', 'paypal', 'recaptcha',
407
+ 'turnstile', 'hotjar', 'clarity', 'typeform', 'jotform', 'acuity', 'housecallpro',
408
+ 'jobber', 'servicetitan', 'momence', 'wellnessliving', 'glofox', 'pike13', 'cookieyes', 'cookiebot', 'complianz', 'algolia', 'mindbody',
409
+ 'squarespace', 'wix', 'shopify', 'woocommerce', 'memberpress',
410
+ ];
411
+ const vendors = VENDORS.filter((v) => new RegExp(v.replace('.', '\\.'), 'i').test(corpus));
412
+
413
+ const origins = [...new Set([...corpus.matchAll(/(?:src|href)=["']https?:\/\/([^"'/]+)/g)].map((m) => m[1]))]
414
+ .filter((h) => !h.endsWith(HOST.replace(/^www\./, '')))
415
+ .sort();
416
+
417
+ const formActions = [...new Set([...corpus.matchAll(/<form[^>]+action=["']([^"']+)/gi)].map((m) => m[1]))];
418
+
419
+ const headRes = await req(`${ORIGIN}/`, { method: 'HEAD' });
420
+ const fingerprint = ['server', 'x-powered-by', 'x-pingback', 'x-generator']
421
+ .map((h) => [h, headRes?.headers.get(h)])
422
+ .filter(([, v]) => v);
423
+ const generator = /<meta[^>]+name=["']generator["'][^>]*content=["']([^"']+)/i.exec(home)?.[1];
424
+
425
+ for (const [label, list] of [
426
+ ['Analytics IDs', ids],
427
+ ['Vendors in markup', vendors],
428
+ ['Third-party origins', origins.slice(0, 12)],
429
+ ['Form actions', formActions],
430
+ ]) {
431
+ console.log(` ${BOLD}${label}${RESET} ${list.length ? '' : `${DIM}none found${RESET}`}`);
432
+ for (const item of list) console.log(` ${item}`);
433
+ }
434
+ if (generator) console.log(` ${BOLD}Generator${RESET}\n ${generator}`);
435
+ for (const [h, v] of fingerprint) console.log(` ${h}: ${v}`);
436
+
437
+ writeFileSync(
438
+ `${OUT}/integrations.md`,
439
+ `# Integration inventory — ${ORIGIN}\n\n` +
440
+ `Detected from markup. **Detect first, ask second** — stacks.md §1b.\n\n` +
441
+ `Every line below becomes a roadmap line that ends in a verified state or an explicit\n` +
442
+ `drop with a date. No third state.\n\n` +
443
+ `## Roadmap lines\n\n` +
444
+ (ids.length || vendors.length
445
+ ? [
446
+ ...ids.map((id) => `- [ ] ${id.startsWith('GTM') ? 'Tag Manager' : id.startsWith('AW') ? 'Google Ads' : id.startsWith('UA') ? 'Universal Analytics (dead — do not port)' : 'GA4'} · \`${id}\` · not ported · ⚠ confirm the client owns this account`),
447
+ ...vendors.map((v) => `- [ ] ${v} · detected in markup · not ported · ⚠ owner unknown`),
448
+ ].join('\n') + '\n\n'
449
+ : '_Nothing detected in the sampled pages._\n\n') +
450
+ `## Third-party origins\n\n` +
451
+ (origins.length ? origins.map((o) => `- \`${o}\``).join('\n') + '\n\n' : '_None._\n\n') +
452
+ `## Where forms post\n\n` +
453
+ (formActions.length ? formActions.map((a) => `- \`${a}\``).join('\n') + '\n\n' : '_No forms in the sampled pages._\n\n') +
454
+ `## Ask the client — markup cannot answer these\n\n` +
455
+ `1. **Who owns the account** — the client, a previous agency, or someone unreachable?\n` +
456
+ ` An unreachable owner on a GA4 property or a registrar blocks go-live, not launch day.\n` +
457
+ `2. **Does it need to keep working**, or was it inherited and forgotten?\n` +
458
+ `3. **Where does its data live**, and does anything need exporting before the old site dies?\n\n` +
459
+ `The list of what was **deliberately dropped** matters as much as what was carried — it is\n` +
460
+ `the difference between a decision and an omission.\n`,
461
+ );
462
+ console.log(`\n ${GREEN}✓${RESET} ${OUT}/integrations.md`);
463
+
464
+ /* ── What this cannot see ─────────────────────────────────────────────── */
465
+ console.log(`\n${BOLD}── What this cannot see ${'─'.repeat(34)}${RESET}`);
466
+ for (const line of [
467
+ 'Anything injected by JavaScript after load — including most GTM tags',
468
+ 'Anything behind a login, a paywall or a members area',
469
+ 'Pages in neither the sitemap nor the Wayback Machine',
470
+ 'Whether a detected vendor is still in use, or just left in the theme',
471
+ ...notes,
472
+ ]) {
473
+ console.log(` ${DIM}·${RESET} ${DIM}${line}${RESET}`);
474
+ }
475
+
476
+ console.log(
477
+ `\n${GREEN}✓${RESET} inventory written to ${OUT}/ — ${all.length} URL(s), ` +
478
+ `${present.length} preserved path(s), ${ids.length + vendors.length} integration(s)\n` +
479
+ `${DIM} Next: the redirect map in public/_redirects, then stacks.md §1b's three questions.${RESET}\n`,
480
+ );