#!/usr/bin/env node /** * checkScreens.mjs — the screenshots agree with what the pages say about them. * * PLAN.md §12, §13 phase 9, D45. * * node scripts/checkScreens.mjs * * --------------------------------------------------------------------------------------- * WHAT IT PROVES, AND WHY EACH ONE IS WORTH A CHECK * --------------------------------------------------------------------------------------- * 1. EVERY DECLARED SCREEN HAS A FILE. A missing image is invisible in review — the page * still builds, still lays out, and only a reader sees the broken frame. * * 2. EVERY FILE IS THE DECLARED SIZE. `width` and `height` reach the markup as intrinsic * attributes, and an attribute that disagrees with the file is a page that jumps as the * image decodes. It also catches a re-capture taken at the wrong viewport, which looks * fine on its own and wrong beside the others. * * 3. NOTHING IN public/screens IS ORPHANED. A capture that stopped being referenced is a * file the container still ships and nobody looks at — and, worse, one that never gets * retaken, so it silently becomes the oldest thing in the repository. * * 4. EVERY DECLARED SCREEN IS ACTUALLY USED. The mirror of 3: an entry in `screens.mjs` * that no page renders is a capture being maintained for nothing. Usage is a literal * search for the id across `src/`, which is how both readers of the data refer to one — * `` and the `groupScreens` map on `/features/`. * * 5. THE ALT TEXT AND CAPTION SAY SOMETHING. An empty alt on an editorial image is an * accessibility failure the build cannot otherwise see, and a caption is the sentence * that makes a screenshot evidence rather than decoration. * * --------------------------------------------------------------------------------------- * WHY IT READS THE PNG HEADER ITSELF * --------------------------------------------------------------------------------------- * It does not: it reads the WebP header, and it does it with twenty lines rather than a * dependency. `sharp` is already here for the brand assets and could answer this, but this * check runs in CI on every pull request and a check that needs a native image library to * tell you a file is 1920 pixels wide is a check that will one day fail for a reason that * has nothing to do with screenshots. */ import { readdirSync, readFileSync, existsSync } from 'node:fs'; import path from 'node:path'; import { fileURLToPath } from 'node:url'; import { screens, WEB, PHONE } from '../src/data/screens.mjs'; const HERE = path.dirname(fileURLToPath(import.meta.url)); const ROOT = path.join(HERE, '..'); const DIR = path.join(ROOT, 'public', 'screens'); const SRC = path.join(ROOT, 'src'); const problems = []; /** * The pixel size of a WebP file, from its header. * * A RIFF container: "RIFF" size "WEBP" then one of three chunk types. Lossy ("VP8 ") and * lossless ("VP8L") pack the dimensions differently, and an animated or extended file * ("VP8X") states them outright. `cwebp` at quality 82 writes VP8 , but a future change of * encoder should not turn this check into a mystery, so all three are handled. */ function webpSize(file) { const buf = readFileSync(file); if (buf.length < 30 || buf.toString('ascii', 0, 4) !== 'RIFF' || buf.toString('ascii', 8, 12) !== 'WEBP') { return null; } const chunk = buf.toString('ascii', 12, 16); if (chunk === 'VP8X') { return { width: 1 + (buf[24] | (buf[25] << 8) | (buf[26] << 16)), height: 1 + (buf[27] | (buf[28] << 8) | (buf[29] << 16)), }; } if (chunk === 'VP8L') { const bits = buf[21] | (buf[22] << 8) | (buf[23] << 16) | (buf[24] << 24); return { width: 1 + (bits & 0x3fff), height: 1 + ((bits >> 14) & 0x3fff) }; } if (chunk === 'VP8 ') { return { width: buf.readUInt16LE(26) & 0x3fff, height: buf.readUInt16LE(28) & 0x3fff, }; } return null; } /** Every file under `src/`, read once, so usage is a search rather than a guess. */ function sourceText() { const out = []; const walk = (dir) => { for (const entry of readdirSync(dir, { withFileTypes: true })) { const full = path.join(dir, entry.name); if (entry.isDirectory()) walk(full); else if (/\.(astro|mdx?|mjs|js|ts|tsx)$/.test(entry.name)) out.push(readFileSync(full, 'utf8')); } }; walk(SRC); return out; } const sources = sourceText(); const declared = new Set(); for (const shot of screens) { const name = `${shot.id}.webp`; const file = path.join(DIR, name); declared.add(name); if (!existsSync(file)) { problems.push( `${shot.id}: no file at public/screens/${name}. ` + `Retake it: node scripts/captureScreens.mjs ${shot.id}`, ); continue; } const want = shot.family === 'web' ? WEB : PHONE; const size = webpSize(file); if (!size) { problems.push(`${shot.id}: public/screens/${name} is not a WebP this check can read.`); } else if (size.width !== want.width || size.height !== want.height) { problems.push( `${shot.id}: file is ${size.width}x${size.height}, ` + `declared ${want.width}x${want.height} for the "${shot.family}" family.`, ); } if (!shot.alt || shot.alt.length < 20) { problems.push(`${shot.id}: alt text is missing or too short to describe the screen.`); } if (!shot.caption) { problems.push(`${shot.id}: no caption.`); } const used = sources.some((text) => text.includes(`'${shot.id}'`) || text.includes(`"${shot.id}"`)); if (!used) { problems.push( `${shot.id}: declared but no page renders it. Use it, or delete the entry and its file.`, ); } } if (existsSync(DIR)) { for (const name of readdirSync(DIR)) { if (!declared.has(name)) { problems.push(`public/screens/${name}: not declared in src/data/screens.mjs.`); } } } if (problems.length > 0) { console.error(`\ncheckScreens: ${problems.length} problem(s)\n`); for (const problem of problems) console.error(` - ${problem}`); console.error(''); process.exit(1); } console.log(`checkScreens: ${screens.length} screens, all present, sized and used.`);