Files
runicgateway.com/scripts/checkBrand.mjs
wtclaude 556dee7355
All checks were successful
PR checks / checks (pull_request) Successful in 49s
feat(home): phase 3 — the homepage
Replaces phase 1's scaffold with the real homepage: hero, the data path as
inline SVG, the self-hosted argument, all five capability groups, and the
get-started CTA. Three decisions the org lead took first are recorded in
PLAN.md as D17-D19.

The data path is drawn generically and captioned specifically (D17): the nodes
say "your game server" and "sidecar", the sub-labels and caption name ServUO and
uo-link. The SVG is aria-hidden because the four numbered steps beside it carry
the same path in prose — one telling, not two.

The capability list is data with a check behind it (D18). Every Game-intelligence
item names the module-uo capability slug it comes from, and the build fails if
the page and platform.json disagree either way. That needed a fifteenth fact in
checkFacts.mjs: §12 named the capability list as an externally-sourced fact and
nothing re-read it, so the chain rested on someone remembering. It also found
that the site was omitting two of the module's eight capabilities — guilds and
city governors are now listed, in the page and in §10.

The hero leads with the emblem (D19), derived from whichever logo.png is in
force so one file still changes the hero, header, tab icon and app icon
together.

Also here, both found by standing the build up rather than by review:

  - checkBrand.mjs now enforces the demo slot's markup contract. applyBrand.mjs
    reveals the demo link by replacing an exact pair of empty attributes; an
    attribute inserted between them produces a build where the mount sets a demo
    URL, the boot log says nothing and the link never appears. Both halves are
    checked and the literal is derived from the expression applyBrand.mjs uses,
    so they cannot drift.

  - The header nav overflowed at 390px — four links plus the lockup measured
    433px against a 390px viewport, so every phone got a horizontally scrolling
    page. Phase 1 left this to phase 3 expecting a disclosure control; it got a
    wrap instead, because with four links there is nothing to disclose and a
    hamburger costs state, script and duplicate markup.

Verified on a clean checkout of this commit: all four checks, astro check, a
production build, a live /brand/* smoke, and a demo URL mounted and reverted.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-08-20 00:03:04 -05:00

306 lines
12 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.'
);
}
}
}
/* =======================================================================================
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(/<!--[\s\S]*?-->/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.'
);
}
}
/* ======================================================================================= */
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 matches its contract.`
);