Files
runicgateway.com/scripts/checkBrand.mjs
wtclaude fe4abe0ebf
All checks were successful
PR checks / checks (pull_request) Successful in 9m9s
feat(brand): phase 2 — the branding pipeline
PLAN.md §7: swapping a logo or recolouring the site is a file copy and a
container restart, never a rebuild. Phase 2 builds the mechanism and the
checks that keep it true.

GET /brand/* resolves every file against the mount first and the baked-in
defaults second, per file, at stable unhashed URLs with an ETag and a five
minute TTL. Nothing goes through Vite, which would fingerprint the names out
of the mount's reach. An X-Brand-Source header says which step answered.

Three decisions were taken with the org lead (recorded as D14-D16 in §7):

D14 — one raster in, every size out. brand-default holds a single logo.png;
the header mark at three pixel ratios, both install icons, the apple-touch
icon, the favicons and a real multi-resolution favicon.ico are derived on
request from whichever logo.png is in force, cached, and limited to an
allowlist of sizes. Shipping fifteen precomputed files would have meant an
operator producing fifteen to change a mark — and getting a new header with
the old favicon.

D15 — brand text is applied at boot. Pages are prerendered, so §7's promise
about the site name, tagline and links could not hold at render time.
npm start now runs scripts/applyBrand.mjs first, rewriting the built HTML
from what it last applied to what the mount says. It rewrites from a record
in dist/.brand-applied.json rather than from the defaults, because the naive
version works exactly once and then silently ignores every later edit. An
empty mount is a no-op; removing a mount restores the stock build byte for
byte. Verified both ways, plus a second rename.

D16 — the header shows the real emblem, replacing phase 1's placeholder
glyph, so the site, the product and the Android launcher icon are one mark.
It is raster art, so theme.css cannot recolour it; replacing logo.png is how
the mark changes.

Two defects found and fixed while proving it:

The mounted theme.css did not win. Astro emits its own stylesheet after the
head markup, so linking the operator's last was not enough and every override
was silently a no-op. tokens.css now lives in @layer tokens and the mounted
file is unlayered, which takes order out of the mechanism entirely.

The documentation was a different site. Starlight builds its own head, so the
docs linked a Starlight default /favicon.svg that does not exist here, carried
no manifest or OG card, and never loaded the brand stylesheet — a mounted
theme recoloured the marketing pages and left the docs stock. A Head override
fixes it; half a rebrand looks like a product bug rather than a missed step.

brand-default/wordmark.svg and og-image.png are generated by
scripts/buildBrandAssets.mjs from the emblem and Cinzel's outlines and are
committed, so CI needs neither the artwork nor a font. Type is converted to
paths, because an SVG in an <img> can see neither the page's @font-face rules
nor fontconfig — the same isolation that broke currentColor in phase 1. Its
glyphs are drawn at the origin and translated: opentype.js emits NaN
coordinates at a non-zero origin for some glyphs, and a path parser stops at
the first malformed command, so the first lockup read "Runic Gate" and looked
like a typo rather than a bug.

scripts/checkBrand.mjs is the mechanism for the two failures that are
otherwise silent: it puts every literal /brand/... URL in the source through
the route's own classifier, so a size that is not on the allowlist fails the
build instead of 404ing in a browser, and it rejects a brand string short
enough that a blind replacement at boot could corrupt a page. Negative-tested
three ways before being trusted. It runs in CI ahead of the type check.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-08-19 23:14:17 -05:00

232 lines
8.9 KiB
JavaScript

#!/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.`
);