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
450 lines
21 KiB
JavaScript
450 lines
21 KiB
JavaScript
#!/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);
|
||
}
|