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