#!/usr/bin/env node /** * checkTerms.mjs — PLAN.md §16, D63 and D64. The twelfth check, added in phase 14. * * 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. * * What it reads, as of 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. * * 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. * * 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 `` tags on a core page (D68). The component prints the * game's name above the block, so the words arrive already labelled. * * What is NOT read, deliberately: * * - 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. */ import { readFileSync } 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')); /** * 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/', }; /** * 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. */ const ALLOW = [ { file: 'src/content/docs/docs/administration/notifications-and-email.mdx', 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 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, ' '); /** 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; } /** * 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(/([\s\S]*?)<\/GameExample>/g)) { const game = m[1]; if (!platform.games[game]) { fail(`${relative}:${lineOf(source, m.index)}`, ` 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(//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 }; } function lineOf(source, index) { return source.slice(0, index).split('\n').length; } /** 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; } 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; } } // ── 1. The documentation pages ────────────────────────────────────────────── let files = 0; const allowUsed = new Set(); 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 ` : 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`); } } // ── 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'}`, ); } } } } // ── Report ────────────────────────────────────────────────────────────────── 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'}).`, ); } else { console.error(`\ncheckTerms: ${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 (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. `); process.exit(1); }