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.
- package/README.md +54 -0
- package/index.mjs +149 -0
- package/package.json +42 -0
- package/template/.dev.vars.example +3 -0
- package/template/.github/workflows/gates.yml +58 -0
- package/template/.node-version +1 -0
- package/template/.pa11yci.json +24 -0
- package/template/BUILD-STATE.md +47 -0
- package/template/CLAUDE.md +153 -0
- package/template/astro.config.mjs +150 -0
- package/template/docs/analytics.md +86 -0
- package/template/docs/content.md +138 -0
- package/template/docs/handover.md +182 -0
- package/template/docs/handover.pdf +0 -0
- package/template/docs/runbook.md +661 -0
- package/template/docs/traps.md +903 -0
- package/template/gitignore +31 -0
- package/template/package-lock.json +8159 -0
- package/template/package.json +53 -0
- package/template/public/_headers +61 -0
- package/template/public/_redirects +39 -0
- package/template/public/site.webmanifest +13 -0
- package/template/scripts/a11y-evidence.mjs +258 -0
- package/template/scripts/check-console.mjs +125 -0
- package/template/scripts/check-env.mjs +99 -0
- package/template/scripts/check-reflow.mjs +148 -0
- package/template/scripts/check-sitemap.mjs +113 -0
- package/template/scripts/dns-snapshot.mjs +267 -0
- package/template/scripts/extract.mjs +317 -0
- package/template/scripts/indexnow.mjs +154 -0
- package/template/scripts/lastmod.mjs +147 -0
- package/template/scripts/lib/inventory.mjs +104 -0
- package/template/scripts/lib/preserved.mjs +42 -0
- package/template/scripts/lib/routes.mjs +92 -0
- package/template/scripts/md-to-pdf.mjs +335 -0
- package/template/scripts/og-cards.config.mjs +114 -0
- package/template/scripts/og-cards.mjs +487 -0
- package/template/scripts/optimize-media.mjs +380 -0
- package/template/scripts/recon.mjs +480 -0
- package/template/scripts/redirects.mjs +298 -0
- package/template/scripts/shots.mjs +447 -0
- package/template/scripts/staging-headers.mjs +102 -0
- package/template/scripts/tells.mjs +268 -0
- package/template/scripts/verify.mjs +1069 -0
- package/template/src/components/ContactForm.astro +405 -0
- package/template/src/components/CtaBand.astro +82 -0
- package/template/src/components/EnvBadge.astro +146 -0
- package/template/src/components/Footer.astro +210 -0
- package/template/src/components/Header.astro +530 -0
- package/template/src/components/Icon.astro +56 -0
- package/template/src/components/Img.astro +129 -0
- package/template/src/components/PageHero.astro +88 -0
- package/template/src/components/Seo.astro +119 -0
- package/template/src/components/StructuredData.astro +173 -0
- package/template/src/content/blog/.gitkeep +5 -0
- package/template/src/content/legal/.gitkeep +0 -0
- package/template/src/content.config.ts +81 -0
- package/template/src/data/areas.ts +31 -0
- package/template/src/data/business.ts +121 -0
- package/template/src/data/categories.ts +37 -0
- package/template/src/data/fonts.ts +25 -0
- package/template/src/data/image-manifest.json +1 -0
- package/template/src/data/lastmod.json +1 -0
- package/template/src/data/nav.ts +49 -0
- package/template/src/data/services.ts +39 -0
- package/template/src/data/site.ts +136 -0
- package/template/src/env.d.ts +28 -0
- package/template/src/layouts/Base.astro +223 -0
- package/template/src/lib/brevo.ts +96 -0
- package/template/src/lib/hast-media.mjs +55 -0
- package/template/src/lib/lastmod.mjs +47 -0
- package/template/src/lib/lead.ts +92 -0
- package/template/src/lib/legal-routes.mjs +31 -0
- package/template/src/lib/legal.ts +75 -0
- package/template/src/lib/posts.ts +64 -0
- package/template/src/lib/runtime.ts +33 -0
- package/template/src/pages/404.astro +51 -0
- package/template/src/pages/[slug].astro +111 -0
- package/template/src/pages/accessibility.astro +128 -0
- package/template/src/pages/api/contact.ts +191 -0
- package/template/src/pages/api/leads.csv.ts +82 -0
- package/template/src/pages/contact.astro +112 -0
- package/template/src/pages/index.astro +84 -0
- package/template/src/pages/robots.txt.ts +38 -0
- package/template/src/pages/rss.xml.ts +27 -0
- package/template/src/styles/global.css +463 -0
- package/template/src/styles/project.css +14 -0
- package/template/src/styles/prose.css +182 -0
- package/template/src/styles/tokens.css +218 -0
- package/template/tsconfig.json +5 -0
- 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 = '[0m';
|
|
35
|
+
const RED = '[31m';
|
|
36
|
+
const GREEN = '[32m';
|
|
37
|
+
const YELLOW = '[33m';
|
|
38
|
+
const DIM = '[2m';
|
|
39
|
+
const BOLD = '[1m';
|
|
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
|
+
}
|