#!/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="…"`: `` on a docs page (D68) and a
* game-tagged `` (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 `` 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(/([\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 };
}
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 ` : 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 ``s by a syntax highlighter is still one word to a reader. Everything else is a
* boundary, so `
Guilds
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 = /
/i.exec(html)?.[0] ?? '';
const headText = [/([\s\S]*?)<\/title>/i.exec(head)?.[1] ?? '', //i, ' ');
html = html.replace(/<(script|style|template|noscript)\b[\s\S]*?<\/\1>/gi, ' ');
html = html.replace(//g, ' ');
// Checked elsewhere (see the header): Starlight's sidebar and its previous/next links.
for (const skip of [/