feat(demo): list the demo's sign-ins on /beta and link into it per game
Some checks failed
PR checks / checks (pull_request) Failing after 2m16s

The public demo (demo.runicgateway.com) is live, so the site's demo slot
can be switched on. Two decisions of record, PLAN.md §15:

- D82: /beta reads the demo's GET /api/v1/public/settings demo.logins on
  the server, caches it for a minute, and lists the published sign-ins
  with the next wipe. If the demo does not answer, /beta links to its
  sign-in page instead. Gate two reads as in place.
- D83: a deep link into a game module's pages carries data-demo-game, and
  applyBrand.mjs fills it only for games in the new mounted demoGames
  list. The Rust features gain links into /rust and /rust/servers/demo.
  UO's stay hidden on a Rust demo.

The public-demo not-built entry is removed, because its resolvedBy was met.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01E14m6SuuY6i1vASFeGDBeY
This commit is contained in:
2026-10-28 15:32:34 -05:00
parent 8701126d64
commit 769d213c80
18 changed files with 650 additions and 63 deletions

View File

@@ -193,17 +193,38 @@ if (demoFrom !== demoTo) {
* because `global.css` hides `[data-demo-url='']` — the visibility rule stays one rule for
* both kinds of link, and only the `href` differs.
*/
const DEEP_LINK = /href="[^"]*" data-demo-url="[^"]*" data-demo-path="([^"]*)"/g;
const DEEP_LINK = /href="[^"]*" data-demo-url="[^"]*" data-demo-path="([^"]*)"(?: data-demo-game="([^"]*)")?/g;
const deepLinkTo = (demoPath) => {
const href = demoTo ? `${demoTo.replace(/\/+$/, '')}${demoPath}` : '';
/**
* Which games the demo runs (D83). A deep link into a game module's pages carries that
* game's id in `data-demo-game`, and it is filled only when the mounted `demoGames` names
* it: the demo is a Rust deployment, and a `/uo/market` link into it is a 404. Core's own
* links (`/wiki`, `/site/events`) carry no game and follow `demoUrl` alone, because every
* deployment has them.
*
* An absent or malformed list means no game's links, not every game's. A demo whose games
* nobody named is one whose game links cannot be known to work.
*/
const gamesOf = (value) => (Array.isArray(value) ? value.filter((id) => typeof id === 'string') : []);
const gamesFrom = gamesOf(previous.demoGames);
const gamesTo = gamesOf(resolved.demoGames);
const gamesChanged = gamesFrom.join(',') !== gamesTo.join(',');
if ('demoGames' in mounted && !Array.isArray(mounted.demoGames)) {
console.warn('[brand] the mounted "demoGames" is not a list of game ids — no game links will show.');
}
const deepLinkTo = (demoPath, game) => {
const root = !game || gamesTo.includes(game) ? demoTo : '';
const href = root ? `${root.replace(/\/+$/, '')}${demoPath}` : '';
return (
`href="${escapeHtml(href)}" data-demo-url="${escapeHtml(demoTo)}" ` +
`data-demo-path="${demoPath}"`
`href="${escapeHtml(href)}" data-demo-url="${escapeHtml(root)}" ` +
`data-demo-path="${demoPath}"` +
(game === undefined ? '' : ` data-demo-game="${game}"`)
);
};
if (!replacements.length) {
if (!replacements.length && !gamesChanged) {
console.log('[brand] mount matches what is already applied; nothing to rewrite.');
process.exit(0);
}
@@ -303,8 +324,8 @@ for (const file of walk(CLIENT)) {
// After the literal swaps, never before: the plain-slot replacement also matches the
// first two attributes of a deep link, so it runs first and this pass corrects the
// `href` it just wrote. Recomputing rather than replacing is what makes that safe.
after = after.replace(DEEP_LINK, (whole, demoPath) => {
const rebuilt = deepLinkTo(demoPath);
after = after.replace(DEEP_LINK, (whole, demoPath, game) => {
const rebuilt = deepLinkTo(demoPath, game);
if (rebuilt !== whole) counts.set('demoDeep', counts.get('demoDeep') + 1);
return rebuilt;
});
@@ -330,6 +351,9 @@ if (counts.get('demoDeep')) {
` ${'demoUrl deep'.padEnd(14)} ${demoTo ? `linked -> ${demoTo}/…` : 'links hidden'} (${counts.get('demoDeep')}x)`
);
}
if (gamesChanged) {
console.log(` ${'demoGames'.padEnd(14)} ${gamesTo.length ? gamesTo.join(', ') : 'no game links'}`);
}
if (inlineCollisions.length) {
console.error(

View File

@@ -32,6 +32,7 @@ import path from 'node:path';
import sharp from 'sharp';
import { classify } from '../src/lib/brandAssets.mjs';
import { games } from '../src/data/games/index.mjs';
const ROOT = fileURLToPath(new URL('..', import.meta.url));
const DEFAULTS = path.join(ROOT, 'brand-default');
@@ -157,6 +158,20 @@ if (brand) {
}
}
// D83: the one list-valued field. Each entry is a game module's id, checked against the
// games this site describes, so a typo is a failed build rather than a demo whose game
// links silently never appear.
const gameIds = games.map((game) => game.key);
if (!Array.isArray(brand.demoGames)) {
fail('brand.json is missing the list field "demoGames" (an empty list is the stock value).');
} else {
for (const id of brand.demoGames) {
if (!gameIds.includes(id)) {
fail(`brand.json's "demoGames" names ${JSON.stringify(id)}, which is not one of ${gameIds.join(', ')}.`);
}
}
}
/**
* Kept in step with `TEXT_FIELDS` in `applyBrand.mjs` by reading that file rather than by
* restating the list. A field added there and forgotten here would be unchecked; a field
@@ -348,6 +363,23 @@ if (!deepPattern) {
);
}
// A game's link (D83) must also be read WITH its game. A pattern that matched the first
// three attributes and dropped the fourth would treat a /uo/ link as a core one and fill
// it on every demo, which is the 404 the attribute exists to prevent.
const gameSample = `${sample} data-demo-game="rust"`;
let gameRead = null;
try {
gameRead = new RegExp(deepPattern[1]).exec(gameSample)?.[2] ?? null;
} catch {
// Reported above.
}
if (gameRead !== 'rust') {
fail(
`applyBrand.mjs's DEEP_LINK does not read the game out of \`${gameSample}\`.\n` +
' Game links would follow demoUrl alone and point into modules the demo may not run.'
);
}
const deepStrays = [];
let deepLinks = 0;