All checks were successful
PR checks / checks (pull_request) Successful in 1m26s
The checks were green before this phase started and are green now. What found anything was the part no script does: fifty pages at three widths in a real browser, a signup walked against its store, and a full brand mount applied and restarted. D51 — the chrome and the head follow the mount; the consent sentence does not. With a complete brand.json mounted, forty-nine pages came back rebranded and /beta did not. applyBrand.mjs rewrites files in dist/client and /beta renders per request, so its HTML never exists as a file to rewrite; liveBrand() was there for exactly that and was used for betaOptInUrl alone. Everything around the form — title, OG tags, header lockup, footer Source and Discord links — came from the shared chrome, and the shared chrome was baked. renderBrand() picks by Astro.isPrerendered, in one place, so the other forty-nine keep taking the value the boot rewrite will replace. CONSENT_TEXT stays a constant: it is stored verbatim in a person's row, so following a mounted name would change the recorded text of a consent already given. D52 — the documentation half gets phase 10's skip-link fix. Starlight's skip link targets the page <h1>, which is no more focusable than the <main> phase 10 fixed, so following it moved the viewport and not the focus on forty pages. A PageTitle override adds tabindex="-1". D53 — no twelfth check. The external-link sweep (73 of 74 alive) and the brand-mount walk stay throwaway scripts: one would make the build depend on other people's uptime and the other needs Chrome on the runner. Also recorded and deliberately not fixed: Starlight's heading anchor links measure under 24px at 390, and are exempt under SC 2.5.8's Equivalent clause because the mobile table of contents links to every one of the same anchors. npm run verify green — fourteen steps, both suites, all eleven checks. Co-Authored-By: Claude <noreply@anthropic.com>
150 lines
7.4 KiB
JavaScript
150 lines
7.4 KiB
JavaScript
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;
|
|
}
|
|
|
|
/* =========================================================================================
|
|
THE CHROME, WHICH RENDERS BOTH WAYS (phase 11)
|
|
=========================================================================================
|
|
|
|
The two accessors above are each correct for a page that only ever renders one way. The
|
|
header, the footer and `Base.astro`'s head are neither: the same components render at
|
|
build time for the forty-nine prerendered pages and at request time for `/beta`.
|
|
|
|
Phase 11's brand walk is what found that. With a full brand mounted, every page came
|
|
back rebranded except `/beta`, which still carried the stock site name in its title, its
|
|
OG tags and its header lockup, and the stock Discord and Gitea links in its footer — the
|
|
one page whose whole job is to ask a person for their address under a stated identity.
|
|
`/beta` already read the mount, but only for `betaOptInUrl`; everything around the form
|
|
came from the chrome, and the chrome was baked.
|
|
|
|
Calling `liveBrand()` from the chrome unconditionally would fix `/beta` and quietly move
|
|
a build-time value onto the runtime path for the other forty-nine, where a build machine
|
|
that happened to have a mount would bake mounted text into HTML the boot rewrite then
|
|
has nothing to replace. `isPrerendered` is the honest discriminator, so it is the one
|
|
used, in one place, rather than the same reasoning repeated in three components:
|
|
|
|
- prerendered -> the stock value, which `applyBrand.mjs` rewrites at boot,
|
|
- on demand -> the mount, read now.
|
|
|
|
The consent sentence is deliberately NOT here. It names the operator of the list in a
|
|
statement a person agrees to and which is stored verbatim in their row, so making it
|
|
follow a mounted name would change the recorded text of an existing consent. The org
|
|
lead settled that on 2026-08-25: the chrome and the head are brandable, `CONSENT_TEXT`
|
|
stays a constant and changes only with a consent-version bump.
|
|
*/
|
|
export function renderBrand(astro) {
|
|
return astro?.isPrerendered === false ? liveBrand() : brand;
|
|
}
|