Files
runicgateway.com/scripts/checkTerms.mjs
wtclaude 56473d4d51
All checks were successful
PR checks / checks (pull_request) Successful in 2m37s
feat(site): phase 17 - per-game module pages, /runicnpc/, and checkTerms over the built site
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
2026-10-22 20:10:51 -05:00

450 lines
21 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

#!/usr/bin/env node
/**
* 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,
* the bridge plugin, the game module — because that is what core itself calls them, and a
* site that says "shard" on a core page is describing one game's deployment as though it were
* the platform.
*
* D64: a convention that holds only while everyone remembers it does not hold. This is the
* same argument `checkTokens.mjs` makes for colours, applied to words. The vocabulary problem
* 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.
*
* 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);
* - `src/config/sidebar.mjs`, as DATA rather than as text: every group label and every
* page label, in both trees. Its comments are not scanned, because a comment is not
* something a reader of the site ever sees.
*
* It needs no build, so it runs early and fails fast, and it names a file and a line.
*
* ── The built pass (phase 17, D79) ──────────────────────────────────────────────────────
*
* 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`;
* - 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: 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.
*
* No token and no network: everything it reads is in this repository or its build output.
*/
import { readFileSync, existsSync } from 'node:fs';
import { readdir } from 'node:fs/promises';
import { fileURLToPath } from 'node:url';
import path from 'node:path';
import { docsSidebar, plannedSidebar, flatLabels } from '../src/config/sidebar.mjs';
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
* word boundaries, so "shard" does not match `SHARD_VISIBILITY` and "wipe" does not match
* "wiped" unless it is listed.
*
* A word is on this list because it is a game's name for something core has its own name for
* (shard → game server, guild and clan → Team), or because it names one game's component
* (uo-link → the sidecar, ServUO → the game server). Game NAMES — Ultima Online, UO, Rust —
* are not on it: D63 allows them everywhere. Steam is not on it either: Steam sign-in is a
* core identity provider, even though Rust was its first user.
*/
const TERMS = [
{ game: 'uo', word: 'shard', re: /\bshards?\b/gi },
{ game: 'uo', word: 'ServUO', re: /\bservuo\b/gi },
{ game: 'uo', word: 'uo-link', re: /\buo-?link\b/gi },
{ game: 'uo', word: 'Module-uo', re: /\bmodule-uo\b/gi },
{ game: 'uo', word: 'Bridge.cfg', re: /\bbridge\.cfg\b/gi },
{ game: 'uo', word: 'cliloc', re: /\bclilocs?\b/gi },
{ game: 'uo', word: 'guild', re: /\bguilds?\b/gi },
{ game: 'uo', word: 'IDOC', re: /\bidocs?\b/gi },
{ game: 'rust', word: 'rust-link', re: /\brust-?link\b/gi },
{ game: 'rust', word: 'Rust-Plugins', re: /\brust-plugins\b/gi },
{ game: 'rust', word: 'Module-Rust', re: /\bmodule-rust\b/gi },
{ game: 'rust', word: 'Oxide', re: /\boxide\b/gi },
{ game: 'rust', word: 'Carbon', re: /\bcarbon\b/gi },
{ game: 'rust', word: 'uMod', re: /\bumod\b/gi },
{ game: 'rust', word: 'clan', re: /\bclans?\b/gi },
{ game: 'rust', word: 'wipe', re: /\bwipes?\b/gi },
];
/** Each game's own docs directory. A page under one may use that game's words. */
const GAME_DIRS = {
uo: 'src/content/docs/docs/ultima-online/',
rust: 'src/content/docs/docs/rust/',
};
/**
* 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 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.`);
}
const failures = [];
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('/');
for (const [game, dir] of Object.entries(GAME_DIRS)) if (posix.startsWith(dir)) return game;
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.
*/
function proseOf(source, relative) {
let text = source;
// GameExample regions first, while the tags are still intact. Recorded, then the tags
// themselves are blanked so the attribute does not read as prose.
const regions = [];
for (const m of text.matchAll(/<GameExample\s+game="([^"]*)"\s*>([\s\S]*?)<\/GameExample>/g)) {
const game = m[1];
if (!platform.games[game]) {
fail(`${relative}:${lineOf(source, m.index)}`, `<GameExample game="${game}"> names a game platform.json does not have`);
}
regions.push({ game, start: m.index, end: m.index + m[0].length });
}
text = text.replace(/<\/?GameExample\b[^>]*>/g, blank);
text = text.replace(/<!--[\s\S]*?-->/g, blank); // HTML comments
text = text.replace(/^import\s.*$/gm, blank); // MDX imports
text = text.replace(/\]\([^)\s]*\)/g, (m) => '] ' + blank(m.slice(2))); // link targets, not their text
text = text.replace(/\b(?:href|src)=(?:"[^"]*"|\{`[^`]*`\})/g, blank); // JSX/HTML link targets
text = text.replace(/\bhttps?:\/\/[^\s)>"'`]+/g, blank); // bare URLs
// `{…}` expressions, outside code fences only — a fenced JSON example's braces are prose.
// Innermost first, repeatedly, so nested and multi-line expressions both go.
const parts = text.split(/(^```[\s\S]*?^```)/m);
for (let i = 0; i < parts.length; i += 2) {
let prev;
do {
prev = parts[i];
parts[i] = parts[i].replace(/\{[^{}]*\}/g, blank);
} while (parts[i] !== prev);
}
text = parts.join('');
return { text, regions };
}
async function sourcePass() {
// ── 1. The documentation pages ──
let files = 0;
const allowUsed = new Set();
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++;
let { text, regions } = proseOf(source, relative);
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));
}
}
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}`);
}
}
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}", ${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`;
}
// ═══ 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${BUILT ? ' --built' : ''}: ${summary} (${TERMS.length} terms, ${ALLOW.length} allowlisted phrase${ALLOW.length === 1 ? '' : 's'}).`);
} else {
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. 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);
}