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,380 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * media/source/ → public/img/
4
+ *
5
+ * photos/, blog/ → WebP at responsive widths
6
+ * certifications/ → WebP, single width (already small logos)
7
+ * brand/ → copied byte-for-byte (SVG logos, favicons)
8
+ *
9
+ * Every output is recorded in src/data/image-manifest.json with its intrinsic
10
+ * dimensions, so <img> can always carry width/height and the page never shifts.
11
+ *
12
+ * Run with `npm run media`. Idempotent — skips work when the output is newer
13
+ * than its source.
14
+ */
15
+
16
+ import sharp from 'sharp';
17
+ import fs from 'node:fs/promises';
18
+ import path from 'node:path';
19
+ import { fileURLToPath } from 'node:url';
20
+
21
+ const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
22
+ const SRC = path.join(ROOT, 'media/source');
23
+ const OUT = path.join(ROOT, 'public/img');
24
+ const MANIFEST = path.join(ROOT, 'src/data/image-manifest.json');
25
+
26
+ /** Widths emitted for photographic content. */
27
+ const PHOTO_WIDTHS = [480, 768, 1200, 1800];
28
+ const BLOG_WIDTHS = [480, 768, 1200];
29
+ const WEBP = { quality: 78, effort: 6 };
30
+
31
+ /*
32
+ * ── AVIF, emitted ALONGSIDE WebP and never instead of it ───────────────────
33
+ *
34
+ * Measured on this pipeline, a 4096x2160 photograph at the widths below, with
35
+ * AVIF quality chosen by RMSE parity against `webp q78` rather than by feel:
36
+ *
37
+ * 1800px webp q78 154 KB avif q55 115 KB −26%, better quality
38
+ * 1200px webp q78 82 KB avif q55 61 KB −26%, better quality
39
+ *
40
+ * q55 rather than q50 on purpose. q50 is −38%, but it is marginally WORSE than
41
+ * the WebP it replaces, and photography is the largest single determinant of
42
+ * whether a site reads as expensive. q55 is smaller *and* better — no trade.
43
+ * Drop to 50 if bytes matter more than the last of the quality on a given job.
44
+ *
45
+ * ⚠ effort 4 is deliberate; do not crank it. Measured at 1800px q55:
46
+ * effort 3 115.8 KB 269 ms
47
+ * effort 4 114.8 KB 750 ms
48
+ * effort 6 112.2 KB 2292 ms
49
+ * effort 9 114.4 KB 8155 ms ← 30x the time, and BIGGER than effort 6
50
+ * Encoding is incremental (see `newerThan`), so this is a one-time cost per
51
+ * image either way, which is exactly why it is not worth 30x.
52
+ *
53
+ * Set FORMATS to ['webp'] to turn AVIF off for a project — nothing else needs
54
+ * to change, and Img.astro drops the <source> on its own.
55
+ */
56
+ const AVIF = { quality: 55, effort: 4 };
57
+ const FORMATS = ['avif', 'webp'];
58
+
59
+ const RASTER = /\.(jpe?g|png|webp|tiff?)$/i;
60
+
61
+ /*
62
+ * ── DO NOT RESET THIS TO {} ────────────────────────────────────────────────
63
+ * This script rebuilds the manifest by walking media/source/. Any OTHER
64
+ * generator that writes manifest keys — scripts/og-cards.mjs writes
65
+ * `social/og-*` — has no file in media/source/, so a fresh object silently
66
+ * deletes everything it owns.
67
+ *
68
+ * That happened on a live site: running `npm run media` for an unrelated image
69
+ * removed every social-card entry, every page fell back to the default card,
70
+ * the build stayed green, and it was found days later by pointing a
71
+ * share-preview tool at one URL by hand.
72
+ *
73
+ * Carrying those keys over means the two scripts can run in either order.
74
+ *
75
+ * The general rule: a generator that rewrites a shared manifest must preserve
76
+ * the keys it does not own.
77
+ */
78
+ const previous = await fs
79
+ .readFile(MANIFEST, 'utf8')
80
+ .then((raw) => JSON.parse(raw))
81
+ .catch(() => ({}));
82
+
83
+ const manifest = Object.fromEntries(
84
+ Object.entries(previous).filter(([k]) => k.startsWith('social/og-')),
85
+ );
86
+
87
+ async function walk(dir) {
88
+ const out = [];
89
+ for (const entry of await fs.readdir(dir, { withFileTypes: true })) {
90
+ const full = path.join(dir, entry.name);
91
+ if (entry.isDirectory()) out.push(...(await walk(full)));
92
+ else out.push(full);
93
+ }
94
+ return out;
95
+ }
96
+
97
+ async function newerThan(target, source) {
98
+ try {
99
+ const [t, s] = await Promise.all([fs.stat(target), fs.stat(source)]);
100
+ return t.mtimeMs >= s.mtimeMs;
101
+ } catch {
102
+ return false;
103
+ }
104
+ }
105
+
106
+ /** Brand assets are copied, never re-encoded — logos are tiny and lossless matters. */
107
+ async function copyBrand(files) {
108
+ for (const file of files) {
109
+ const rel = path.relative(SRC, file);
110
+ const dest = path.join(OUT, rel);
111
+ await fs.mkdir(path.dirname(dest), { recursive: true });
112
+ if (await newerThan(dest, file)) continue;
113
+ await fs.copyFile(file, dest);
114
+ console.log(` copy ${rel}`);
115
+ }
116
+ }
117
+
118
+ async function emitResponsive(file, widths) {
119
+ const rel = path.relative(SRC, file);
120
+ const dir = path.dirname(rel);
121
+ const name = path.basename(rel, path.extname(rel));
122
+ const image = sharp(file, { failOn: 'none' }).rotate();
123
+ const meta = await image.metadata();
124
+ const key = `${dir}/${name}`;
125
+
126
+ const sources = [];
127
+ for (const w of widths) {
128
+ // Never upscale — a 900px source has no business claiming a 1800px variant.
129
+ if (meta.width && w > meta.width && sources.length) continue;
130
+ const target = Math.min(w, meta.width ?? w);
131
+ const variant = {};
132
+ for (const fmt of FORMATS) {
133
+ const dest = path.join(OUT, dir, `${name}-${w}.${fmt}`);
134
+ await fs.mkdir(path.dirname(dest), { recursive: true });
135
+ if (!(await newerThan(dest, file))) {
136
+ const resized = image.clone().resize({ width: target, withoutEnlargement: true });
137
+ await (fmt === 'avif' ? resized.avif(AVIF) : resized.webp(WEBP)).toFile(dest);
138
+ }
139
+ variant[fmt] = `/img/${dir}/${name}-${w}.${fmt}`;
140
+ }
141
+ /* Dimensions come from the WebP, which is always emitted — it is the
142
+ fallback every browser can read, and the one <img> points at. */
143
+ const info = await sharp(path.join(OUT, dir, `${name}-${w}.webp`)).metadata();
144
+ sources.push({ width: info.width, height: info.height, src: variant.webp, avif: variant.avif });
145
+ }
146
+
147
+ const largest = sources[sources.length - 1];
148
+ manifest[key] = {
149
+ src: largest.src,
150
+ width: largest.width,
151
+ height: largest.height,
152
+ aspect: +(largest.width / largest.height).toFixed(4),
153
+ srcset: sources.map((s) => `${s.src} ${s.width}w`).join(', '),
154
+ /* Null when AVIF is off for this project, and Img.astro then emits a plain
155
+ <img> exactly as before. Never the only srcset — WebP stays the floor. */
156
+ avifSrcset: sources.every((s) => s.avif)
157
+ ? sources.map((s) => `${s.avif} ${s.width}w`).join(', ')
158
+ : null,
159
+ widths: sources.map((s) => s.width),
160
+ };
161
+ console.log(` webp ${key} ${sources.map((s) => s.width).join('/')}`);
162
+ }
163
+
164
+ async function emitSingle(file) {
165
+ const rel = path.relative(SRC, file);
166
+ const dir = path.dirname(rel);
167
+ const name = path.basename(rel, path.extname(rel));
168
+ const key = `${dir}/${name}`;
169
+ const dest = path.join(OUT, dir, `${name}.webp`);
170
+ await fs.mkdir(path.dirname(dest), { recursive: true });
171
+ if (!(await newerThan(dest, file))) {
172
+ await sharp(file, { failOn: 'none' }).webp({ ...WEBP, quality: 88 }).toFile(dest);
173
+ }
174
+ const info = await sharp(dest).metadata();
175
+ manifest[key] = {
176
+ src: `/img/${dir}/${name}.webp`,
177
+ width: info.width,
178
+ height: info.height,
179
+ aspect: +(info.width / info.height).toFixed(4),
180
+ srcset: `/img/${dir}/${name}.webp ${info.width}w`,
181
+ widths: [info.width],
182
+ };
183
+ console.log(` webp ${key} ${info.width}`);
184
+ }
185
+
186
+ /**
187
+ * Social cards stay JPEG on purpose. Facebook, LinkedIn and X still do not
188
+ * reliably render WebP og:image, and a card that fails to unfurl is worse than
189
+ * a slightly larger file nobody downloads on the site itself.
190
+ *
191
+ * The card is branded artwork with centred type, so it is passed through at its
192
+ * native 1200x675 rather than cropped to 1200x630. Cropping — especially with
193
+ * `position: 'attention'`, which picks the highest-entropy region — shifts off
194
+ * centre and clips the logo. Every major scraper accepts 16:9 happily.
195
+ */
196
+ async function emitSocialCard() {
197
+ const source = path.join(SRC, 'brand/og-default.jpg');
198
+ if (!(await fs.access(source).then(() => true).catch(() => false))) {
199
+ console.log(' skip social card — add media/source/brand/og-default.jpg (1200x630 or 16:9)');
200
+ return;
201
+ }
202
+ const dest = path.join(OUT, 'social/og-default.jpg');
203
+ await fs.mkdir(path.dirname(dest), { recursive: true });
204
+ // Skip the *encode* when the output is current — but never skip the manifest
205
+ // entry, or a warm rebuild ships a page with no og:image at all.
206
+ if (!(await newerThan(dest, source))) {
207
+ await sharp(source)
208
+ .resize({ width: 1200, withoutEnlargement: true })
209
+ .jpeg({ quality: 88, mozjpeg: true })
210
+ .toFile(dest);
211
+ }
212
+ const info = await sharp(dest).metadata();
213
+ manifest['social/og-default'] = {
214
+ src: '/img/social/og-default.jpg',
215
+ width: info.width,
216
+ height: info.height,
217
+ aspect: +(info.width / info.height).toFixed(4),
218
+ srcset: `/img/social/og-default.jpg ${info.width}w`,
219
+ widths: [info.width],
220
+ };
221
+ console.log(` jpeg social/og-default (${info.width}x${info.height}, JPEG for scraper compatibility)`);
222
+ }
223
+
224
+ /**
225
+ * JPEG twins for og:image — and only for images actually used as one.
226
+ *
227
+ * Every image the *site* serves is WebP. But Facebook and LinkedIn still fail
228
+ * to render a WebP og:image, so a shared post would unfurl with no picture at
229
+ * all. These files are never fetched by a browser rendering a page; only by a
230
+ * scraper following a meta tag.
231
+ *
232
+ * Because nothing on-page loads them, they are cropped to the 1.91:1 card
233
+ * ratio at modest quality — a preview thumbnail, not a full-size asset.
234
+ */
235
+ async function emitSocialTwins() {
236
+ // Read which images posts actually declare, rather than twinning everything
237
+ // in media/source/blog — inline body images never become an og:image.
238
+ /*
239
+ * `.catch(() => [])` because a site with no blog has no src/content/blog, and
240
+ * an unguarded readdir threw ENOENT here — AFTER every variant had been
241
+ * written to public/img/ and BEFORE the manifest was saved. The images were
242
+ * on disk, so it looked like the run had worked, and the next build failed
243
+ * with "no manifest entry" for an image that visibly existed.
244
+ *
245
+ * Found by adding the first photograph to a fresh template, which is the one
246
+ * moment this path had never been exercised.
247
+ */
248
+ const postsDir = path.join(ROOT, 'src/content/blog');
249
+ const used = new Set();
250
+ for (const entry of await fs.readdir(postsDir).catch(() => [])) {
251
+ if (!entry.endsWith('.md')) continue;
252
+ const body = await fs.readFile(path.join(postsDir, entry), 'utf8');
253
+ const declared = /^image:\s*"([^"]+)"/m.exec(body)?.[1];
254
+ const name = declared && /\/img\/[^/]+\/(.+?)(?:-\d+)?\.webp$/.exec(declared)?.[1];
255
+ if (name) used.add(name);
256
+ }
257
+
258
+ const sources = (await walk(path.join(SRC, 'blog')).catch(() => [])).filter((file) =>
259
+ used.has(path.basename(file, path.extname(file))),
260
+ );
261
+
262
+ for (const file of sources) {
263
+ const name = path.basename(file, path.extname(file));
264
+ const dest = path.join(OUT, 'social', `${name}.jpg`);
265
+ await fs.mkdir(path.dirname(dest), { recursive: true });
266
+ if (!(await newerThan(dest, file))) {
267
+ await sharp(file, { failOn: 'none' })
268
+ .rotate()
269
+ .resize(1200, 630, { fit: 'cover', position: 'centre', withoutEnlargement: true })
270
+ .flatten({ background: '#ffffff' })
271
+ .jpeg({ quality: 76, mozjpeg: true })
272
+ .toFile(dest);
273
+ }
274
+ const info = await sharp(dest).metadata();
275
+ manifest[`social/${name}`] = {
276
+ src: `/img/social/${name}.jpg`,
277
+ width: info.width,
278
+ height: info.height,
279
+ aspect: +(info.width / info.height).toFixed(4),
280
+ srcset: `/img/social/${name}.jpg ${info.width}w`,
281
+ widths: [info.width],
282
+ };
283
+ }
284
+ console.log(` jpeg ${sources.length} social twins (og:image only — never loaded on-page)`);
285
+ }
286
+
287
+ /**
288
+ * Favicons, from the single 512px brand mark.
289
+ *
290
+ * The .ico is a real ICO container rather than a PNG with the extension
291
+ * changed. Browsers sniff content and would have accepted the fake, but
292
+ * crawlers and older clients that request /favicon.ico unconditionally do not
293
+ * all sniff — and writing the header is 15 lines, not a dependency.
294
+ */
295
+ async function emitFavicons() {
296
+ // A fresh project has no brand assets yet. Skip rather than fail the build.
297
+ const haveSvg = await fs.access(path.join(SRC, 'brand/favicon.svg')).then(() => true).catch(() => false);
298
+ const havePng = await fs.access(path.join(SRC, 'brand/favicon.png')).then(() => true).catch(() => false);
299
+ if (!haveSvg && !havePng) {
300
+ console.log(' skip favicons — add media/source/brand/favicon.svg (or .png)');
301
+ return;
302
+ }
303
+ // Prefer the vector. Rendering each size from the SVG at its native
304
+ // resolution is sharper than downscaling one 512px raster — the difference
305
+ // is very visible at 16px, which is the size that actually appears in a tab.
306
+ const svg = path.join(SRC, 'brand/favicon.svg');
307
+ const png512 = path.join(SRC, 'brand/favicon.png');
308
+ const source = (await fs.access(svg).then(() => true).catch(() => false)) ? svg : png512;
309
+
310
+ const png = (size) =>
311
+ sharp(source, { density: Math.max(72, Math.ceil((size / 512) * 72 * 8)) })
312
+ .resize(size, size, { fit: 'contain', background: { r: 0, g: 0, b: 0, alpha: 0 } })
313
+ .png()
314
+ .toBuffer();
315
+
316
+ const targets = [
317
+ ['apple-touch-icon.png', 180],
318
+ ['icon-192.png', 192],
319
+ ['icon-512.png', 512],
320
+ ];
321
+ for (const [name, size] of targets) {
322
+ await fs.writeFile(path.join(ROOT, 'public', name), await png(size));
323
+ }
324
+
325
+ // ICO directory with PNG-compressed entries (supported since Vista / OS X).
326
+ const sizes = [16, 32, 48];
327
+ const images = await Promise.all(sizes.map(png));
328
+ const header = Buffer.alloc(6);
329
+ header.writeUInt16LE(0, 0); // reserved
330
+ header.writeUInt16LE(1, 2); // type: icon
331
+ header.writeUInt16LE(sizes.length, 4);
332
+
333
+ let offset = 6 + sizes.length * 16;
334
+ const entries = sizes.map((size, i) => {
335
+ const entry = Buffer.alloc(16);
336
+ entry.writeUInt8(size === 256 ? 0 : size, 0);
337
+ entry.writeUInt8(size === 256 ? 0 : size, 1);
338
+ entry.writeUInt8(0, 2); // palette
339
+ entry.writeUInt8(0, 3); // reserved
340
+ entry.writeUInt16LE(1, 4); // colour planes
341
+ entry.writeUInt16LE(32, 6); // bits per pixel
342
+ entry.writeUInt32LE(images[i].length, 8);
343
+ entry.writeUInt32LE(offset, 12);
344
+ offset += images[i].length;
345
+ return entry;
346
+ });
347
+
348
+ await fs.writeFile(path.join(ROOT, 'public/favicon.ico'), Buffer.concat([header, ...entries, ...images]));
349
+ console.log(` icon favicon.ico (${sizes.join('/')}) + apple-touch-icon + 192/512 from ${path.basename(source)}`);
350
+ }
351
+
352
+ const bytes = async (dir) => {
353
+ let total = 0;
354
+ for (const f of await walk(dir).catch(() => [])) total += (await fs.stat(f)).size;
355
+ return total;
356
+ };
357
+ const mb = (n) => `${(n / 1024 / 1024).toFixed(1)} MB`;
358
+
359
+ const files = await walk(SRC).catch(() => []);
360
+ const before = await bytes(SRC);
361
+
362
+ console.log('optimizing media…');
363
+ await copyBrand(files.filter((f) => f.includes(`${path.sep}brand${path.sep}`)));
364
+
365
+ for (const file of files.filter((f) => RASTER.test(f))) {
366
+ if (file.includes(`${path.sep}brand${path.sep}`)) continue;
367
+ if (file.includes(`${path.sep}certifications${path.sep}`)) await emitSingle(file);
368
+ else if (file.includes(`${path.sep}blog${path.sep}`)) await emitResponsive(file, BLOG_WIDTHS);
369
+ else await emitResponsive(file, PHOTO_WIDTHS);
370
+ }
371
+
372
+ await emitFavicons();
373
+ await emitSocialCard();
374
+ await emitSocialTwins();
375
+
376
+ await fs.writeFile(MANIFEST, JSON.stringify(manifest, null, 2) + '\n');
377
+
378
+ const after = await bytes(OUT);
379
+ console.log(`\nsource ${mb(before)} → dist ${mb(after)} (${Math.round((1 - after / before) * 100)}% smaller)`);
380
+ console.log(`${Object.keys(manifest).length} images in manifest`);