#!/usr/bin/env node /** * checkBrand.mjs — PLAN.md §7 * * The branding pipeline makes two promises that nothing else in the build can verify, and * both fail quietly rather than loudly. This is their mechanism, in the same spirit as * `checkTokens.mjs`: diligence does not survive contact with a year of commits. * * 1. EVERY `/brand/*` URL THE SITE ASKS FOR MUST ACTUALLY RESOLVE. * The route serves an allowlist of names and derives a fixed set of sizes. A template * that asks for `/brand/logo-44.webp` — a plausible number that is not on the list — * gets a 404, and a missing logo is exactly the kind of thing that looks like a * styling glitch and survives review. So every literal `/brand/...` in the source is * put through the route's own classifier, rather than a copy of its rules. * * 2. EVERY REWRITABLE BRAND STRING MUST BE SAFE TO REPLACE BLINDLY. * `applyBrand.mjs` swaps brand text in built HTML with plain string replacement. * That is safe only while the values are distinctive: a `siteName` of "Site", or a * tagline that contains the site name inside it, would corrupt pages at boot on a * machine nobody is watching. Checking it here makes the failure a red build. * * 3. `brand-default/` must be complete, because §7 says it always is. * * node scripts/checkBrand.mjs */ import { readFileSync, existsSync, statSync } from 'node:fs'; import { readdir } from 'node:fs/promises'; import { fileURLToPath } from 'node:url'; import path from 'node:path'; import sharp from 'sharp'; import { classify } from '../src/lib/brandAssets.mjs'; const ROOT = fileURLToPath(new URL('..', import.meta.url)); const DEFAULTS = path.join(ROOT, 'brand-default'); const failures = []; const fail = (message) => failures.push(message); /* ======================================================================================= 1. brand-default is complete ======================================================================================= */ /** * The stock set is deliberately small. Everything else the site requests — every logo size, * both PWA icons, the apple-touch icon, the favicons and the .ico — is derived at runtime * from `logo.png`, so that an operator rebrands by replacing one file rather than fifteen. * Adding a precomputed derivative here would quietly undo that. */ const REQUIRED = ['brand.json', 'theme.css', 'logo.png', 'wordmark.svg', 'og-image.png']; for (const name of REQUIRED) { const file = path.join(DEFAULTS, name); if (!existsSync(file)) { fail(`brand-default/${name} is missing — §7 requires the stock brand to be complete.`); } else if (statSync(file).size === 0) { fail(`brand-default/${name} is empty.`); } } if (existsSync(path.join(DEFAULTS, 'logo.png'))) { const meta = await sharp(path.join(DEFAULTS, 'logo.png')).metadata(); if (meta.width !== meta.height) { fail(`brand-default/logo.png is ${meta.width}x${meta.height}; the mark must be square.`); } // 512 is the largest thing anything asks for (icon-512.png). A smaller source would be // upscaled into an installed app icon, which is where it would be most visible. if (meta.width < 512) { fail(`brand-default/logo.png is ${meta.width}px; derivatives go up to 512 and must not upscale.`); } if (!meta.hasAlpha) { fail('brand-default/logo.png has no alpha channel; the mark would carry a background.'); } } /* ======================================================================================= 2. Every /brand/* URL in the source resolves ======================================================================================= */ const SCAN_EXT = new Set(['.astro', '.ts', '.tsx', '.js', '.mjs', '.css', '.md', '.mdx', '.json']); async function* walk(dir) { let entries; try { entries = await readdir(dir, { withFileTypes: true }); } catch { return; } for (const entry of entries) { const full = path.join(dir, entry.name); if (entry.isDirectory()) { if (entry.name === 'node_modules' || entry.name.startsWith('.')) continue; yield* walk(full); } else if (SCAN_EXT.has(path.extname(entry.name))) { yield full; } } } const referenced = new Map(); // name -> [where] for await (const file of walk(path.join(ROOT, 'src'))) { const source = readFileSync(file, 'utf8'); const relative = path.relative(ROOT, file); for (const match of source.matchAll(/\/brand\/([a-z0-9][a-z0-9._-]*)/g)) { const name = match[1]; const line = source.slice(0, match.index).split('\n').length; if (!referenced.has(name)) referenced.set(name, []); referenced.get(name).push(`${relative}:${line}`); } } for (const [name, sites] of referenced) { // The classifier is imported from the route's own module rather than reimplemented, so // this check cannot drift from what the server will actually do. if (!classify(name)) { fail( `/brand/${name} is requested by ${sites.join(', ')} but the route would 404 it.\n` + ` Add it to STATIC_FILES, NAMED_DERIVATIVES or DERIVABLE_SIZES in ` + `src/lib/brandAssets.mjs — or use a size that is already on the list.` ); } } /* ======================================================================================= 3. The rewritable brand strings are safe to replace blindly ======================================================================================= */ const brandPath = path.join(DEFAULTS, 'brand.json'); let brand = null; if (existsSync(brandPath)) { try { brand = JSON.parse(readFileSync(brandPath, 'utf8')); } catch (error) { fail(`brand-default/brand.json is not valid JSON: ${error.message}`); } } if (brand) { const REQUIRED_FIELDS = [ 'siteName', 'tagline', 'contactEmail', 'discordInvite', 'giteaOrg', 'demoUrl', ]; for (const field of REQUIRED_FIELDS) { if (typeof brand[field] !== 'string') { fail(`brand.json is missing the string field "${field}".`); } } /** * Kept in step with `TEXT_FIELDS` in `applyBrand.mjs` by reading that file rather than by * restating the list. A field added there and forgotten here would be unchecked; a field * added here and forgotten there would be silently build-time only. Either way the two * disagreeing is the bug, so the check is that they agree. */ const applySource = readFileSync(path.join(ROOT, 'scripts/applyBrand.mjs'), 'utf8'); const declared = applySource.match(/const TEXT_FIELDS = \[([^\]]*)\]/); if (!declared) { fail('could not find TEXT_FIELDS in scripts/applyBrand.mjs — has it been renamed?'); } else { const rewritable = [...declared[1].matchAll(/'([^']+)'/g)].map((m) => m[1]); for (const field of rewritable) { if (!(field in brand)) { fail(`applyBrand.mjs rewrites "${field}", which brand.json does not define.`); continue; } const value = brand[field]; if (value.length < 8) { fail( `brand.json's "${field}" is ${JSON.stringify(value)} — under 8 characters.\n` + ` applyBrand.mjs replaces this string across every built page at boot; a short\n` + ` value will match unrelated markup and corrupt the output.` ); } if (/[<>]|="/.test(value)) { fail(`brand.json's "${field}" contains markup characters, which the boot rewrite cannot survive.`); } // A value that occurs inside another value is the subtler failure: replacing the // shorter one first leaves the longer one half-rewritten, and which runs first is an // accident of declaration order. for (const other of rewritable) { if (other === field) continue; if (typeof brand[other] === 'string' && brand[other].includes(value)) { fail( `brand.json's "${field}" (${JSON.stringify(value)}) occurs inside "${other}".\n` + ` The boot rewrite would corrupt one while replacing the other.` ); } } } // demoUrl is gated by markup rather than replaced as text (see applyBrand.mjs), so it // is correct for it NOT to be in TEXT_FIELDS. Saying so out loud, because "the demo URL // is missing from the rewrite list" is an easy and wrong thing to conclude. if (rewritable.includes('demoUrl')) { fail( 'demoUrl must not be in TEXT_FIELDS: its default is the empty string, which cannot\n' + ' be string-replaced. It is handled by the data-attribute gate instead.' ); } } } /* ======================================================================================= */ if (failures.length) { console.error('\ncheckBrand: the branding pipeline has problems.\n'); for (const failure of failures) console.error(` - ${failure}`); console.error(''); process.exit(1); } console.log( `checkBrand: brand-default is complete, ${referenced.size} /brand/ URL(s) resolve, ` + `and every rewritable string is safe to replace.` );