#!/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', 'betaOptInUrl', ]; 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.' ); } // betaOptInUrl is out for the same arithmetic reason and a second, stronger one: the // only page that reads it renders per request, so it never passes through the boot // rewrite at all. `liveBrand()` in src/lib/brand.mjs reads the mount directly. Putting // it in TEXT_FIELDS would not make it work — it would be a rewrite that never matches. if (rewritable.includes('betaOptInUrl')) { fail( 'betaOptInUrl must not be in TEXT_FIELDS: its default is the empty string, and\n' + ' /beta is server-rendered, so it reads the mounted brand.json via liveBrand().' ); } } } /* ======================================================================================= 4. The demo slot's markup contract (§15 / D12) ======================================================================================= `applyBrand.mjs` reveals the demo link by string-replacing an exact pair of empty attributes in the built HTML. That is a contract between a script and a template that share no code, and it fails in the quietest possible way: an attribute inserted between the two, or `href` written after `data-demo-url`, produces a build where the demo URL is set in the mount, the boot log says nothing, and the link is simply never there. Both halves are checked, and neither is retyped from memory — the literal is derived from the same expression `applyBrand.mjs` uses, so the two cannot drift apart. */ const applyForCheck = existsSync(path.join(ROOT, 'scripts/applyBrand.mjs')) ? readFileSync(path.join(ROOT, 'scripts/applyBrand.mjs'), 'utf8') : ''; const attrTemplate = applyForCheck.match( /`href="\$\{escapeHtml\(value\)\}" data-demo-url="\$\{escapeHtml\(value\)\}"`/ ); if (!attrTemplate) { fail( 'applyBrand.mjs no longer builds the demo attributes as `href="..." data-demo-url="..."`.\n' + ' Update the expected pair below to match, and re-check every template that writes it.' ); } else { // What the script will look for when the applied value is the stock empty string. const EMPTY_PAIR = 'href="" data-demo-url=""'; let slots = 0; const strays = []; for await (const file of walk(path.join(ROOT, 'src'))) { if (path.extname(file) !== '.astro') continue; // Comments discuss the contract at length, including in the template that implements // it. Scanning them would make the check fail on its own documentation. // Blanked rather than removed: keeping every newline and every offset means the line // numbers reported below are the ones in the file, not the ones in a shortened copy. const blank = (match) => match.replace(/[^\n]/g, ' '); const source = readFileSync(file, 'utf8') .replace(/\/\*[\s\S]*?\*\//g, blank) .replace(//g, blank); const relative = path.relative(ROOT, file); slots += source.split(EMPTY_PAIR).length - 1; for (const match of source.matchAll(/data-demo-url/g)) { const start = match.index - EMPTY_PAIR.indexOf('data-demo-url'); if (source.slice(start, start + EMPTY_PAIR.length) !== EMPTY_PAIR) { strays.push(`${relative}:${source.slice(0, match.index).split('\n').length}`); } } } if (!slots) { fail( `no demo slot found in src/**/*.astro — expected the literal \`${EMPTY_PAIR}\`.\n` + ' §15 reserves this slot so that gaining a demo instance is one line in the mounted\n' + ' brand.json. Removing it makes that a rebuild.' ); } for (const site of strays) { fail( `${site} writes data-demo-url outside the exact pair \`${EMPTY_PAIR}\`.\n` + ' applyBrand.mjs replaces that literal at boot; anything else is invisible to it and\n' + ' the slot will never appear.' ); } } /* ======================================================================================= 5. The demo DEEP-link contract (§15 / D25) ======================================================================================= `/features/` links individual capabilities into the demo, which the slot in §4 cannot express — it swaps a whole URL, so it can only ever produce the demo's root. Those links carry a third attribute and `applyBrand.mjs` recomputes all three from it. Same failure mode as §4 and the same reason to check it: a template and a script with no shared code, agreeing on an exact byte sequence, where disagreement is silent. This one is worse in one respect — a broken deep link is INVISIBLE in a stock build, because the stock build hides every demo link. It would first appear on the day the org lead sets `demoUrl` and finds the new links pointing at the demo's front page, or at nothing. The regex is not retyped here either: it is lifted out of `applyBrand.mjs` and run against the stock literal, so this fails if the script's pattern stops matching what the templates write — whichever side moved. */ const deepPattern = applyForCheck.match(/const DEEP_LINK = \/(.*)\/g;/); const EMPTY_DEEP_PREFIX = 'href="" data-demo-url="" '; let deepLinkCount = 0; if (!deepPattern) { fail( 'applyBrand.mjs no longer defines DEEP_LINK as a single /…/g literal.\n' + ' §15/D25 relies on it to fill the per-capability demo links. Update this check to\n' + ' match the new shape rather than deleting it.' ); } else { // Does the script's own pattern still match what a template writes in a stock build? const sample = `${EMPTY_DEEP_PREFIX}data-demo-path="/example"`; let matches = false; try { matches = new RegExp(deepPattern[1]).test(sample); } catch (error) { fail(`applyBrand.mjs's DEEP_LINK is not a usable pattern: ${error.message}`); } if (!matches) { fail( `applyBrand.mjs's DEEP_LINK no longer matches the stock markup \`${sample}\`.\n` + ' Every per-capability demo link would be left empty and hidden, on a deployment\n' + ' that has a demo configured — which is the one place nobody would look.' ); } const deepStrays = []; let deepLinks = 0; for await (const file of walk(path.join(ROOT, 'src'))) { if (path.extname(file) !== '.astro') continue; // Blanked, not stripped — same reason as §4: the line numbers reported have to be the // ones in the file. const blank = (match) => match.replace(/[^\n]/g, ' '); const source = readFileSync(file, 'utf8') .replace(/\/\*[\s\S]*?\*\//g, blank) .replace(//g, blank); const relative = path.relative(ROOT, file); for (const match of source.matchAll(/data-demo-path/g)) { deepLinks++; const start = match.index - EMPTY_DEEP_PREFIX.length; if (start < 0 || source.slice(start, match.index) !== EMPTY_DEEP_PREFIX) { deepStrays.push(`${relative}:${source.slice(0, match.index).split('\n').length}`); } } } for (const site of deepStrays) { fail( `${site} writes data-demo-path without the exact prefix \`${EMPTY_DEEP_PREFIX}\`.\n` + ' applyBrand.mjs matches all three attributes together and in that order; anything\n' + ' else is invisible to it and the link will never point anywhere.' ); } deepLinkCount = deepLinks; } /* ======================================================================================= */ 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, ` + `every rewritable string is safe to replace, and the demo slot plus ${deepLinkCount} ` + `deep link(s) match their contracts.` );