Files
runicgateway.com/src/lib/brandAssets.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

336 lines
13 KiB
JavaScript

/**
* brandAssets.mjs — the brand mount, resolved (PLAN.md §7)
*
* Two directories. `brand-default/` is baked into the image and always complete.
* `brand/` is the bind mount and may be empty, partial or full. Every file resolves
* against the mount first and the defaults second, PER FILE, so overriding only
* `theme.css` leaves every logo stock and an empty mount produces exactly the stock site.
*
* ---------------------------------------------------------------------------------------
* WHY THIS IS A RUNTIME MODULE AND NOT AN ASSET IMPORT
* ---------------------------------------------------------------------------------------
* Nothing here goes through Vite. Vite would fingerprint the filename into the build —
* `logo.a1b2c3.png` — and a mounted file could then never replace it, because no page
* would ever ask for the name the operator wrote. Stable, unhashed URLs are the mechanism;
* the ETag below is what buys back the caching that fingerprinting would have given.
*
* ---------------------------------------------------------------------------------------
* ONE FILE IS THE WHOLE REBRAND
* ---------------------------------------------------------------------------------------
* §7's promise is that swapping a logo is "a file copy". The site asks for about fifteen
* images — header at three pixel ratios, hero, two PWA icons, an apple-touch icon, three
* favicon sizes and an .ico. If those were fifteen files in `brand-default/`, keeping the
* promise would mean an operator producing fifteen files, and the realistic outcome is a
* deployment with a new header mark and the old favicon.
*
* So the defaults hold exactly one raster — `logo.png` — and everything else is derived
* from whichever `logo.png` is in force, cached in memory after the first request. Drop in
* one file, restart, and the header, the browser tab, the installed icon and the hero all
* change together.
*
* Derivation is limited to an allowlist of sizes. That is not tidiness: an open size
* parameter is an invitation to make the container resize an image ten thousand times.
*/
import { createHash } from 'node:crypto';
import { readFile, stat } from 'node:fs/promises';
import path from 'node:path';
import sharp from 'sharp';
/**
* Both directories are resolved from the working directory, which is `/app` in the
* container and the repository root in development — so the defaults are correct in both
* places and the env vars exist for the third case nobody has hit yet.
*/
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');
const CONTENT_TYPES = {
'.png': 'image/png',
'.webp': 'image/webp',
'.avif': 'image/avif',
'.svg': 'image/svg+xml',
'.ico': 'image/x-icon',
'.css': 'text/css; charset=utf-8',
'.json': 'application/json; charset=utf-8',
'.webmanifest': 'application/manifest+json; charset=utf-8',
};
/**
* The files that may be served verbatim from either directory.
*
* An allowlist rather than "whatever is in the directory", because the mount is operator
* data: without this, dropping a stray file into `brand/` would publish it, and a mount
* pointed at the wrong directory by a typo in a compose file would publish that instead.
* Serving only names the site actually asks for keeps the blast radius of a mistake to a
* missing logo.
*/
const STATIC_FILES = new Set([
'brand.json',
'theme.css',
'logo.png',
'logo.svg',
'wordmark.svg',
'og-image.png',
]);
/** Sizes the site actually uses, at 1x, 2x and 3x where it uses them. */
const DERIVABLE_SIZES = new Set([
16, 32, 40, 48, 64, 80, 96, 120, 128, 160, 180, 192, 240, 256, 320, 384, 512,
]);
const NAMED_DERIVATIVES = {
// Derivable, not static, even though §7's table lists it as a file: a mounted
// `favicon.ico` still wins, because `locate` runs before derivation for every name. The
// distinction that matters is what happens when NOBODY supplies one, and the answer has
// to be "derive it from the logo" rather than "404" — browsers request `/favicon.ico`
// whether or not a page links it.
'favicon.ico': { size: 48, format: 'ico' },
'icon-192.png': { size: 192, format: 'png' },
'icon-512.png': { size: 512, format: 'png' },
'apple-touch-icon.png': { size: 180, format: 'png' },
'favicon-16.png': { size: 16, format: 'png' },
'favicon-32.png': { size: 32, format: 'png' },
'favicon-48.png': { size: 48, format: 'png' },
};
/** `logo-<size>.<format>` — the header and hero variants. */
const SIZED = /^logo-(\d{1,4})\.(webp|avif|png)$/;
/**
* Describes what a requested name means, or returns null if it means nothing.
* Names are flat by construction: a `/` or a `..` never reaches here (see `parseName`).
*/
export function classify(name) {
if (STATIC_FILES.has(name)) return { kind: 'static', name };
if (NAMED_DERIVATIVES[name]) return { kind: 'derived', name, ...NAMED_DERIVATIVES[name] };
const sized = SIZED.exec(name);
if (sized) {
const size = Number(sized[1]);
if (DERIVABLE_SIZES.has(size)) return { kind: 'derived', name, size, format: sized[2] };
}
return null;
}
/**
* Rejects anything that is not a single flat filename.
*
* Path traversal is the obvious reason, and it is not the only one: this route reads from
* a directory an operator controls but does not audit, so "one segment, lowercase, from
* the allowlist" is a much smaller thing to be sure of than "no `..` anywhere".
*/
export function parseName(rest) {
const name = (rest || '').replace(/^\/+/, '');
if (!name || !/^[a-z0-9][a-z0-9._-]*$/.test(name) || name.includes('..')) return null;
return name;
}
async function statOrNull(file) {
try {
const info = await stat(file);
return info.isFile() ? info : null;
} catch {
return null;
}
}
/**
* Where a given file comes from, mount first. Returns null when neither directory has it —
* which for a derivable name is not an error, it just means "derive it".
*/
async function locate(name) {
const mounted = path.join(MOUNT_DIR, name);
const info = await statOrNull(mounted);
if (info) return { file: mounted, info, source: 'mount' };
const fallback = path.join(DEFAULT_DIR, name);
const defaultInfo = await statOrNull(fallback);
if (defaultInfo) return { file: fallback, info: defaultInfo, source: 'default' };
return null;
}
/**
* The raster every derivative descends from.
*
* `logo.svg` is second rather than first because it is the rarer case and the PNG is what
* §7's table calls the emblem; an operator who mounts both means the PNG. An operator who
* mounts only the SVG gets it rasterised, which is better than getting the stock mark.
*/
async function locateSource() {
for (const candidate of ['logo.png', 'logo.svg']) {
const mounted = path.join(MOUNT_DIR, candidate);
const info = await statOrNull(mounted);
if (info) return { file: mounted, info, source: 'mount' };
}
const fallback = path.join(DEFAULT_DIR, 'logo.png');
const info = await statOrNull(fallback);
return info ? { file: fallback, info, source: 'default' } : null;
}
/**
* A cache key that changes when the file behind it changes.
*
* §7 says a rebrand is a file copy and a restart, and a restart empties this map — so
* strictly the mtime is redundant. It is here because the failure it prevents is the
* confusing one: an operator who copies a new logo in without restarting should see either
* the old mark or the new one, never a header showing the new mark beside a favicon still
* derived from the old.
*/
function signature({ file, info }) {
return `${file}:${info.mtimeMs}:${info.size}`;
}
/** name -> { bytes, etag, type, source } */
const cache = new Map();
const etagOf = (bytes) => `"${createHash('sha256').update(bytes).digest('base64url').slice(0, 24)}"`;
/**
* Minimal ICO container.
*
* `favicon.ico` is in §7's table and sharp cannot write the format, but an .ico is barely a
* format: a six-byte header, a sixteen-byte directory entry per image, and — since Vista —
* ordinary PNG payloads. Writing those forty bytes is cheaper than a dependency, and it is
* what lets `/favicon.ico`, which browsers request whether or not a page links it, answer
* with the operator's mark rather than a 404.
*/
function encodeIco(images) {
const header = Buffer.alloc(6);
header.writeUInt16LE(0, 0); // reserved
header.writeUInt16LE(1, 2); // 1 = icon
header.writeUInt16LE(images.length, 4);
const directory = Buffer.alloc(16 * images.length);
let offset = header.length + directory.length;
images.forEach(({ size, bytes }, i) => {
const at = i * 16;
directory.writeUInt8(size >= 256 ? 0 : size, at); // 0 means 256
directory.writeUInt8(size >= 256 ? 0 : size, at + 1);
directory.writeUInt8(0, at + 2); // palette size, 0 for truecolour
directory.writeUInt8(0, at + 3); // reserved
directory.writeUInt16LE(1, at + 4); // colour planes
directory.writeUInt16LE(32, at + 6); // bits per pixel
directory.writeUInt32LE(bytes.length, at + 8);
directory.writeUInt32LE(offset, at + 12);
offset += bytes.length;
});
return Buffer.concat([header, directory, ...images.map((image) => image.bytes)]);
}
/**
* Rasterise at the target size. An SVG source needs the density raised to match, otherwise
* librsvg renders it at its nominal size and sharp scales the result up.
*/
async function rasterise(source, size) {
const isSvg = source.file.endsWith('.svg');
const bytes = await readFile(source.file);
if (!isSvg) return sharp(bytes).resize(size, size, { fit: 'contain', background: TRANSPARENT });
const nominal = (await sharp(bytes).metadata()).width || size;
return sharp(bytes, { density: Math.min(2400, Math.max(72, (72 * size) / nominal)) })
.resize(size, size, { fit: 'contain', background: TRANSPARENT });
}
const TRANSPARENT = { r: 0, g: 0, b: 0, alpha: 0 };
async function derive(spec, source) {
if (spec.name === 'favicon.ico') {
const images = await Promise.all(
[16, 32, 48].map(async (size) => ({
size,
bytes: await (await rasterise(source, size)).png({ compressionLevel: 9 }).toBuffer(),
}))
);
return encodeIco(images);
}
const pipeline = await rasterise(source, spec.size);
if (spec.format === 'webp') return pipeline.webp({ quality: 90, effort: 5 }).toBuffer();
if (spec.format === 'avif') return pipeline.avif({ quality: 62, effort: 4 }).toBuffer();
return pipeline.png({ compressionLevel: 9 }).toBuffer();
}
/**
* Resolve a brand file to bytes: mount, then defaults, then derivation.
*
* Returns null for a name that is not a brand file at all, so the caller answers 404
* rather than leaking which of the three steps failed.
*/
export async function resolveBrandFile(name) {
const spec = classify(name);
if (!spec) return null;
const found = await locate(name);
// A mounted or stock file always wins over a derivation. An operator who has produced a
// hand-tuned 32px favicon should get theirs, not one this code resized.
if (found) {
const key = `file:${signature(found)}`;
const hit = cache.get(name);
if (hit?.key === key) return hit;
const bytes = await readFile(found.file);
const entry = {
key,
bytes,
etag: etagOf(bytes),
type: CONTENT_TYPES[path.extname(name)] || 'application/octet-stream',
source: found.source,
};
cache.set(name, entry);
return entry;
}
if (spec.kind !== 'derived') return null;
const source = await locateSource();
if (!source) return null;
const key = `derive:${signature(source)}`;
const hit = cache.get(name);
if (hit?.key === key) return hit;
const bytes = await derive(spec, source);
const entry = {
key,
bytes,
etag: etagOf(bytes),
type: CONTENT_TYPES[path.extname(name)] || 'application/octet-stream',
source: `derived:${source.source}`,
};
cache.set(name, entry);
return entry;
}
/**
* The variants every page requests, derived ahead of the first visitor.
*
* Called when the route module is first loaded, not at process start — Astro loads a route
* lazily — so in practice the first request to anything under `/brand/` pays for its own
* file and warms the rest in the background. That is the difference between one slow
* request and eight.
*/
export function warmCache() {
const names = [
'logo-40.webp',
'logo-80.webp',
'logo-120.webp',
'favicon-32.png',
'favicon.ico',
'apple-touch-icon.png',
];
return Promise.allSettled(names.map((name) => resolveBrandFile(name)));
}
/** For the checks and the smoke test, which assert on what a request WOULD produce. */
export const brandDirs = { mount: MOUNT_DIR, defaults: DEFAULT_DIR };