#!/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 `` 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); }