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,148 @@
1
+ /**
2
+ * WCAG 2.2 §1.4.10 Reflow and §1.4.4 Resize Text, against the DEPLOYED site.
3
+ *
4
+ * npm run reflow # every route, staging
5
+ * npm run reflow -- --only=/pricing/ # one route, substring match
6
+ * npm run reflow -- https://example.com # a different host
7
+ *
8
+ * Reflow: content must work at a 320 CSS-pixel width without a second scroll
9
+ * axis. Resize: text to 200% without loss of content or function.
10
+ *
11
+ * ── WHY THIS EXISTS ────────────────────────────────────────────────────────
12
+ * `/accessibility/` states that both were tested and passed. On one build that
13
+ * was true when written and false a day later, because a redesign rebuilt every
14
+ * route. A published accessibility statement had been carrying a specific
15
+ * testing claim about a site that no longer existed, and nothing anywhere would
16
+ * have caught it.
17
+ *
18
+ * So this is not really a test, it is the thing that keeps a sentence on a legal
19
+ * page honest. Re-run it whenever layout moves, and update the `reviewed` date
20
+ * in src/pages/accessibility.astro when you do.
21
+ *
22
+ * ── ROUTES ARE DISCOVERED, NEVER LISTED ────────────────────────────────────
23
+ * An earlier version carried a hardcoded array. It was one project's routes,
24
+ * and it shipped inside this template — passing happily while testing pages
25
+ * that did not exist. See scripts/lib/routes.mjs.
26
+ */
27
+
28
+ import puppeteer from 'puppeteer';
29
+ import { discoverRoutes } from './lib/routes.mjs';
30
+
31
+ const args = process.argv.slice(2);
32
+ const only = (args.find((a) => a.startsWith('--only=')) ?? '').replace('--only=', '');
33
+ const host = args.find((a) => !a.startsWith('--'));
34
+ const BASE = (host ?? process.env.PUBLIC_SITE_URL ?? 'http://localhost:8788').replace(/\/$/, '');
35
+
36
+ /*
37
+ * ⚠ PROJECT CONFIG — selectors for fixed-position third-party overlays.
38
+ *
39
+ * A chat bubble or cookie banner pinned to the viewport is not layout, but it
40
+ * is measured as if it were, so a widget hanging off the right edge reports as
41
+ * a reflow failure on every route at once. Removing it before measuring is
42
+ * correct; removing anything of your own is cheating.
43
+ *
44
+ * Empty by default — add only what a vendor injects, e.g. '[id*=chat-widget]'.
45
+ */
46
+ const VENDOR_OVERLAYS = [];
47
+
48
+ const { routes: discovered, source } = await discoverRoutes(BASE);
49
+
50
+ const ROUTES = discovered
51
+ .map((url) => new URL(url).pathname)
52
+ .filter((r) => !only || only.split(',').some((frag) => r.includes(frag.trim())));
53
+
54
+ if (!discovered.length) {
55
+ console.error(
56
+ `No routes found for ${BASE}.\n` +
57
+ ' Build first (`npm run build:staging`), or pass a host that serves a sitemap.',
58
+ );
59
+ process.exit(1);
60
+ }
61
+ if (!ROUTES.length) {
62
+ console.error(`No route matches --only=${only}. Discovered ${discovered.length} from ${source}.`);
63
+ process.exit(1);
64
+ }
65
+
66
+ console.log(`${ROUTES.length} route(s) from ${source}, against ${BASE}`);
67
+
68
+ /*
69
+ * The check runs in the page. `scrollWidth - clientWidth` on the documentElement
70
+ * is the only reliable measure of a second scroll axis; a per-element sweep then
71
+ * NAMES the widest offender, so one overflowing table is identifiable rather
72
+ * than the page simply being "broken".
73
+ */
74
+ const CHECK = () => {
75
+ const d = document.documentElement;
76
+ const over = d.scrollWidth - d.clientWidth;
77
+ const wide = [];
78
+ if (over > 1) {
79
+ for (const el of document.querySelectorAll('body *')) {
80
+ const r = el.getBoundingClientRect();
81
+ if (r.width > d.clientWidth + 1 && r.height > 0) {
82
+ const cls =
83
+ typeof el.className === 'string' && el.className.trim()
84
+ ? '.' + el.className.trim().split(/\s+/)[0]
85
+ : '';
86
+ const sel = el.tagName.toLowerCase() + cls;
87
+ if (!wide.some((w) => w.sel === sel)) wide.push({ sel, w: Math.round(r.width) });
88
+ }
89
+ }
90
+ }
91
+ return { over, wide: wide.slice(0, 3) };
92
+ };
93
+
94
+ const CASES = [
95
+ { label: 'reflow — 320px wide', w: 320, h: 640, scale: null },
96
+ { label: 'resize text — 200%', w: 1280, h: 900, scale: '200%' },
97
+ ];
98
+
99
+ const browser = await puppeteer.launch({ args: ['--no-sandbox'] });
100
+ let failures = 0;
101
+
102
+ try {
103
+ for (const c of CASES) {
104
+ console.log(`\n── ${c.label} ${'─'.repeat(Math.max(0, 46 - c.label.length))}`);
105
+ const page = await browser.newPage();
106
+ await page.setViewport({ width: c.w, height: c.h });
107
+
108
+ for (const route of ROUTES) {
109
+ try {
110
+ await page.goto(BASE + route, { waitUntil: 'domcontentloaded', timeout: 45000 });
111
+ await new Promise((r) => setTimeout(r, 1400));
112
+
113
+ if (VENDOR_OVERLAYS.length) {
114
+ await page.evaluate((selectors) => {
115
+ for (const s of selectors) document.querySelectorAll(s).forEach((e) => e.remove());
116
+ }, VENDOR_OVERLAYS);
117
+ }
118
+
119
+ if (c.scale) {
120
+ await page.evaluate((s) => {
121
+ document.documentElement.style.fontSize = s;
122
+ }, c.scale);
123
+ await new Promise((r) => setTimeout(r, 700));
124
+ }
125
+
126
+ const res = await page.evaluate(CHECK);
127
+ if (res.over > 1) {
128
+ failures++;
129
+ const named = res.wide.map((x) => `${x.sel} (${x.w}px)`).join(', ');
130
+ console.log(` FAIL ${route.padEnd(34)} overflow ${res.over}px ${named}`);
131
+ }
132
+ } catch (e) {
133
+ failures++;
134
+ console.log(` ERROR ${route.padEnd(33)} ${e.name}`);
135
+ }
136
+ }
137
+ await page.close();
138
+ }
139
+ } finally {
140
+ await browser.close();
141
+ }
142
+
143
+ console.log(
144
+ failures === 0
145
+ ? `\n✓ no horizontal scroll at 320px or at 200% text, across ${ROUTES.length} route(s)`
146
+ : `\n✗ ${failures} failure(s)`,
147
+ );
148
+ process.exit(failures === 0 ? 0 : 1);
@@ -0,0 +1,113 @@
1
+ /**
2
+ * A URL cannot be in the sitemap AND serve `noindex`.
3
+ *
4
+ * node scripts/check-sitemap.mjs # after a production build
5
+ *
6
+ * Runs at the end of `build:production`, against dist/ — so the contradiction
7
+ * is caught before the deploy rather than after, which is the only part
8
+ * `npm run verify` cannot do.
9
+ *
10
+ * ── THE FAILURE THIS EXISTS FOR ────────────────────────────────────────────
11
+ * A URL in the sitemap is a request to index it. The same URL serving
12
+ * `noindex` is a refusal. Search Console reports the pair as **"Submitted URL
13
+ * marked 'noindex'" — an error, not a warning**, counted against the whole
14
+ * submission, so one contradictory page devalues the file every other page is
15
+ * listed in.
16
+ *
17
+ * Seen on a live site: a `/search/` page sat in the sitemap and served
18
+ * `noindex` from the day it was built. Nothing compares the two lists, so it
19
+ * stayed invisible until the sitemap was actually submitted, weeks later.
20
+ *
21
+ * ── WHY A CHECK AND NOT A FIX ──────────────────────────────────────────────
22
+ * The obvious fix is to make the sitemap derive from the pages' own `noindex`.
23
+ * It cannot: `@astrojs/sitemap` decides inclusion in astro.config.mjs, which is
24
+ * evaluated before any page renders, so at that moment nothing knows which
25
+ * routes will emit the tag. The exclusion list in the config and the pages
26
+ * themselves are two sources of truth by construction — this is what keeps them
27
+ * honest.
28
+ */
29
+
30
+ import { existsSync, readdirSync, readFileSync, statSync } from 'node:fs';
31
+ import { join, relative } from 'node:path';
32
+
33
+ const RESET = '[0m';
34
+ const RED = '[31m';
35
+ const GREEN = '[32m';
36
+ const DIM = '[2m';
37
+
38
+ const root = ['dist/client', 'dist'].find((d) => existsSync(d));
39
+ if (!root) {
40
+ console.error('check-sitemap: no dist/ — run a build first.');
41
+ process.exit(1);
42
+ }
43
+
44
+ /* Staging emits no sitemap, deliberately. Nothing to contradict. */
45
+ const sitemaps = readdirSync(root).filter((f) => /^sitemap.*\.xml$/.test(f));
46
+ if (!sitemaps.length) {
47
+ console.log(`${DIM}· no sitemap in ${root}/ — staging build, nothing to check${RESET}`);
48
+ process.exit(0);
49
+ }
50
+
51
+ const listed = new Set();
52
+ for (const file of sitemaps) {
53
+ const xml = readFileSync(join(root, file), 'utf8');
54
+ for (const m of xml.matchAll(/<loc>([^<]+)<\/loc>/g)) {
55
+ const loc = m[1].trim();
56
+ if (/sitemap.*\.xml$/i.test(loc)) continue; // an index pointing at its children
57
+ try {
58
+ listed.add(new URL(loc).pathname);
59
+ } catch {
60
+ /* ignore a malformed loc — the build would have failed elsewhere */
61
+ }
62
+ }
63
+ }
64
+
65
+ const walk = (dir) =>
66
+ readdirSync(dir).flatMap((entry) => {
67
+ const full = join(dir, entry);
68
+ return statSync(full).isDirectory() ? walk(full) : [full];
69
+ });
70
+
71
+ /*
72
+ * Match the META TAG, not the word. The accessibility page explains what
73
+ * `noindex` means in prose, and a substring search reports it as noindex —
74
+ * a false positive that would train someone to ignore this check.
75
+ */
76
+ const ROBOTS_META = /<meta[^>]+name=["']robots["'][^>]+content=["'][^"']*\bnoindex\b/i;
77
+
78
+ const noindexed = walk(root)
79
+ .filter((f) => f.endsWith('.html'))
80
+ .filter((f) => ROBOTS_META.test(readFileSync(f, 'utf8')))
81
+ .map((f) => '/' + relative(root, f).replace(/index\.html$/, '').replace(/\.html$/, '/'))
82
+ .map((p) => (p.endsWith('/') ? p : `${p}/`));
83
+
84
+ const contradictions = noindexed.filter((p) => listed.has(p));
85
+
86
+ if (!contradictions.length) {
87
+ console.log(
88
+ `${GREEN}✓${RESET} sitemap and noindex agree ` +
89
+ `${DIM}(${listed.size} listed, ${noindexed.length} noindex, no overlap)${RESET}`,
90
+ );
91
+ process.exit(0);
92
+ }
93
+
94
+ console.error(`\n${RED}✗ ${contradictions.length} URL(s) are in the sitemap AND serve noindex${RESET}\n`);
95
+ for (const p of contradictions) console.error(` ${p}`);
96
+
97
+ console.error(
98
+ `\n Search Console reports this as "Submitted URL marked 'noindex'" — an ERROR,\n` +
99
+ ` counted against the whole submission, not just these pages.\n\n` +
100
+ ` Fix ONE of the two, never leave both:\n` +
101
+ ` · exclude the route in the sitemap \`filter\` in astro.config.mjs, or\n` +
102
+ ` · stop the page emitting noindex\n`,
103
+ );
104
+
105
+ if (contradictions.includes('/')) {
106
+ console.error(
107
+ ` ${DIM}'/' is here because the scaffold home page sets noindex while \`unbuilt\`\n` +
108
+ ` is true. Replace src/pages/index.astro with the real page — a production\n` +
109
+ ` build of the template skeleton is not a thing to deploy.${RESET}\n`,
110
+ );
111
+ }
112
+
113
+ process.exit(1);
@@ -0,0 +1,267 @@
1
+ /**
2
+ * Snapshot a domain's public DNS, before you touch it — and compare after.
3
+ *
4
+ * npm run dns -- example.com # capture → recon/dns.md
5
+ * npm run dns -- example.com --compare # what changed since the capture
6
+ *
7
+ * ── WHY THIS EXISTS ────────────────────────────────────────────────────────
8
+ * A rebuild moves the apex. Everything else in the zone is somebody else's
9
+ * service, and the kit had **no mention of MX anywhere** before this file.
10
+ *
11
+ * **Breaking MX kills the client's email.** That is worse than any failure this
12
+ * kit documents: a dead site is visible in minutes and someone shouts, while
13
+ * dead email is silent. Senders get bounces the client never sees, they assume
14
+ * a quiet week, and it surfaces days later when an invoice did not arrive.
15
+ *
16
+ * The same logic as `stacks.md` §1d — paths other systems point at — one layer
17
+ * down. These are records other systems depend on, and losing one is silent.
18
+ *
19
+ * ── IT RECORDS AND WARNS. IT NEVER FAILS THE RUN ───────────────────────────
20
+ * DNS lives outside the repo, a project can legitimately have no MX, and a
21
+ * check nobody can make green is a check everybody learns to ignore.
22
+ *
23
+ * ── WHAT IT CANNOT SEE ─────────────────────────────────────────────────────
24
+ * It reads PUBLIC DNS: what the world resolves, not what sits in the
25
+ * registrar's panel. A record that exists but is not published is invisible
26
+ * here, and so is anything proxied behind a provider that answers differently
27
+ * per network. Treat it as a verification tool, never as the source of truth
28
+ * for the zone.
29
+ */
30
+
31
+ import { mkdirSync, readFileSync, writeFileSync, existsSync } from 'node:fs';
32
+ import { Resolver } from 'node:dns/promises';
33
+
34
+ const RESET = '';
35
+ const RED = '';
36
+ const GREEN = '';
37
+ const YELLOW = '';
38
+ const DIM = '';
39
+ const BOLD = '';
40
+
41
+ const args = process.argv.slice(2);
42
+ const compare = args.includes('--compare');
43
+ const domain = args.find((a) => !a.startsWith('--'))?.replace(/^https?:\/\//, '').replace(/\/.*$/, '');
44
+
45
+ if (!domain) {
46
+ console.error('usage: npm run dns -- example.com [--compare]');
47
+ process.exit(1);
48
+ }
49
+
50
+ const OUT = 'recon/dns.md';
51
+ const SNAP = 'recon/dns.json';
52
+
53
+ /*
54
+ * A public resolver rather than the system one. The machine running this may
55
+ * sit behind a split-horizon or corporate resolver that answers differently
56
+ * from the internet, and the whole point is to see what the world sees.
57
+ */
58
+ const resolver = new Resolver({ timeout: 5000, tries: 2 });
59
+ resolver.setServers(['1.1.1.1', '8.8.8.8']);
60
+
61
+ const ask = async (name, fn) => {
62
+ try {
63
+ return await fn();
64
+ } catch (e) {
65
+ if (['ENODATA', 'ENOTFOUND', 'ESERVFAIL'].includes(e.code)) return [];
66
+ return [];
67
+ }
68
+ };
69
+
70
+ const zone = {
71
+ domain,
72
+ capturedAt: new Date().toISOString(),
73
+ ns: await ask('NS', () => resolver.resolveNs(domain)),
74
+ a: await ask('A', () => resolver.resolve4(domain, { ttl: true })),
75
+ aaaa: await ask('AAAA', () => resolver.resolve6(domain, { ttl: true })),
76
+ mx: await ask('MX', () => resolver.resolveMx(domain)),
77
+ txt: (await ask('TXT', () => resolver.resolveTxt(domain))).map((r) => r.join('')),
78
+ caa: await ask('CAA', () => resolver.resolveCaa(domain)),
79
+ wwwCname: await ask('CNAME', () => resolver.resolveCname(`www.${domain}`)),
80
+ /* Selectors are per-provider and unguessable in general; these three cover
81
+ Google Workspace, Microsoft 365 and most ESPs. A miss here is not a
82
+ finding — it is a limit, and the report says so. */
83
+ dkim: {},
84
+ dmarc: (await ask('DMARC', () => resolver.resolveTxt(`_dmarc.${domain}`))).map((r) => r.join('')),
85
+ };
86
+
87
+ for (const selector of ['google', 'selector1', 'selector2', 'k1', 'default']) {
88
+ const rec = await ask('DKIM', () => resolver.resolveTxt(`${selector}._domainkey.${domain}`));
89
+ if (rec.length) zone.dkim[selector] = rec.map((r) => r.join(''));
90
+ }
91
+
92
+ /* ── Read the zone ─────────────────────────────────────────────────────── */
93
+
94
+ const spf = zone.txt.filter((t) => /^v=spf1/i.test(t));
95
+ const verifications = zone.txt.filter((t) =>
96
+ /google-site-verification|MS=|facebook-domain-verification|apple-domain-verification|_?bing|stripe-verification|atlassian|docusign|adobe-idp/i.test(t),
97
+ );
98
+
99
+ /*
100
+ * Cloudflare issues certificates through Google Trust Services, Let's Encrypt
101
+ * and SSL.com. A CAA record that names none of them blocks issuance — the
102
+ * deploy succeeds, DNS cuts over, and the site serves a TLS error nothing in
103
+ * the repo can fix. It is the classic launch-day emergency and it is invisible
104
+ * until the moment it is not.
105
+ */
106
+ const CF_ISSUERS = ['pki.goog', 'letsencrypt.org', 'ssl.com', 'digicert.com', 'comodoca.com'];
107
+ const caaIssuers = zone.caa.map((r) => r.issue ?? r.issuewild).filter(Boolean);
108
+ const caaBlocks =
109
+ caaIssuers.length > 0 && !caaIssuers.some((i) => CF_ISSUERS.some((ok) => String(i).includes(ok)));
110
+
111
+ /* ── Report ────────────────────────────────────────────────────────────── */
112
+
113
+ console.log(`${BOLD}── ${domain} ${'─'.repeat(Math.max(0, 50 - domain.length))}${RESET}`);
114
+
115
+ const line = (mark, label, value) => console.log(` ${mark} ${label.padEnd(26)} ${value}`);
116
+
117
+ line(zone.ns.length ? `${GREEN}✓${RESET}` : `${RED}✗${RESET}`, 'nameservers', zone.ns.join(', ') || '—');
118
+ line(zone.a.length ? `${GREEN}✓${RESET}` : `${DIM}·${RESET}`, 'A',
119
+ zone.a.map((r) => `${r.address} (ttl ${r.ttl}s)`).join(', ') || '—');
120
+ line(zone.wwwCname.length ? `${GREEN}✓${RESET}` : `${DIM}·${RESET}`, 'www', zone.wwwCname.join(', ') || '—');
121
+
122
+ if (zone.mx.length) {
123
+ line(`${YELLOW}!${RESET}`, 'MX — EMAIL LIVES HERE',
124
+ zone.mx.map((m) => `${m.exchange} (${m.priority})`).join(', '));
125
+ console.log(
126
+ ` ${DIM}Carry these across before you move the apex. Losing MX kills the\n` +
127
+ ` client's email, and it fails silently — the bounces go to senders.${RESET}`,
128
+ );
129
+ } else {
130
+ line(`${DIM}·${RESET}`, 'MX', 'none — email is not on this domain');
131
+ }
132
+
133
+ line(spf.length ? `${GREEN}✓${RESET}` : `${DIM}·${RESET}`, 'SPF', spf[0] ?? '—');
134
+ line(zone.dmarc.length ? `${GREEN}✓${RESET}` : `${DIM}·${RESET}`, 'DMARC', zone.dmarc[0] ?? '—');
135
+ line(Object.keys(zone.dkim).length ? `${GREEN}✓${RESET}` : `${DIM}·${RESET}`, 'DKIM selectors',
136
+ Object.keys(zone.dkim).join(', ') || 'none found at common selectors');
137
+
138
+ if (caaBlocks) {
139
+ line(`${RED}✗${RESET}`, 'CAA', caaIssuers.join(', '));
140
+ console.log(
141
+ ` ${DIM}None of these is a CA Cloudflare issues through. Certificate\n` +
142
+ ` issuance will FAIL after cutover and the site will serve a TLS\n` +
143
+ ` error nothing in the repo can fix. Add one before you cut over.${RESET}`,
144
+ );
145
+ } else {
146
+ line(zone.caa.length ? `${GREEN}✓${RESET}` : `${DIM}·${RESET}`, 'CAA',
147
+ caaIssuers.join(', ') || 'none — any CA may issue');
148
+ }
149
+
150
+ if (verifications.length) {
151
+ line(`${YELLOW}!${RESET}`, 'verification TXT', `${verifications.length} record(s)`);
152
+ for (const v of verifications) console.log(` ${DIM}${v.slice(0, 78)}${RESET}`);
153
+ console.log(` ${DIM}Each one belongs to a service that will silently lose access.${RESET}`);
154
+ }
155
+
156
+ const lowestTtl = Math.min(...zone.a.map((r) => r.ttl), Infinity);
157
+ if (Number.isFinite(lowestTtl)) {
158
+ const ready = lowestTtl <= 600;
159
+ line(ready ? `${GREEN}✓${RESET}` : `${YELLOW}!${RESET}`, 'TTL on A',
160
+ `${lowestTtl}s${ready ? '' : ' — lower to 300s at least 24h before cutover'}`);
161
+ }
162
+
163
+ /* ── Compare, or capture ───────────────────────────────────────────────── */
164
+
165
+ mkdirSync('recon', { recursive: true });
166
+
167
+ if (compare) {
168
+ if (!existsSync(SNAP)) {
169
+ console.error(`\n${RED}✗${RESET} no ${SNAP} to compare against. Run without --compare first.`);
170
+ process.exit(1);
171
+ }
172
+ const before = JSON.parse(readFileSync(SNAP, 'utf8'));
173
+ console.log(`\n${BOLD}── Changed since ${before.capturedAt.slice(0, 10)} ${'─'.repeat(24)}${RESET}`);
174
+
175
+ const sets = {
176
+ MX: [before.mx.map((m) => m.exchange).sort(), zone.mx.map((m) => m.exchange).sort()],
177
+ SPF: [before.txt.filter((t) => /^v=spf1/i.test(t)), spf],
178
+ DMARC: [before.dmarc, zone.dmarc],
179
+ 'verification TXT': [
180
+ before.txt.filter((t) => /verification|MS=/i.test(t)),
181
+ verifications,
182
+ ],
183
+ CAA: [before.caa.map((r) => r.issue ?? r.issuewild), caaIssuers],
184
+ nameservers: [before.ns.sort(), zone.ns.sort()],
185
+ };
186
+
187
+ let lost = 0;
188
+ for (const [label, [was, now]] of Object.entries(sets)) {
189
+ const gone = was.filter((v) => !now.includes(v));
190
+ if (gone.length) {
191
+ lost++;
192
+ console.log(` ${RED}✗${RESET} ${label} LOST: ${gone.join(', ')}`);
193
+ } else if (JSON.stringify(was) !== JSON.stringify(now)) {
194
+ console.log(` ${YELLOW}!${RESET} ${label} changed (nothing lost)`);
195
+ }
196
+ }
197
+ console.log(
198
+ lost
199
+ ? `\n${RED}${lost} record group(s) lost something that was there before cutover.${RESET}\n`
200
+ : `\n${GREEN}✓ nothing that was published before the cutover has been lost.${RESET}\n`,
201
+ );
202
+ } else {
203
+ writeFileSync(SNAP, JSON.stringify(zone, null, 2) + '\n');
204
+ writeFileSync(OUT, report());
205
+ console.log(`\n${GREEN}✓${RESET} ${OUT} and ${SNAP}`);
206
+ console.log(
207
+ `${DIM} Commit both. The JSON is the rollback artefact — after cutover run\n` +
208
+ ` \`npm run dns -- ${domain} --compare\` and it will tell you what was lost.${RESET}`,
209
+ );
210
+ }
211
+
212
+ console.log(
213
+ `${DIM}\n Reads PUBLIC DNS: what the world resolves, not what is in the registrar's\n` +
214
+ ` panel. A record that exists but is not published is invisible here.${RESET}\n`,
215
+ );
216
+
217
+ function report() {
218
+ const rows = (title, items) =>
219
+ `### ${title}\n\n` + (items.length ? items.map((i) => `- \`${i}\``).join('\n') : '_none_') + '\n\n';
220
+
221
+ return `# DNS — ${domain}
222
+
223
+ Captured ${zone.capturedAt}. **Public DNS only** — what the world resolves, not what is in
224
+ the registrar's panel.
225
+
226
+ ⚠ **This is the rollback artefact for the cutover.** Commit it. After moving the apex, run
227
+ \`npm run dns -- ${domain} --compare\` to see what stopped resolving.
228
+
229
+ ## The two that end launches
230
+
231
+ **MX — ${zone.mx.length ? `${zone.mx.length} record(s). EMAIL LIVES ON THIS DOMAIN.` : 'none. Email is not on this domain.'}**
232
+
233
+ ${
234
+ zone.mx.length
235
+ ? zone.mx.map((m) => `- \`${m.exchange}\` priority ${m.priority}`).join('\n') +
236
+ `\n\nCarry these before moving the apex. Losing MX kills the client's email and does it\nsilently — the bounce goes to the sender, not to them. A dead site gets a phone call in\nminutes; dead email gets noticed when an invoice does not arrive.\n\n`
237
+ : 'Nothing to preserve. Confirm it with the client anyway — email on a subdomain or a\nseparate provider still breaks if the nameservers move.\n\n'
238
+ }**CAA — ${caaIssuers.length ? caaIssuers.join(', ') : 'none, any CA may issue'}**
239
+
240
+ ${
241
+ caaBlocks
242
+ ? '⚠ **None of these is a CA Cloudflare issues through** (Google Trust Services, ' +
243
+ "Let's Encrypt, SSL.com). Certificate issuance will fail after cutover and the site will\nserve a TLS error that nothing in the repo can fix. Fix this before the cutover, not during.\n\n"
244
+ : 'No blocker. If a CAA record is added later it must include the host\'s CA.\n\n'
245
+ }## Records
246
+
247
+ ${rows('Nameservers', zone.ns)}${rows('A', zone.a.map((r) => `${r.address} ttl ${r.ttl}s`))}${rows('AAAA', zone.aaaa.map((r) => r.address))}${rows(`CNAME on www.${domain}`, zone.wwwCname)}
248
+ ## Email authentication
249
+
250
+ Losing any of these does not bounce mail immediately — it degrades deliverability, so the
251
+ symptom is "our emails started going to spam" weeks later.
252
+
253
+ ${rows('SPF', spf)}${rows('DMARC', zone.dmarc)}${rows('DKIM selectors found', Object.keys(zone.dkim))}
254
+ DKIM selectors are per-provider and cannot be enumerated. Only common ones were probed, so an
255
+ empty list here means *not found*, never *not present*.
256
+
257
+ ## Verification records
258
+
259
+ ${rows('Ownership / verification TXT', verifications)}
260
+ Each belongs to a service — Search Console, Bing, Meta, a payment provider — that loses access
261
+ silently when the record goes. Same rule as the file-based verification in \`stacks.md\` §1d.
262
+
263
+ ## All TXT
264
+
265
+ ${rows('TXT', zone.txt)}
266
+ `;
267
+ }