All checks were successful
PR checks / checks (pull_request) Successful in 2m10s
PLAN.md §16, D63/D64, plus D67-D69 taken 2026-10-22. - Core docs use core's words: a Team, a game server, the sidecar, the bridge plugin, the game module. Detail that belonged to Ultima Online moved into its pages rather than being dropped. - New "Ultima Online" sidebar group (nine pages): an overview, Connect a ServUO shard, Verify the bridge, The shard connection, Client files, Troubleshooting, Bridge.cfg, sidecar.toml, Shard event catalog. Five moved URLs answer a real 301 (src/config/redirects.mjs, asserted by the served test). - Getting started is game-neutral. "Connect your game server" keeps the old URL and hands off to each game; Rust links docs main:rust-link/INSTALL.md until phase 15. "Verify the whole stack" stays, neutral (D69). - D68: GameExample.astro labels a game's real example on a core page with the game's name; checkTerms allows that game's words inside it. - scripts/checkTerms.mjs, the twelfth check: 16 game words, docs pages and both sidebar trees, one allowlisted phrase (Discord's "Guild (server) ID"). Wired into verify and pr-checks.yml. - canonicalDocs grouped by game; the Rust group carries four documents. - D67: the screenshot rule from #32 is adopted. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01E14m6SuuY6i1vASFeGDBeY
272 lines
12 KiB
JavaScript
272 lines
12 KiB
JavaScript
#!/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 `<GameExample game="…">` 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 } 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(/<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 };
|
|
}
|
|
|
|
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 <GameExample game="${region.game}">` : 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, g.items.map((i) => i.label)])],
|
|
['plannedSidebar', Object.entries(plannedSidebar)],
|
|
];
|
|
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
|
|
<GameExample game="…"> (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);
|
|
}
|