Some checks failed
PR checks / checks (pull_request) Failing after 2m16s
The public demo (demo.runicgateway.com) is live, so the site's demo slot can be switched on. Two decisions of record, PLAN.md §15: - D82: /beta reads the demo's GET /api/v1/public/settings demo.logins on the server, caches it for a minute, and lists the published sign-ins with the next wipe. If the demo does not answer, /beta links to its sign-in page instead. Gate two reads as in place. - D83: a deep link into a game module's pages carries data-demo-game, and applyBrand.mjs fills it only for games in the new mounted demoGames list. The Rust features gain links into /rust and /rust/servers/demo. UO's stay hidden on a Rust demo. The public-demo not-built entry is removed, because its resolvedBy was met. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01E14m6SuuY6i1vASFeGDBeY
432 lines
17 KiB
JavaScript
432 lines
17 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';
|
|
import { games } from '../src/data/games/index.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}".`);
|
|
}
|
|
}
|
|
|
|
// D83: the one list-valued field. Each entry is a game module's id, checked against the
|
|
// games this site describes, so a typo is a failed build rather than a demo whose game
|
|
// links silently never appear.
|
|
const gameIds = games.map((game) => game.key);
|
|
if (!Array.isArray(brand.demoGames)) {
|
|
fail('brand.json is missing the list field "demoGames" (an empty list is the stock value).');
|
|
} else {
|
|
for (const id of brand.demoGames) {
|
|
if (!gameIds.includes(id)) {
|
|
fail(`brand.json's "demoGames" names ${JSON.stringify(id)}, which is not one of ${gameIds.join(', ')}.`);
|
|
}
|
|
}
|
|
}
|
|
|
|
/**
|
|
* 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(/<!--[\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.'
|
|
);
|
|
}
|
|
}
|
|
|
|
/* =======================================================================================
|
|
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.'
|
|
);
|
|
}
|
|
|
|
// A game's link (D83) must also be read WITH its game. A pattern that matched the first
|
|
// three attributes and dropped the fourth would treat a /uo/ link as a core one and fill
|
|
// it on every demo, which is the 404 the attribute exists to prevent.
|
|
const gameSample = `${sample} data-demo-game="rust"`;
|
|
let gameRead = null;
|
|
try {
|
|
gameRead = new RegExp(deepPattern[1]).exec(gameSample)?.[2] ?? null;
|
|
} catch {
|
|
// Reported above.
|
|
}
|
|
if (gameRead !== 'rust') {
|
|
fail(
|
|
`applyBrand.mjs's DEEP_LINK does not read the game out of \`${gameSample}\`.\n` +
|
|
' Game links would follow demoUrl alone and point into modules the demo may not run.'
|
|
);
|
|
}
|
|
|
|
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(/<!--[\s\S]*?-->/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.`
|
|
);
|