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:
72
src/pages/brand/[...file].ts
Normal file
72
src/pages/brand/[...file].ts
Normal file
@@ -0,0 +1,72 @@
|
||||
import type { APIRoute } from 'astro';
|
||||
|
||||
import { parseName, resolveBrandFile, warmCache } from '../../lib/brandAssets.mjs';
|
||||
|
||||
/**
|
||||
* `GET /brand/*` — the bind-mounted branding (PLAN.md §7).
|
||||
*
|
||||
* This is one of exactly two routes on the site that execute per request; the other is the
|
||||
* beta signup in phase 5. Everything else is prerendered, which is why the config comment
|
||||
* in `astro.config.mjs` describes opting OUT rather than in.
|
||||
*
|
||||
* It has to be dynamic. The whole point of §7 is that these bytes come from a directory
|
||||
* that did not exist when the image was built, so there is nothing to prerender: a build
|
||||
* that baked them in would be a build that has to be repeated to change a logo.
|
||||
*/
|
||||
export const prerender = false;
|
||||
|
||||
// Warm the variants every page asks for, so the first visitor pays for one derivation
|
||||
// rather than eight. Fire and forget: a failure here is a cache miss, not an error.
|
||||
void warmCache();
|
||||
|
||||
export const GET: APIRoute = async ({ params, request }) => {
|
||||
const name = parseName(params.file);
|
||||
if (!name) return new Response('Not found', { status: 404 });
|
||||
|
||||
let file;
|
||||
try {
|
||||
file = await resolveBrandFile(name);
|
||||
} catch (error) {
|
||||
// A mounted file that is not what it claims to be — a truncated PNG, a text file named
|
||||
// logo.png — must not take the page down with it. The brand is decoration; the site
|
||||
// still works without it, and the operator gets a log line naming the file.
|
||||
console.error(`[brand] could not serve ${name}:`, error);
|
||||
return new Response('Not found', { status: 404 });
|
||||
}
|
||||
|
||||
if (!file) return new Response('Not found', { status: 404 });
|
||||
|
||||
// Revalidation is what makes the short TTL affordable: browsers keep the bytes and ask
|
||||
// only whether they changed, so the common case is a 304 with no body.
|
||||
if (request.headers.get('if-none-match') === file.etag) {
|
||||
return new Response(null, {
|
||||
status: 304,
|
||||
headers: { ETag: file.etag, 'Cache-Control': CACHE_CONTROL },
|
||||
});
|
||||
}
|
||||
|
||||
return new Response(file.bytes, {
|
||||
status: 200,
|
||||
headers: {
|
||||
'Content-Type': file.type,
|
||||
'Content-Length': String(file.bytes.length),
|
||||
ETag: file.etag,
|
||||
'Cache-Control': CACHE_CONTROL,
|
||||
// Which of the three resolution steps answered. The mount is the one part of this
|
||||
// site an operator configures by hand and cannot see the result of from the outside;
|
||||
// this turns "the logo did not change" from a guess into one curl.
|
||||
'X-Brand-Source': file.source,
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
/**
|
||||
* Five minutes, not a year.
|
||||
*
|
||||
* These URLs are deliberately unhashed (§7 — a fingerprinted filename could never be
|
||||
* replaced by a mounted file), so a long max-age would mean an operator swapping a logo and
|
||||
* being told by every already-warm browser that nothing had happened. Five minutes plus
|
||||
* revalidation costs one conditional request per asset per five minutes and bounds how
|
||||
* wrong a stale cache can be.
|
||||
*/
|
||||
const CACHE_CONTROL = 'public, max-age=300, must-revalidate';
|
||||
Reference in New Issue
Block a user