feat(site): phase 17 - per-game module pages, /runicnpc/, and checkTerms over the built site
All checks were successful
PR checks / checks (pull_request) Successful in 2m37s

PLAN.md §16 phase 17, built on the org lead's answers of 2026-10-22,
recorded as D75-D79 (count of record 79).

- D75: core marketing pages describe what a game module adds in core's
  words. "Game intelligence" became "What a game module adds"; each
  game's own list moved to src/data/games/ and renders only on the new
  /modules/uo/ and /modules/rust/. /modules/ is a card per game.
- The page-side capability check is per game (assertGameCoverage), the
  item phase 13 moved here.
- D76: the homepage keeps its game screenshot, labelled. screens.mjs
  entries may carry `game` (D67); Screenshot.astro and the app grid
  print the game's name and mark the figure data-game.
- D77: /app/ describes the Rust screens (released the same day).
- D78: /runicnpc/, the standalone page, is in the header and footer;
  the narrow header was re-measured so five links keep two rows.
- D79: checkTerms.mjs --built reads every built page's visible text
  after the build; game routes and data-game elements allow their
  game's words. Proven against ten plants. Still the twelfth check.
- Re-read fixes: /architecture/'s "two machines is also the maximum",
  Discord "guild" on /integrations/, and collection.mjs against what
  Steam sign-in and the Rust module keep (PLAY_DATA_SAFETY.md
  regenerated; the Play answers are unchanged).

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-22 20:10:51 -05:00
parent ec65b2d5aa
commit 56473d4d51
32 changed files with 1924 additions and 451 deletions

View File

@@ -1,6 +1,7 @@
#!/usr/bin/env node
/**
* checkTerms.mjs — PLAN.md §16, D63 and D64. The twelfth check, added in phase 14.
* checkTerms.mjs — PLAN.md §16, D63 and D64. The twelfth check, added in phase 14 and widened
* to the whole site in phase 17 (D79).
*
* D63: outside a game's own section, the site may NAME the games it supports, but it may not
* use their VOCABULARY. Core concepts keep core's names — a Team, a game server, the sidecar,
@@ -13,7 +14,12 @@
* was measured before this check existed — "shard" 227 times in 45 files — and none of that
* was anybody's decision; it was the residue of a site written while there was one game.
*
* What it reads, as of phase 14:
* It runs in two passes, one before the build and one after.
*
* node scripts/checkTerms.mjs # the SOURCE pass: docs pages and the sidebar
* node scripts/checkTerms.mjs --built # the BUILT pass: every page in dist/, as rendered
*
* ── The source pass (phase 14) ──────────────────────────────────────────────────────────
*
* - every page under `src/content/docs`, frontmatter included (a title and a description
* are what a reader sees first, in the sidebar and in search results);
@@ -21,29 +27,44 @@
* page label, in both trees. Its comments are not scanned, because a comment is not
* something a reader of the site ever sees.
*
* Phase 17 widens it to all of `src/`, once the marketing pages have a game page to move
* their game content to. That is also when it learns D67's screenshot tag.
* It needs no build, so it runs early and fails fast, and it names a file and a line.
*
* Where a game's words are allowed:
* ── The built pass (phase 17, D79) ──────────────────────────────────────────────────────
*
* - in that game's own docs directory (`GAME_DIRS`), and its own sidebar group — a group
* The marketing pages are `.astro` templates fed from `src/data/`, so most of the words a
* reader sees on them are not in the template at all: a capability's label, a screenshot's
* caption, a version read from `platform.json`. A source scan sees an expression and skips
* it, or sees an id like `shard-status` and takes it for prose. So this pass reads what the
* build actually produced — the same argument `checkLinks.mjs` makes for links. For every
* page in `dist/client` it reads the visible text, plus `alt`, `title`, `aria-label`,
* `placeholder`, the `<title>` and the meta description. It does not read link targets,
* scripts, styles or comments. It names a route and a snippet rather than a line; the source
* pass still gives lines for the docs, where most of the words are.
*
* Two parts of every docs page are skipped, because each is checked somewhere better:
* Starlight's sidebar (the source pass reads both trees as data, group by group) and the
* previous/next links (each is a neighbouring page's title, checked on that page).
*
* ── Where a game's words are allowed ────────────────────────────────────────────────────
*
* - in that game's own docs directory (`GAME_DIRS`) and its own sidebar group — a group
* whose label is the game's `name` in `platform.json`;
* - between `<GameExample game="…">` tags on a core page (D68). The component prints the
* game's name above the block, so the words arrive already labelled.
* - on that game's own routes (`GAME_ROUTES`): its docs group, its module page, and for
* Rust the standalone RunicNPC page;
* - inside an element marked `data-game="…"`: `<GameExample>` on a docs page (D68) and a
* game-tagged `<Screenshot>` (D67). Both print the game's name beside the words, which is
* the bargain: a game's words are honest on a core page when the game is named right
* there. In the source pass, the `<GameExample>` tags themselves mark the region.
*
* What is NOT read, deliberately:
* What is NOT read, deliberately: link targets and bare URLs or paths (a repository is called
* what it is called, and an address is not prose — but LINK TEXT is read, so `[Module-uo](…)`
* fails and `[releases](…)` passes), MDX `import` lines, and, in the source pass only, `{…}`
* expressions — the built pass reads what they rendered.
*
* - link targets and bare URLs. A repository is called what it is called, and an address is
* not prose. The LINK TEXT is read, so `[Module-uo](…)` fails and `[releases](…)` passes;
* - `import` lines and `{…}` expressions. They are code, and what an expression renders is
* a value from `platform.json` or `src/data/`, which phase 17 brings into scope.
*
* node scripts/checkTerms.mjs
*
* No token and no network: everything it reads is in this repository.
* No token and no network: everything it reads is in this repository or its build output.
*/
import { readFileSync } from 'node:fs';
import { readFileSync, existsSync } from 'node:fs';
import { readdir } from 'node:fs/promises';
import { fileURLToPath } from 'node:url';
import path from 'node:path';
@@ -52,6 +73,7 @@ import { docsSidebar, plannedSidebar, flatLabels } from '../src/config/sidebar.m
const ROOT = fileURLToPath(new URL('..', import.meta.url));
const platform = JSON.parse(readFileSync(path.join(ROOT, 'src/data/platform.json'), 'utf8'));
const BUILT = process.argv.includes('--built');
/**
* The words, each tagged with the game it belongs to. Matching is case-insensitive and on
@@ -90,20 +112,32 @@ const GAME_DIRS = {
};
/**
* The allowlist: an exact phrase in one file that may use a word the rule would otherwise
* refuse. Short on purpose, and every entry says why. An entry that no longer matches its
* file fails the check too, so the list cannot rot into a set of permanent exemptions.
* Each game's own routes, for the built pass: its docs group, the page about its module, and
* for Rust the standalone RunicNPC page (D61), which a server owner reads without knowing what
* the platform is. A route matches when the page's path starts with one of these.
*/
const GAME_ROUTES = {
uo: ['/docs/ultima-online/', '/modules/uo/'],
rust: ['/docs/rust/', '/modules/rust/', '/runicnpc/'],
};
/**
* The allowlist: an exact phrase on one page that may use a word the rule would otherwise
* refuse. Short on purpose, and every entry says why. `file` is where the source pass meets
* it and `route` is where the built pass does. An entry that no longer matches fails the
* check too, so the list cannot rot into a set of permanent exemptions.
*/
const ALLOW = [
{
file: 'src/content/docs/docs/administration/notifications-and-email.mdx',
route: '/docs/administration/notifications-and-email/',
text: 'Guild (server) ID',
why: "Discord's own word, not a game's: it is the label on core's Discord Bot screen.",
},
];
for (const game of Object.keys(GAME_DIRS)) {
if (!platform.games[game]) throw new Error(`checkTerms: GAME_DIRS names "${game}", which platform.json does not.`);
for (const game of new Set([...Object.keys(GAME_DIRS), ...Object.keys(GAME_ROUTES)])) {
if (!platform.games[game]) throw new Error(`checkTerms: "${game}" is given a section here, but platform.json has no such game.`);
}
for (const t of TERMS) {
if (!platform.games[t.game]) throw new Error(`checkTerms: "${t.word}" is tagged "${t.game}", which platform.json does not know.`);
@@ -115,6 +149,35 @@ const fail = (where, detail) => failures.push({ where, detail });
/** Replace a span with spaces, keeping newlines, so later line numbers stay true. */
const blank = (text) => text.replace(/[^\n]/g, ' ');
/** Hits for every term the given context may not use. `allowed` is a game key, or null. */
function scan(text, allowed) {
const hits = [];
for (const t of TERMS) {
if (t.game === allowed) continue;
t.re.lastIndex = 0;
for (const m of text.matchAll(t.re)) hits.push({ term: t, value: m[0], index: m.index });
}
return hits;
}
const gameName = (game) => platform.games[game].name;
async function* walk(dir, pattern) {
let entries;
try {
entries = await readdir(dir, { withFileTypes: true });
} catch {
return;
}
for (const entry of entries) {
const full = path.join(dir, entry.name);
if (entry.isDirectory()) yield* walk(full, pattern);
else if (pattern.test(entry.name)) yield full;
}
}
// ═══ The source pass ═══════════════════════════════════════════════════════════════════
/** The game whose directory holds this file, or null for a core page. */
function gameOfPath(relative) {
const posix = relative.split(path.sep).join('/');
@@ -122,6 +185,10 @@ function gameOfPath(relative) {
return null;
}
function lineOf(source, index) {
return source.slice(0, index).split('\n').length;
}
/**
* Turn an MDX source into the text a reader would actually see, with everything that is not
* prose blanked out. Returns that text plus the GameExample regions, as offsets into it.
@@ -162,110 +229,221 @@ function proseOf(source, relative) {
return { text, regions };
}
function lineOf(source, index) {
return source.slice(0, index).split('\n').length;
}
async function sourcePass() {
// ── 1. The documentation pages ──
let files = 0;
const allowUsed = new Set();
/** Hits for every term the given context may not use. */
function scan(text, allowedGame) {
const hits = [];
for (const t of TERMS) {
if (t.game === allowedGame) continue;
t.re.lastIndex = 0;
for (const m of text.matchAll(t.re)) hits.push({ term: t, value: m[0], index: m.index });
}
return hits;
}
for await (const file of walk(path.join(ROOT, 'src', 'content', 'docs'), /\.mdx?$/)) {
const relative = path.relative(ROOT, file).split(path.sep).join('/');
const source = readFileSync(file, 'utf8').replace(/\r\n/g, '\n');
files++;
async function* walk(dir) {
let entries;
try {
entries = await readdir(dir, { withFileTypes: true });
} catch {
return;
}
for (const entry of entries) {
const full = path.join(dir, entry.name);
if (entry.isDirectory()) yield* walk(full);
else if (/\.mdx?$/.test(entry.name)) yield full;
}
}
let { text, regions } = proseOf(source, relative);
// ── 1. The documentation pages ──────────────────────────────────────────────
let files = 0;
const allowUsed = new Set();
for (const [i, entry] of ALLOW.entries()) {
if (entry.file !== relative) continue;
if (text.includes(entry.text)) {
allowUsed.add(i);
text = text.split(entry.text).join(blank(entry.text));
}
}
for await (const file of walk(path.join(ROOT, 'src', 'content', 'docs'))) {
const relative = path.relative(ROOT, file).split(path.sep).join('/');
const posix = relative;
const source = readFileSync(file, 'utf8').replace(/\r\n/g, '\n');
files++;
let { text, regions } = proseOf(source, relative);
for (const [i, entry] of ALLOW.entries()) {
if (entry.file !== posix) continue;
if (text.includes(entry.text)) {
allowUsed.add(i);
text = text.split(entry.text).join(blank(entry.text));
const pageGame = gameOfPath(relative);
for (const hit of scan(text, pageGame)) {
const region = regions.find((r) => hit.index >= r.start && hit.index < r.end);
if (region && region.game === hit.term.game) continue;
const where = region ? `inside <GameExample game="${region.game}">` : pageGame ? `in the ${gameName(pageGame)} group` : 'on a core page';
fail(`${relative}:${lineOf(source, hit.index)}`, `"${hit.value}" is ${gameName(hit.term.game)} vocabulary, ${where}`);
}
}
const pageGame = gameOfPath(relative);
for (const hit of scan(text, pageGame)) {
const region = regions.find((r) => hit.index >= r.start && hit.index < r.end);
if (region && region.game === hit.term.game) continue;
const where = region ? `inside <GameExample game="${region.game}">` : pageGame ? `in the ${platform.games[pageGame].name} group` : 'on a core page';
fail(
`${relative}:${lineOf(source, hit.index)}`,
`"${hit.value}" is ${platform.games[hit.term.game].name} vocabulary, ${where}`,
);
for (const [i, entry] of ALLOW.entries()) {
if (!allowUsed.has(i)) fail(`allowlist: ${entry.file}`, `"${entry.text}" no longer appears there — remove the entry`);
}
}
for (const [i, entry] of ALLOW.entries()) {
if (!allowUsed.has(i)) {
fail(`allowlist: ${entry.file}`, `"${entry.text}" no longer appears there — remove the entry`);
}
}
// ── 2. The sidebar, as data ─────────────────────────────────────────────────
const gameByName = new Map(Object.entries(platform.games).map(([key, g]) => [g.name, key]));
const trees = [
['docsSidebar', docsSidebar.map((g) => [g.label, flatLabels(g.items)])],
['plannedSidebar', Object.entries(plannedSidebar).map(([label, items]) => [label, flatLabels(items)])],
];
let labels = 0;
for (const [treeName, groups] of trees) {
for (const [groupLabel, items] of groups) {
const game = gameByName.get(groupLabel) ?? null;
for (const label of [groupLabel, ...items]) {
labels++;
for (const hit of scan(label, game)) {
fail(
`src/config/sidebar.mjs ${treeName} → ${groupLabel}`,
`"${label}" uses "${hit.value}", ${platform.games[hit.term.game].name} vocabulary, ${game ? `in the ${platform.games[game].name} group` : 'in a core group'}`,
);
// ── 2. The sidebar, as data ──
const gameByName = new Map(Object.entries(platform.games).map(([key, g]) => [g.name, key]));
const trees = [
['docsSidebar', docsSidebar.map((g) => [g.label, flatLabels(g.items)])],
['plannedSidebar', Object.entries(plannedSidebar).map(([label, items]) => [label, flatLabels(items)])],
];
let labels = 0;
for (const [treeName, groups] of trees) {
for (const [groupLabel, items] of groups) {
const game = gameByName.get(groupLabel) ?? null;
for (const label of [groupLabel, ...items]) {
labels++;
for (const hit of scan(label, game)) {
fail(
`src/config/sidebar.mjs ${treeName} → ${groupLabel}`,
`"${label}" uses "${hit.value}", ${gameName(hit.term.game)} vocabulary, ${game ? `in the ${gameName(game)} group` : 'in a core group'}`,
);
}
}
}
}
return `${files} docs pages and ${labels} sidebar labels use only their own game's words`;
}
// ── Report ──────────────────────────────────────────────────────────────────
// ═══ The built pass ════════════════════════════════════════════════════════════════════
const DIST = path.join(ROOT, 'dist', 'client');
/** `dist/client/a/b/index.html` → `/a/b/`; `dist/client/404.html` → `/404`. */
function routeOf(file) {
const rel = path.relative(DIST, file).split(path.sep).join('/');
if (rel === 'index.html') return '/';
if (rel.endsWith('/index.html')) return `/${rel.slice(0, -'index.html'.length)}`;
return `/${rel.replace(/\.html$/, '')}`;
}
function gameOfRoute(route) {
for (const [game, prefixes] of Object.entries(GAME_ROUTES)) {
if (prefixes.some((p) => route.startsWith(p))) return game;
}
return null;
}
/**
* Where the element opening at `start` closes, counting nested elements of the same name.
* Astro's output is well formed, which is what makes this enough without a parser.
*/
function elementEnd(html, start, tag) {
const re = new RegExp(`<(/?)${tag}\\b[^>]*?(/?)>`, 'gi');
re.lastIndex = start;
let depth = 0;
for (let m = re.exec(html); m; m = re.exec(html)) {
if (m[2] === '/') continue; // self-closing
depth += m[1] === '/' ? -1 : 1;
if (depth === 0) return re.lastIndex;
}
return html.length;
}
/** Every element whose opening tag matches `openRe`, as `{ start, end, match }`. */
function elements(html, openRe) {
const found = [];
for (const m of html.matchAll(openRe)) {
const tag = /^<([a-z0-9-]+)/i.exec(m[0])[1];
found.push({ start: m.index, end: elementEnd(html, m.index, tag), match: m });
}
return found;
}
const ENTITIES = { amp: '&', lt: '<', gt: '>', quot: '"', apos: "'", nbsp: ' ', rsquo: '’', lsquo: '‘', ldquo: '“', rdquo: '”', mdash: '—', ndash: '–', hellip: '…', rarr: '→', middot: '·' };
const decode = (s) =>
s.replace(/&(#x[0-9a-f]+|#\d+|[a-z]+);/gi, (m, e) => {
if (e[0] === '#') return String.fromCodePoint(e[1] === 'x' || e[1] === 'X' ? parseInt(e.slice(2), 16) : Number(e.slice(1)));
return ENTITIES[e.toLowerCase()] ?? m;
});
/**
* Elements whose text runs on into the next word with no space between: a word split across
* two `<span>`s by a syntax highlighter is still one word to a reader. Everything else is a
* boundary, so `<h3>Guilds</h3><p>Guild…` does not read as "GuildsGuild".
*/
const INLINE = /^(?:a|abbr|b|bdi|bdo|cite|code|data|dfn|em|i|kbd|mark|q|s|samp|small|span|strong|sub|sup|time|u|var|wbr)$/i;
/** The words a reader sees in a piece of HTML: its text, and the attributes that are read aloud or shown. */
function visibleText(html) {
const attrs = [];
for (const m of html.matchAll(/\s(?:alt|title|aria-label|placeholder)="([^"]*)"/gi)) attrs.push(m[1]);
let text = html.replace(/<\/?([a-z][a-z0-9-]*)\b[^>]*>/gi, (_, tag) => (INLINE.test(tag) ? '' : ' '));
text = decode([text, ...attrs.map(decode)].join(' \n '));
text = text.replace(/\bhttps?:\/\/\S+/g, ' '); // bare URLs
text = text.replace(/(^|[\s(“"'])\/[\w./#?=&%-]+/g, '$1 '); // bare paths — an address, not prose
return text.replace(/\s+/g, ' ');
}
function snippet(text, index, length) {
const from = Math.max(0, index - 50);
const to = Math.min(text.length, index + length + 50);
return `${from > 0 ? '…' : ''}${text.slice(from, to).trim()}${to < text.length ? '…' : ''}`;
}
async function builtPass() {
if (!existsSync(DIST)) {
console.error('checkTerms --built: dist/client does not exist. Run `npm run build` first.');
process.exit(1);
}
let pages = 0;
let regionsSeen = 0;
const allowUsed = new Set();
for await (const file of walk(DIST, /\.html$/)) {
const route = routeOf(file);
let html = readFileSync(file, 'utf8');
pages++;
// The head: only the parts a reader sees, in a tab or a search result.
const head = /<head\b[\s\S]*?<\/head>/i.exec(html)?.[0] ?? '';
const headText = [/<title>([\s\S]*?)<\/title>/i.exec(head)?.[1] ?? '', /<meta\s+name="description"\s+content="([^"]*)"/i.exec(head)?.[1] ?? ''].join(' \n ');
html = html.replace(/<head\b[\s\S]*?<\/head>/i, ' ');
html = html.replace(/<(script|style|template|noscript)\b[\s\S]*?<\/\1>/gi, ' ');
html = html.replace(/<!--[\s\S]*?-->/g, ' ');
// Checked elsewhere (see the header): Starlight's sidebar and its previous/next links.
for (const skip of [/<nav\b[^>]*\baria-label="Main"[^>]*>/gi, /<div\b[^>]*\bclass="pagination-links\b[^>]*>/gi]) {
for (const el of elements(html, skip).reverse()) html = html.slice(0, el.start) + ' ' + html.slice(el.end);
}
const pageGame = gameOfRoute(route);
const pieces = [{ text: decode(headText), allowed: pageGame, where: 'in the page title or description' }];
// Regions marked with a game. Read with that game's words allowed, then cut out.
for (const el of elements(html, /<[a-z][a-z0-9-]*\b[^>]*\bdata-game="([^"]*)"[^>]*>/gi).reverse()) {
const game = el.match[1];
regionsSeen++;
if (!platform.games[game]) {
fail(route, `an element is marked data-game="${game}", which platform.json does not have`);
continue;
}
pieces.push({ text: visibleText(html.slice(el.start, el.end)), allowed: game, also: pageGame, where: `inside a block marked ${gameName(game)}` });
html = html.slice(0, el.start) + ' ' + html.slice(el.end);
}
pieces.push({ text: visibleText(html), allowed: pageGame, where: pageGame ? `on a ${gameName(pageGame)} page` : 'on a core page' });
for (const piece of pieces) {
let { text } = piece;
for (const [i, entry] of ALLOW.entries()) {
if (entry.route !== route || !text.includes(entry.text)) continue;
allowUsed.add(i);
text = text.split(entry.text).join(' '.repeat(entry.text.length));
}
for (const hit of scan(text, piece.allowed)) {
if (piece.also && hit.term.game === piece.also) continue;
fail(route, `"${hit.value}" is ${gameName(hit.term.game)} vocabulary, ${piece.where}\n ${snippet(text, hit.index, hit.value.length)}`);
}
}
}
for (const [i, entry] of ALLOW.entries()) {
if (!allowUsed.has(i)) fail(`allowlist: ${entry.route}`, `"${entry.text}" no longer appears in the built page — remove the entry`);
}
return `${pages} built pages use only their own game's words (${regionsSeen} game-marked blocks read with their game's words allowed)`;
}
// ═══ Report ════════════════════════════════════════════════════════════════════════════
const summary = BUILT ? await builtPass() : await sourcePass();
if (failures.length === 0) {
console.log(
`checkTerms: ${files} docs pages and ${labels} sidebar labels use only their own game's words ` +
`(${TERMS.length} terms, ${ALLOW.length} allowlisted phrase${ALLOW.length === 1 ? '' : 's'}).`,
);
console.log(`checkTerms${BUILT ? ' --built' : ''}: ${summary} (${TERMS.length} terms, ${ALLOW.length} allowlisted phrase${ALLOW.length === 1 ? '' : 's'}).`);
} else {
console.error(`\ncheckTerms: ${failures.length} game word(s) outside their game's section:\n`);
console.error(`\ncheckTerms${BUILT ? ' --built' : ''}: ${failures.length} game word(s) outside their game's section:\n`);
for (const f of failures) console.error(` ✗ ${f.where}\n ${f.detail}`);
console.error(`
PLAN.md §16, D63: name the games anywhere, but keep their vocabulary in their own section.
Use core's word instead — a Team, a game server, the sidecar, the bridge plugin, the game
module. If a core page genuinely needs a game's real example, wrap it in
<GameExample game="…"> (D68), which labels it with the game's name. Add to the allowlist
in this script only for a word that is not a game's at all, and say why.
module. A marketing page describes what a game module adds in core's words and links to
the game's own page (D75). If a docs page genuinely needs a game's real example, wrap it in
<GameExample game="…"> (D68); a screenshot of a game screen carries its game tag (D67).
Add to the allowlist in this script only for a word that is not a game's at all, and say why.
`);
process.exit(1);
}