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,487 @@
1
+ /**
2
+ * Generate one Open Graph card per page.
3
+ *
4
+ * npm run cards
5
+ *
6
+ * Writes 1200x630 JPEGs to public/img/social/og-<slug>.jpg and registers them
7
+ * in src/data/image-manifest.json. Seo.astro picks the card up automatically
8
+ * from the canonical path, so a new page gets one by adding a row to CARDS in
9
+ * scripts/og-cards.config.mjs and re-running.
10
+ *
11
+ * ── THIS FILE HOLDS NO DESIGN ──────────────────────────────────────────────
12
+ * Every colour, face, size, string and card lives in og-cards.config.mjs, which
13
+ * ships as a stub. What is here is the machinery: the crop-safe box, the
14
+ * contrast measurement, and the composite ordering — each of which was got
15
+ * wrong once, silently, on a real build.
16
+ *
17
+ * ── WHY JPEG AND NOT WEBP/SVG ──────────────────────────────────────────────
18
+ * Facebook and LinkedIn still fail to render a WebP og:image, and no scraper
19
+ * accepts SVG. A shared post would unfurl with no picture at all. This is the
20
+ * one place in the build where JPEG is the correct answer.
21
+ *
22
+ * ── REQUIREMENTS ───────────────────────────────────────────────────────────
23
+ * ImageMagick (`magick`), `rsvg-convert`, and python3 with fontTools. None are
24
+ * npm packages, so preflight() checks for them by hand and names the missing
25
+ * one — an `npm run` that only works on its author's machine is worse than no
26
+ * script at all. See docs/runbook.md §1.
27
+ */
28
+
29
+ import { execFileSync } from 'node:child_process';
30
+ import { mkdirSync, existsSync, readFileSync, writeFileSync, rmSync } from 'node:fs';
31
+ import { join } from 'node:path';
32
+
33
+ import { PALETTE, FONTS, WORDMARK, TYPE, FOOTER, CARDS } from './og-cards.config.mjs';
34
+
35
+ const OUT = 'public/img/social';
36
+ const TMP = '.og-tmp';
37
+ const W = 1200;
38
+ const H = 630;
39
+
40
+ /*
41
+ * Layout, derived from how platforms CROP a card rather than from taste.
42
+ *
43
+ * X/Twitter renders a 2:1 slice of this 1.91:1 card, and iMessage and WhatsApp
44
+ * crop harder still. `MARGIN` is the visual gutter; `SAFE_TOP` is the stricter
45
+ * box that anything load-bearing stays inside, so a crop never takes a word or
46
+ * clips the logo. Type sizes are a design choice and live in the config.
47
+ */
48
+ const MARGIN = 76;
49
+ const SAFE_TOP = 92;
50
+
51
+ /* ── Preflight ─────────────────────────────────────────────────────────────
52
+ *
53
+ * Every check here failed on a real machine at least once. The Buffer dump an
54
+ * uncaught execFileSync produces is not a diagnosis, so nothing below is
55
+ * allowed to reach one.
56
+ */
57
+
58
+ function have(bin, args = ['--version']) {
59
+ try {
60
+ execFileSync(bin, args, { stdio: 'ignore' });
61
+ return true;
62
+ } catch {
63
+ return false;
64
+ }
65
+ }
66
+
67
+ function preflight() {
68
+ const missing = [];
69
+
70
+ if (!PALETTE) missing.push('PALETTE');
71
+ if (!FONTS) missing.push('FONTS');
72
+ if (!CARDS.length) missing.push('CARDS');
73
+
74
+ if (missing.length) {
75
+ console.error(
76
+ `\nog-cards: scripts/og-cards.config.mjs is still the stub.\n` +
77
+ ` Not set: ${missing.join(', ')}.\n\n` +
78
+ ` Social cards need the design to exist first — the real ramp, the two\n` +
79
+ ` faces in public/fonts/, and a wordmark. Run \`npm run tells\` to see\n` +
80
+ ` what is still undecided.\n`,
81
+ );
82
+ process.exit(1);
83
+ }
84
+
85
+ const tools = [
86
+ ['magick', ['-version'], 'ImageMagick', 'brew install imagemagick'],
87
+ ['rsvg-convert', ['--version'], 'rsvg-convert', 'brew install librsvg'],
88
+ ['python3', ['--version'], 'Python 3', 'brew install python'],
89
+ ];
90
+ for (const [bin, args, label, install] of tools) {
91
+ if (!have(bin, args)) {
92
+ console.error(`\nog-cards: ${label} not found (\`${bin}\`).\n Install: ${install}\n`);
93
+ process.exit(1);
94
+ }
95
+ }
96
+
97
+ try {
98
+ execFileSync('python3', ['-c', 'import fontTools, brotli'], { stdio: 'ignore' });
99
+ } catch {
100
+ console.error(
101
+ '\nog-cards: python3 has fontTools or brotli missing.\n' +
102
+ ' Install: python3 -m pip install fonttools brotli\n' +
103
+ ' (both are needed — fontTools decompresses woff2 through brotli)\n',
104
+ );
105
+ process.exit(1);
106
+ }
107
+
108
+ for (const [role, path] of Object.entries(FONTS)) {
109
+ if (!existsSync(path)) {
110
+ console.error(
111
+ `\nog-cards: FONTS.${role} points at ${path}, which does not exist.\n` +
112
+ ' The template ships no typefaces on purpose. Add the project\'s own\n' +
113
+ ' to public/fonts/ first — the cards are converted from the same files\n' +
114
+ ' the site serves, so they cannot drift from the page.\n',
115
+ );
116
+ process.exit(1);
117
+ }
118
+ }
119
+
120
+ if (WORDMARK && !existsSync(WORDMARK.source)) {
121
+ console.error(
122
+ `\nog-cards: WORDMARK.source is ${WORDMARK.source}, which does not exist.\n` +
123
+ ' Point it at the component holding the logo paths, or set WORDMARK to\n' +
124
+ ' null for cards without one.\n',
125
+ );
126
+ process.exit(1);
127
+ }
128
+
129
+ if (!FOOTER) {
130
+ console.warn('og-cards: FOOTER is empty — cards will carry no footer line.');
131
+ }
132
+ }
133
+
134
+ /* ── Fonts ─────────────────────────────────────────────────────────────── */
135
+
136
+ function woff2ToTtf(src, dest) {
137
+ execFileSync('python3', [
138
+ '-c',
139
+ `from fontTools.ttLib.woff2 import decompress; decompress(${JSON.stringify(src)}, ${JSON.stringify(dest)})`,
140
+ ]);
141
+ }
142
+
143
+ /* ── Contrast ──────────────────────────────────────────────────────────── */
144
+
145
+ const srgb = (v) => (v <= 0.03928 ? v / 12.92 : ((v + 0.055) / 1.055) ** 2.4);
146
+ const relLum = ([r, g, b]) => 0.2126 * srgb(r) + 0.7152 * srgb(g) + 0.0722 * srgb(b);
147
+
148
+ function ratio(fg, bg) {
149
+ const [hi, lo] = [relLum(fg), relLum(bg)].sort((a, b) => b - a);
150
+ return (hi + 0.05) / (lo + 0.05);
151
+ }
152
+
153
+ const hexToRgb = (h) => [1, 3, 5].map((i) => parseInt(h.slice(i, i + 2), 16) / 255);
154
+
155
+ /**
156
+ * Worst-case contrast of a text colour over a REGION OF THE BACKGROUND.
157
+ *
158
+ * Measured on the base image, before any type is composited. Measuring the
159
+ * finished card samples the glyphs themselves and reports text-against-text —
160
+ * about 1:1 — which looks like catastrophic failure and means nothing. That
161
+ * mistake was made once here already; it is the same reason automated tools
162
+ * cannot judge text over a photograph.
163
+ *
164
+ * Per-channel maxima, so the brightest thing anywhere in the band decides, not
165
+ * the average. Averages hide exactly the highlight that breaks a word.
166
+ */
167
+ function worstContrast(image, fgHex, [x, y, w, h]) {
168
+ const out = execFileSync('magick', [
169
+ image, '-crop', `${w}x${h}+${x}+${y}`, '+repage',
170
+ '-format', '%[fx:maxima.r] %[fx:maxima.g] %[fx:maxima.b]', 'info:',
171
+ ]).toString().trim().split(/\s+/).map(Number);
172
+ return ratio(hexToRgb(fgHex), out);
173
+ }
174
+
175
+ /* ── Drawing ───────────────────────────────────────────────────────────── */
176
+
177
+ const manifest = JSON.parse(readFileSync('src/data/image-manifest.json', 'utf8'));
178
+
179
+ /**
180
+ * Render one text run as its own transparent layer.
181
+ *
182
+ * `-annotate` positions against a gravity box and the result shifts with the
183
+ * string's own ascenders and descenders, which is how the first attempt put the
184
+ * footer row through the middle of the title. A `label:` composited at an
185
+ * explicit x,y is deterministic: the same coordinates put the same pixel in the
186
+ * same place regardless of what the text says.
187
+ */
188
+ function textLayer(tmp, name, { text, font, size, fill, spacing }) {
189
+ const file = join(tmp, `${name}.png`);
190
+ const body = spacing ? text.toUpperCase().split('').join(spacing) : text;
191
+ execFileSync('magick', [
192
+ '-background', 'none',
193
+ '-fill', fill,
194
+ '-font', font,
195
+ '-pointsize', String(size),
196
+ `label:${body}`,
197
+ file,
198
+ ]);
199
+ const [w, h] = execFileSync('magick', ['identify', '-format', '%w %h', file])
200
+ .toString().split(' ').map(Number);
201
+ return { file, w, h };
202
+ }
203
+
204
+ function buildCard(card, fonts, mark) {
205
+ /*
206
+ * `card.slug` overrides the route-derived name. Only the fallback uses it —
207
+ * it is the card for any page without one of its own, so it has no route to
208
+ * derive from.
209
+ */
210
+ const slug =
211
+ card.slug ?? (card.route === '/' ? 'home' : card.route.replace(/^\/|\/$/g, '').replace(/\//g, '-'));
212
+ const out = join(OUT, `og-${slug}.jpg`);
213
+ const base = join(TMP, `${slug}-base.png`);
214
+
215
+ /* 1. Background. */
216
+ if (card.photo) {
217
+ const entry = manifest[card.photo];
218
+ if (!entry) throw new Error(`${card.route ?? slug}: no manifest entry for "${card.photo}"`);
219
+ const file = join('public', entry.src);
220
+ if (!existsSync(file)) throw new Error(`${card.route ?? slug}: missing file ${file}`);
221
+
222
+ const photo = join(TMP, `${slug}-p.png`);
223
+ const veil = join(TMP, `${slug}-v.png`);
224
+
225
+ execFileSync('magick', [
226
+ file, '-resize', `${W}x${H}^`, '-gravity', 'center', '-extent', `${W}x${H}`, photo,
227
+ ]);
228
+
229
+ /*
230
+ * A veil that is heavy on the left and light on the right.
231
+ *
232
+ * Every word sits in the left two-thirds, so that is where it has to
233
+ * guarantee contrast whatever the photograph does; the right stays open so
234
+ * the picture still reads as a picture. A flat veil dark enough for the
235
+ * worst photo makes every card look like a dark rectangle — which is
236
+ * exactly what the first attempt produced.
237
+ */
238
+ execFileSync('magick', [
239
+ '-size', `${W}x${H}`, `xc:${PALETTE.veil}`,
240
+ '(', '-size', `${W}x${H}`, '-define', 'gradient:direction=east', 'gradient:gray90-gray30', ')',
241
+ '-alpha', 'off', '-compose', 'copy_opacity', '-composite', veil,
242
+ ]);
243
+
244
+ /*
245
+ * A second veil running bottom-up, for the footer row.
246
+ *
247
+ * The horizontal gradient deliberately lets the photograph through on the
248
+ * right, and on a bright frame the footer line washed out completely. This
249
+ * band gives the whole row a floor without darkening the picture above it.
250
+ *
251
+ * It is a 170px band, NOT a gradient across the whole card. A full-height
252
+ * gradient is only about 14% opaque by the time it reaches the footer
253
+ * baseline, which measured 2.7:1 on the brightest frames. The fix is not a
254
+ * darker gradient — that greys out the photograph — it is a shorter one, so
255
+ * the ink is where the small text is and nowhere else.
256
+ */
257
+ const FOOT_H = 170;
258
+ const foot = join(TMP, `${slug}-f.png`);
259
+ execFileSync('magick', [
260
+ '-size', `${W}x${FOOT_H}`, `xc:${PALETTE.veil}`,
261
+ '(', '-size', `${W}x${FOOT_H}`, '-define', 'gradient:direction=south', 'gradient:gray0-gray80', ')',
262
+ '-alpha', 'off', '-compose', 'copy_opacity', '-composite', foot,
263
+ ]);
264
+
265
+ /*
266
+ * Three separate calls, not one chained command. Chaining two composites in
267
+ * a single invocation silently dropped the first veil — titles measured
268
+ * 7.1:1 before and 2.5:1 after, with no error and a plausible-looking
269
+ * image. Each step is verifiable on its own.
270
+ */
271
+ const step1 = join(TMP, `${slug}-s1.png`);
272
+ execFileSync('magick', [photo, veil, '-compose', 'over', '-composite', step1]);
273
+ execFileSync('magick', [
274
+ step1, foot,
275
+ '-gravity', 'southwest', '-geometry', '+0+0',
276
+ '-compose', 'over', '-composite', base,
277
+ ]);
278
+ } else {
279
+ execFileSync('magick', ['-size', `${W}x${H}`, `xc:${PALETTE.ink}`, base]);
280
+ }
281
+
282
+ /*
283
+ * 2. Type, on one coordinate system measured from the top-left.
284
+ *
285
+ * The title is anchored to the FOOTER and grows upward, so a one-line and a
286
+ * three-line card share a baseline instead of drifting apart.
287
+ */
288
+ const FOOTER_Y = H - MARGIN - 24;
289
+ const lines = card.title.split('\n');
290
+ const size = TYPE.title[Math.min(lines.length, 3)] ?? TYPE.title[3];
291
+ const lead = Math.round(size * 1.14);
292
+
293
+ const layers = [];
294
+
295
+ /*
296
+ * The wordmark sits at MARGIN/SAFE_TOP — inside the safe box on both axes, so
297
+ * no platform crop can clip it. Its height follows its width through the
298
+ * artwork's own aspect rather than being set independently.
299
+ */
300
+ if (mark) layers.push({ ...mark, x: MARGIN, y: SAFE_TOP });
301
+
302
+ const titleTop = FOOTER_Y - 58 - lines.length * lead;
303
+ lines.forEach((line, i) => {
304
+ const l = textLayer(TMP, `${slug}-t${i}`, {
305
+ text: line, font: fonts.title, size, fill: PALETTE.fg,
306
+ });
307
+ layers.push({ ...l, fg: PALETTE.fg, label: `title line ${i + 1}`, x: MARGIN, y: titleTop + i * lead });
308
+ });
309
+
310
+ /*
311
+ * The lighter colour only on the PLAIN card.
312
+ *
313
+ * An accent is a mid tone, so its ceiling against a solid dark ground is
314
+ * around 7:1 and it has nothing left to give once a photograph is underneath.
315
+ * Over a photo, everything takes the foreground colour.
316
+ */
317
+ const overPhoto = Boolean(card.photo);
318
+ const accentFill = overPhoto ? PALETTE.fg : PALETTE.accent;
319
+
320
+ if (card.eyebrow) {
321
+ const e = textLayer(TMP, `${slug}-e`, {
322
+ text: card.eyebrow, font: fonts.eyebrow, size: TYPE.eyebrow, fill: accentFill,
323
+ });
324
+ layers.push({ ...e, fg: accentFill, label: 'eyebrow', x: MARGIN, y: titleTop - e.h - 6 });
325
+ }
326
+
327
+ /*
328
+ * One left-aligned footer line, not a left/right pair.
329
+ *
330
+ * The right-hand end of the card is where the veil is deliberately lightest,
331
+ * so the photograph still reads — which is exactly the wrong place for 23px
332
+ * text. Right-aligned it measured 1.7:1 to 3.2:1 across a set of cards and no
333
+ * amount of gradient fixed it without greying out every photograph. Left
334
+ * puts every word in the strongest third of the card.
335
+ */
336
+ if (FOOTER) {
337
+ const foot = textLayer(TMP, `${slug}-f1`, {
338
+ text: FOOTER, font: fonts.title, size: TYPE.footer, fill: accentFill, spacing: ' ',
339
+ });
340
+ layers.push({ ...foot, fg: accentFill, label: 'footer', x: MARGIN, y: FOOTER_Y });
341
+ }
342
+
343
+ /*
344
+ * Measure BEFORE compositing type, over each layer's OWN box.
345
+ *
346
+ * An earlier pass measured fixed bands 860px wide. The glyphs are nowhere
347
+ * near that wide, so the check was sampling bright photograph the text never
348
+ * touches and reporting 2.5:1 on cards that are actually fine. Measuring the
349
+ * rendered box — the exact pixels the type will cover, plus a few px of bleed
350
+ * — is the only number that means anything.
351
+ */
352
+ const PAD = 6;
353
+ const measured = layers
354
+ .filter((l) => l.fg)
355
+ .map((l) => [
356
+ l.label,
357
+ worstContrast(base, l.fg, [
358
+ Math.max(0, l.x - PAD),
359
+ Math.max(0, l.y - PAD),
360
+ Math.min(W - Math.max(0, l.x - PAD), l.w + PAD * 2),
361
+ Math.min(H - Math.max(0, l.y - PAD), l.h + PAD * 2),
362
+ ]),
363
+ ]);
364
+
365
+ const args = [base];
366
+ for (const l of layers) {
367
+ args.push('(', l.file, '-resize', `${l.w}x${l.h}`, ')',
368
+ '-gravity', 'northwest', '-geometry', `+${l.x}+${l.y}`, '-compose', 'over', '-composite');
369
+ }
370
+ args.push('-quality', '86', out);
371
+ execFileSync('magick', args);
372
+
373
+ return { slug, out, measured };
374
+ }
375
+
376
+ /**
377
+ * Rasterise the wordmark at 3x for a clean downscale.
378
+ *
379
+ * Read from the component that already draws it rather than committed as a
380
+ * second copy of the artwork — the same reason the fonts are converted from
381
+ * public/fonts at run time. The card cannot drift from what the header renders.
382
+ *
383
+ * The aspect comes from the artwork's OWN viewBox, so replacing the logo with
384
+ * one of a different shape needs no edit here.
385
+ */
386
+ function renderWordmark() {
387
+ if (!WORDMARK) return null;
388
+
389
+ const svg = readFileSync(WORDMARK.source, 'utf8');
390
+ const viewBox = /viewBox="([^"]+)"/.exec(svg)?.[1];
391
+ const paths = [...svg.matchAll(/<path d="([^"]+)"/g)].map((m) => m[1]);
392
+
393
+ if (!viewBox || !paths.length) {
394
+ console.error(
395
+ `\nog-cards: no <svg viewBox> with <path d="…"> found in ${WORDMARK.source}.\n` +
396
+ ' WORDMARK.source must point at a file containing the logo as SVG paths.\n',
397
+ );
398
+ process.exit(1);
399
+ }
400
+
401
+ const [, , vbW, vbH] = viewBox.split(/[\s,]+/).map(Number);
402
+ const w = WORDMARK.width;
403
+ const h = Math.round(w / (vbW / vbH));
404
+
405
+ /*
406
+ * fill-rule="evenodd" is not decoration. A traced logo expresses holes — the
407
+ * gaps inside a letter, the rings of a spiral — as inner contours, and the
408
+ * default nonzero rule fills them in solid. The artwork then looks nothing
409
+ * like the file it came from.
410
+ */
411
+ writeFileSync(
412
+ join(TMP, 'mark.svg'),
413
+ `<svg xmlns="http://www.w3.org/2000/svg" viewBox="${viewBox}" fill="${PALETTE.fg}" fill-rule="evenodd">` +
414
+ paths.map((d) => `<path d="${d}"/>`).join('') +
415
+ `</svg>`,
416
+ );
417
+ execFileSync('rsvg-convert', ['-w', String(w * 3), join(TMP, 'mark.svg'), '-o', join(TMP, 'mark.png')]);
418
+
419
+ return { file: join(TMP, 'mark.png'), w, h };
420
+ }
421
+
422
+ /* ── Run ───────────────────────────────────────────────────────────────── */
423
+
424
+ function main() {
425
+ preflight();
426
+
427
+ mkdirSync(TMP, { recursive: true });
428
+ mkdirSync(OUT, { recursive: true });
429
+
430
+ const fonts = { title: join(TMP, 'title.ttf'), eyebrow: join(TMP, 'eyebrow.ttf') };
431
+ woff2ToTtf(FONTS.title, fonts.title);
432
+ woff2ToTtf(FONTS.eyebrow ?? FONTS.title, fonts.eyebrow);
433
+
434
+ const mark = renderWordmark();
435
+
436
+ const made = [];
437
+ const failures = [];
438
+ for (const card of CARDS) {
439
+ const r = buildCard(card, fonts, mark);
440
+ made.push(r);
441
+ const worst = Math.min(...r.measured.map(([, v]) => v));
442
+ const bad = r.measured.filter(([, v]) => v < 4.5);
443
+ if (bad.length) failures.push([r.slug, bad]);
444
+ console.log(
445
+ ` ${r.slug.padEnd(30)} worst ${worst.toFixed(2)}:1 ` +
446
+ r.measured.map(([n, v]) => `${n.split(' ')[0]} ${v.toFixed(1)}`).join(' '),
447
+ );
448
+ }
449
+
450
+ if (failures.length) {
451
+ console.error('\n Text below 4.5:1 on its own background:');
452
+ for (const [slug, bad] of failures) {
453
+ console.error(` ${slug}: ` + bad.map(([n, v]) => `${n} ${v.toFixed(2)}:1`).join(', '));
454
+ }
455
+ console.error(' Darken the veil for those photographs, or choose a darker frame.\n');
456
+ process.exitCode = 1;
457
+ }
458
+
459
+ /* Register every card in the manifest so Seo.astro can resolve it. */
460
+ const m = JSON.parse(readFileSync('src/data/image-manifest.json', 'utf8'));
461
+ for (const { slug } of made) {
462
+ /*
463
+ * Same shape as every other manifest entry, including `srcset` and
464
+ * `widths`, even though a social card is only ever served at one size. A
465
+ * heterogeneous manifest breaks the type Img.astro asserts over it —
466
+ * `astro check` fails on the cast, not on any line that uses these.
467
+ *
468
+ * They are not referenced by <Img>: og:image is emitted by Seo.astro as a
469
+ * plain URL, because no social scraper reads srcset.
470
+ */
471
+ const src = `/img/social/og-${slug}.jpg`;
472
+ m[`social/og-${slug}`] = {
473
+ src,
474
+ width: W,
475
+ height: H,
476
+ aspect: Number((W / H).toFixed(4)),
477
+ srcset: `${src} ${W}w`,
478
+ widths: [W],
479
+ };
480
+ }
481
+ writeFileSync('src/data/image-manifest.json', JSON.stringify(m, null, 2) + '\n');
482
+
483
+ rmSync(TMP, { recursive: true, force: true });
484
+ console.log(`${made.length} cards written to ${OUT} and registered in the manifest.`);
485
+ }
486
+
487
+ main();