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,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();
|