feat(brand): phase 2 — the branding pipeline
All checks were successful
PR checks / checks (pull_request) Successful in 9m9s
All checks were successful
PR checks / checks (pull_request) Successful in 9m9s
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>
This commit is contained in:
232
scripts/applyBrand.mjs
Normal file
232
scripts/applyBrand.mjs
Normal file
@@ -0,0 +1,232 @@
|
||||
#!/usr/bin/env node
|
||||
/**
|
||||
* applyBrand.mjs — brand TEXT from the bind mount (PLAN.md §7)
|
||||
*
|
||||
* Runs immediately before the server, as part of `npm start`. For an empty mount — the
|
||||
* stock deployment, and the common case — it reads two small files, finds nothing to do
|
||||
* and exits. It is not a build step and it is not a template engine.
|
||||
*
|
||||
* ---------------------------------------------------------------------------------------
|
||||
* THE PROBLEM THIS SOLVES
|
||||
* ---------------------------------------------------------------------------------------
|
||||
* §7 promises that renaming the product, changing the Discord invite or publishing a
|
||||
* different contact address is the same class of change as swapping a logo: edit the file
|
||||
* in the mount, restart, done. §6 prerenders every page. Those two are in direct conflict,
|
||||
* because a value read at build time is baked into HTML that no mounted file can reach.
|
||||
*
|
||||
* Assets escape the conflict by being served per request from `/brand/*`. Text cannot: it
|
||||
* is inside the markup.
|
||||
*
|
||||
* Three ways out were considered and the org lead chose this one (2026-08-20):
|
||||
*
|
||||
* 1. THIS — rewrite the built HTML at boot, before the server opens a socket. Every page
|
||||
* stays prerendered, Pagefind still has static HTML to index in phase 10, and the docs
|
||||
* are covered by the same pass as the marketing pages.
|
||||
* 2. Mark the brand-bearing pages `prerender = false`. Simpler, but the footer is on
|
||||
* every page, so "the handful" is the whole site — and the docs would have to stay
|
||||
* static for search anyway, leaving them showing the stock name.
|
||||
* 3. Accept text as build-time and amend §7. Cheapest, and it gives up the promise.
|
||||
*
|
||||
* ---------------------------------------------------------------------------------------
|
||||
* WHY IT REWRITES FROM A RECORD RATHER THAN FROM THE DEFAULTS
|
||||
* ---------------------------------------------------------------------------------------
|
||||
* The obvious version of this script replaces the DEFAULT value with the mounted one. It
|
||||
* works exactly once. The second time an operator edits the mount — renaming from "Foo" to
|
||||
* "Bar" — the default no longer appears anywhere in the HTML, every replacement matches
|
||||
* nothing, and the site silently keeps saying "Foo". The bug would surface as "the first
|
||||
* change worked and the second did nothing", which is a miserable thing to debug.
|
||||
*
|
||||
* So the script records what it baked, in `dist/.brand-applied.json`, and the next run
|
||||
* rewrites from that record to the new values. A fresh image has no record and starts from
|
||||
* the defaults, which is the same thing said differently.
|
||||
*
|
||||
* ---------------------------------------------------------------------------------------
|
||||
* WHAT MAKES PLAIN STRING REPLACEMENT SAFE HERE
|
||||
* ---------------------------------------------------------------------------------------
|
||||
* Not much, on its own — which is why `scripts/checkBrand.mjs` exists. It fails the build
|
||||
* if any rewritable default is short enough to collide with ordinary markup or prose. The
|
||||
* check is the mechanism; the eight-character minimum below is only its last line.
|
||||
*
|
||||
* Replacing the site name across the docs as well as the marketing pages is deliberate. If
|
||||
* the product is renamed, prose that says "Runic Gateway" should say the new name too.
|
||||
*/
|
||||
|
||||
import { readFileSync, writeFileSync, existsSync, readdirSync, statSync } from 'node:fs';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import path from 'node:path';
|
||||
|
||||
const ROOT = fileURLToPath(new URL('..', import.meta.url));
|
||||
|
||||
const DIST = process.env.BRAND_DIST || path.join(ROOT, 'dist');
|
||||
const CLIENT = path.join(DIST, 'client');
|
||||
const RECORD = path.join(DIST, '.brand-applied.json');
|
||||
|
||||
const MOUNT_DIR = process.env.BRAND_DIR || path.join(process.cwd(), 'brand');
|
||||
const DEFAULT_DIR = process.env.BRAND_DEFAULT_DIR || path.join(process.cwd(), 'brand-default');
|
||||
|
||||
/**
|
||||
* The fields that appear in markup as literal text, and may therefore be rewritten.
|
||||
*
|
||||
* `demoUrl` is not one of them and is handled separately below: its default is the empty
|
||||
* string, and there is no such thing as replacing every occurrence of "".
|
||||
*/
|
||||
const TEXT_FIELDS = ['siteName', 'tagline', 'contactEmail', 'discordInvite', 'giteaOrg'];
|
||||
|
||||
/**
|
||||
* Below this length a value is too likely to occur inside unrelated markup — a class name,
|
||||
* an attribute, a word in a sentence — for a blind replacement to be safe. `checkBrand.mjs`
|
||||
* enforces the same floor at build time, where the failure is cheap; this is the copy that
|
||||
* runs in production, where being wrong means corrupted pages.
|
||||
*/
|
||||
const MIN_REWRITABLE_LENGTH = 8;
|
||||
|
||||
const REWRITABLE_EXTENSIONS = new Set(['.html', '.webmanifest']);
|
||||
|
||||
function readJson(file, label) {
|
||||
try {
|
||||
return JSON.parse(readFileSync(file, 'utf8'));
|
||||
} catch (error) {
|
||||
if (error.code === 'ENOENT') return null;
|
||||
// A malformed mounted brand.json must not take the site down.
|
||||
//
|
||||
// The alternative — exit non-zero and let the container crash-loop — surfaces the typo
|
||||
// immediately, and that is genuinely tempting. But an operator editing a mount is
|
||||
// watching the logs, whereas the restart six months later that trips over the same file
|
||||
// is unattended, and a marketing site that is up with stock branding beats one that is
|
||||
// down with correct branding.
|
||||
console.error(`\n[brand] ${label} is not valid JSON and will be IGNORED:\n ${file}\n ${error.message}\n`);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/** `$comment` keys are documentation for whoever opens the mounted copy, not fields. */
|
||||
const fieldsOf = (object) =>
|
||||
Object.fromEntries(Object.entries(object || {}).filter(([key]) => !key.startsWith('$')));
|
||||
|
||||
const defaults = fieldsOf(readJson(path.join(DEFAULT_DIR, 'brand.json'), 'the stock brand.json'));
|
||||
const mounted = fieldsOf(readJson(path.join(MOUNT_DIR, 'brand.json'), 'the mounted brand.json'));
|
||||
|
||||
if (!Object.keys(defaults).length) {
|
||||
console.error(
|
||||
`\n[brand] no stock brand.json at ${path.join(DEFAULT_DIR, 'brand.json')}.\n` +
|
||||
`brand-default/ is baked into the image and must always be complete (§7).\n`
|
||||
);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
for (const key of Object.keys(mounted)) {
|
||||
if (!(key in defaults)) {
|
||||
console.warn(`[brand] the mounted brand.json sets an unknown field "${key}" — ignoring it.`);
|
||||
}
|
||||
}
|
||||
|
||||
const resolved = { ...defaults, ...mounted };
|
||||
const previous = { ...defaults, ...(fieldsOf(readJson(RECORD, 'the applied-brand record')) || {}) };
|
||||
|
||||
/* ---------------------------------------------------------------------------------------
|
||||
Work out what actually changed
|
||||
--------------------------------------------------------------------------------------- */
|
||||
|
||||
const escapeHtml = (value) =>
|
||||
value.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"');
|
||||
|
||||
const replacements = [];
|
||||
|
||||
for (const field of TEXT_FIELDS) {
|
||||
const from = previous[field];
|
||||
const to = resolved[field];
|
||||
if (typeof from !== 'string' || typeof to !== 'string' || from === to) continue;
|
||||
|
||||
if (from.length < MIN_REWRITABLE_LENGTH) {
|
||||
console.error(
|
||||
`[brand] refusing to rewrite "${field}": the value being replaced (${JSON.stringify(from)}) ` +
|
||||
`is under ${MIN_REWRITABLE_LENGTH} characters and would match unrelated markup.`
|
||||
);
|
||||
continue;
|
||||
}
|
||||
|
||||
replacements.push({ field, from, to });
|
||||
// Astro escapes `&`, `<`, `>` and `"` when it writes a value into markup, so a Discord
|
||||
// invite or a Gitea URL carrying a query string appears in the HTML in its escaped form.
|
||||
// Adding the escaped pair rather than unescaping the document keeps this a string
|
||||
// operation on bytes, with no parser to disagree with the browser's.
|
||||
const escapedFrom = escapeHtml(from);
|
||||
if (escapedFrom !== from) replacements.push({ field, from: escapedFrom, to: escapeHtml(to) });
|
||||
}
|
||||
|
||||
/**
|
||||
* The demo slot (§15 / D12) is a rendering decision rather than a piece of text, and this
|
||||
* is the one place a string replacement can still express it.
|
||||
*
|
||||
* The markup contract, which phase 3 writes and this script relies on:
|
||||
*
|
||||
* <a class="demo-cta" href="" data-demo-url="">See it running</a>
|
||||
*
|
||||
* `global.css` hides `[data-demo-url='']`, so a stock build renders nothing. Setting
|
||||
* `demoUrl` in the mount turns both empty attributes into the URL, which fills the link and
|
||||
* reveals it in the same edit. Going back to an empty value reverses it, because the
|
||||
* previous value is in the record.
|
||||
*/
|
||||
const demoFrom = previous.demoUrl || '';
|
||||
const demoTo = resolved.demoUrl || '';
|
||||
|
||||
if (demoFrom !== demoTo) {
|
||||
const attr = (value) => `href="${escapeHtml(value)}" data-demo-url="${escapeHtml(value)}"`;
|
||||
replacements.push({ field: 'demoUrl', from: attr(demoFrom), to: attr(demoTo) });
|
||||
}
|
||||
|
||||
if (!replacements.length) {
|
||||
console.log('[brand] mount matches what is already applied; nothing to rewrite.');
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
/* ---------------------------------------------------------------------------------------
|
||||
Rewrite
|
||||
--------------------------------------------------------------------------------------- */
|
||||
|
||||
if (!existsSync(CLIENT)) {
|
||||
console.error(`\n[brand] no build to rewrite at ${CLIENT}. Run \`npm run build\` first.\n`);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
function* walk(dir) {
|
||||
for (const entry of readdirSync(dir, { withFileTypes: true })) {
|
||||
const full = path.join(dir, entry.name);
|
||||
if (entry.isDirectory()) yield* walk(full);
|
||||
else if (REWRITABLE_EXTENSIONS.has(path.extname(entry.name))) yield full;
|
||||
}
|
||||
}
|
||||
|
||||
const counts = new Map(replacements.map((r) => [r.field, 0]));
|
||||
let filesTouched = 0;
|
||||
|
||||
for (const file of walk(CLIENT)) {
|
||||
const before = readFileSync(file, 'utf8');
|
||||
let after = before;
|
||||
|
||||
for (const { field, from, to } of replacements) {
|
||||
if (!after.includes(from)) continue;
|
||||
counts.set(field, counts.get(field) + after.split(from).length - 1);
|
||||
after = after.split(from).join(to);
|
||||
}
|
||||
|
||||
if (after !== before) {
|
||||
writeFileSync(file, after);
|
||||
filesTouched++;
|
||||
}
|
||||
}
|
||||
|
||||
writeFileSync(RECORD, `${JSON.stringify(resolved, null, 2)}\n`);
|
||||
|
||||
console.log(`[brand] applied the mounted brand to ${filesTouched} file(s):`);
|
||||
for (const { field, from, to } of replacements) {
|
||||
if (from.startsWith('href=')) continue; // the demo pair, reported once below
|
||||
console.log(` ${field.padEnd(14)} ${JSON.stringify(from)} -> ${JSON.stringify(to)} (${counts.get(field)}x)`);
|
||||
}
|
||||
if (demoFrom !== demoTo) {
|
||||
console.log(` ${'demoUrl'.padEnd(14)} ${demoTo ? `slot shown -> ${demoTo}` : 'slot hidden'} (${counts.get('demoUrl')}x)`);
|
||||
}
|
||||
|
||||
// Pagefind builds its search index from the HTML at BUILD time (phase 10), so a rename
|
||||
// applied here reaches the pages but not the search results. Worth fixing when search
|
||||
// lands; recorded here rather than in a plan section nobody will re-read.
|
||||
Reference in New Issue
Block a user