import { readFileSync, statSync } from 'node:fs'; import path from 'node:path'; import brandDefault from '../../brand-default/brand.json' with { type: 'json' }; /** * The single accessor for brand text (§7). Every template reads brand through here and * never imports `brand.json` directly. * * --------------------------------------------------------------------------- * WHAT THIS RETURNS, AND HOW THE MOUNT STILL WINS * --------------------------------------------------------------------------- * These are the STOCK values, read from `brand-default/brand.json` at build time, and they * are what gets baked into the prerendered HTML. That is correct and complete for a stock * deployment, which is the common case. * * The mount reaches the text afterwards, from outside this module. Phase 1 recorded the * conflict here — §7 promises that renaming the product or changing the Discord invite is * a file edit plus a restart, while §6 prerenders every page, so a build-time value is * baked where no mounted file can reach it. The org lead settled it on 2026-08-20: * `scripts/applyBrand.mjs` rewrites the built HTML at boot, before the server opens a * socket, replacing what was baked with what the mount says. Every page stays prerendered, * the docs are covered by the same pass, and Pagefind still has static HTML to index. * * Two consequences for anyone adding a field here: * * - A new brand string is not automatically rewritable. Add it to `TEXT_FIELDS` in * `applyBrand.mjs`, or it is build-time only and §7 quietly stops being true for it. * - The rewrite is a plain string replacement, so a default that is short or that occurs * in ordinary markup is unsafe. `scripts/checkBrand.mjs` fails the build for one. * * Assets never had this problem: `GET /brand/*` reads the mount per request. */ export const brand = Object.freeze({ ...brandDefault }); /** * `brand.json` carries `$comment` keys for the operator who opens the mounted copy. They * are documentation, not fields, and must never reach a template. */ export function brandFields() { return Object.fromEntries(Object.entries(brand).filter(([k]) => !k.startsWith('$'))); } /* ========================================================================================= THE LIVE READ, FOR ON-DEMAND ROUTES ONLY (phase 5) ========================================================================================= Everything above is build-time, and the boot rewrite is what carries the mount into prerendered HTML. Neither reaches a page that renders per request: `applyBrand.mjs` rewrites files in `dist/client`, and an on-demand route's HTML never existed as a file. A server-rendered page reading `brand` would therefore show the STOCK value forever, no matter what is mounted — §7 quietly untrue, on exactly the page that needs it most. So `/beta` reads the mount itself. It is allowed to, because it is already executing: the reason the rest of the site cannot is that it is not running when its HTML is made, and that argument does not apply here. It is also strictly better where it applies. The rewrite happens at boot, so changing a mounted value means restarting the container; this is picked up on the next request. An operator who pastes the Play opt-in URL into `brand.json` has a working confirmation screen before they have finished reading this sentence. The mtime guard is what keeps that from being a file read per request. `statSync` on a file the OS has cached is cheap enough to do on every render and honest enough to notice an edit immediately, which a TTL would not be. */ const MOUNTED_BRAND = path.join( process.env.BRAND_DIR || path.join(process.cwd(), 'brand'), 'brand.json' ); let cache = { mtimeMs: -1, value: brand }; /** * The brand as it is on disk right now: the mounted `brand.json` layered over the stock * one, per key. Use from on-demand routes; prerendered pages must keep using `brand`. * * Never throws. A missing mount is the normal case, and a malformed one is the operator's * typo — both fall back to stock with a log line, for the reason `applyBrand.mjs` gives at * length: a site up with the wrong logo beats a site down with the right one. */ export function liveBrand() { let mtimeMs; try { mtimeMs = statSync(MOUNTED_BRAND).mtimeMs; } catch { // No mounted file. Cache the stock answer against a sentinel so the miss is not // re-statted into a re-parse every request. if (cache.mtimeMs !== -1) cache = { mtimeMs: -1, value: brand }; return cache.value; } if (mtimeMs === cache.mtimeMs) return cache.value; let mounted = null; try { mounted = JSON.parse(readFileSync(MOUNTED_BRAND, 'utf8')); } catch (error) { console.error(`[brand] the mounted brand.json is not valid JSON and is being ignored: ${error.message}`); } const merged = { ...brand }; if (mounted && typeof mounted === 'object') { for (const [key, value] of Object.entries(mounted)) { // Same two rules as the boot rewrite: `$comment` keys are documentation, and a field // the stock file does not declare is a typo rather than a new feature. if (key.startsWith('$')) continue; if (!(key in brand)) continue; if (typeof value === 'string') merged[key] = value; } } cache = { mtimeMs, value: Object.freeze(merged) }; return cache.value; }