mirror of
https://github.com/phantomptr/ps5upload.git
synced 2026-10-19 02:20:02 -05:00
Upload, install, convert and manage PS5 game packages over the network. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
387 lines
14 KiB
JavaScript
Executable File
387 lines
14 KiB
JavaScript
Executable File
#!/usr/bin/env node
|
||
/*
|
||
* i18n coverage gate.
|
||
*
|
||
* Loads `client/src/i18n.ts` and compares every non-English language's
|
||
* key set against English. For each non-en language:
|
||
*
|
||
* missing = (en_keys − lang_keys) − allowlist[lang]
|
||
*
|
||
* If `missing` is non-empty for any language, the script exits non-
|
||
* zero with a per-language report. The allowlist
|
||
* (`scripts/i18n-known-missing.json`) is the curated "we know these
|
||
* aren't translated yet" register — keep it tight so newly added keys
|
||
* surface as build failures and force either a translation or an
|
||
* explicit allowlist entry.
|
||
*
|
||
* Modes
|
||
* default Check; non-zero if any non-allowlisted miss.
|
||
* --report Print per-language miss counts even when passing.
|
||
* --update-allowlist
|
||
* Rewrite allowlist with whatever is currently
|
||
* missing. Use sparingly — the whole point of the
|
||
* gate is that humans look at adds. Useful only for
|
||
* bootstrapping or after a deliberate big i18n
|
||
* purge.
|
||
*
|
||
* Wires into validate-repo.mjs as a separate gate so a missing
|
||
* translation fails the same `npm run validate` users run before
|
||
* pushing — no separate ritual.
|
||
*/
|
||
|
||
import fs from "node:fs";
|
||
import path from "node:path";
|
||
import vm from "node:vm";
|
||
import { fileURLToPath } from "node:url";
|
||
|
||
const repoRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..");
|
||
const LOCALES_DIR = path.join(repoRoot, "client/src/i18n/locales");
|
||
const ALLOWLIST_PATH = path.join(repoRoot, "scripts/i18n-known-missing.json");
|
||
|
||
const args = process.argv.slice(2);
|
||
const updateAllowlist = args.includes("--update-allowlist");
|
||
const report = args.includes("--report");
|
||
|
||
/**
|
||
* Load translations from the per-locale split layout under
|
||
* `client/src/i18n/locales/<code>.ts`.
|
||
*
|
||
* Each locale file is plain TS with one runtime surface: a top-level
|
||
* `const <safeId>: Translations = { ... }` object literal followed by
|
||
* `export default <safeId>;`. We extract that literal as a string and
|
||
* evaluate it in an isolated V8 context (`vm.runInNewContext`) so the
|
||
* `import type` line + `export default` don't trip up the parser.
|
||
*
|
||
* The locale ID inside the file uses underscores (e.g. `zh_CN`) to
|
||
* stay a valid JS identifier; the filename keeps the canonical
|
||
* hyphenated form (`zh-CN.ts`). Both this script and the runtime
|
||
* code rely on the filename — the in-file identifier is internal.
|
||
*
|
||
* Why vm.runInNewContext: runs in a fresh global with no access to
|
||
* require/process/fs/etc — the strongest sandbox Node ships without
|
||
* spawning a subprocess.
|
||
*
|
||
* Why not `import(...)` directly: Node can't load .ts files without
|
||
* a TS loader (tsx, ts-node) which we'd need to install as a script-
|
||
* only dep. Extracting the literal inline keeps the dep graph
|
||
* minimal.
|
||
*
|
||
* Replaces the older monolithic `client/src/i18n.ts` scanner
|
||
* (extracted a single `const translations = { ... }` block). The
|
||
* locale-split refactor moved each locale to its own file so Vite
|
||
* could chunk-split the i18n bundle by ~50 KB lazy-load groups.
|
||
*/
|
||
function loadTranslations() {
|
||
if (!fs.existsSync(LOCALES_DIR)) {
|
||
throw new Error(
|
||
`i18n locales dir not found: ${LOCALES_DIR} ` +
|
||
"(expected one file per locale, e.g. en.ts, zh-CN.ts, …)",
|
||
);
|
||
}
|
||
const files = fs
|
||
.readdirSync(LOCALES_DIR)
|
||
.filter((f) => f.endsWith(".ts") && !f.endsWith(".d.ts"));
|
||
const translations = {};
|
||
for (const f of files) {
|
||
const code = f.replace(/\.ts$/, "");
|
||
const fp = path.join(LOCALES_DIR, f);
|
||
const src = fs.readFileSync(fp, "utf8");
|
||
// Find the object literal: `const <id>: Translations = { ... };`
|
||
// The id might contain underscores for hyphenated locales
|
||
// (zh-CN → zh_CN inside the file). Match on any identifier.
|
||
const startMatch = src.match(
|
||
/^const\s+[A-Za-z_][A-Za-z0-9_]*\s*:\s*Translations\s*=\s*/m,
|
||
);
|
||
if (!startMatch) {
|
||
throw new Error(
|
||
`${fp}: could not find 'const <name>: Translations = ' declaration`,
|
||
);
|
||
}
|
||
const startIdx = startMatch.index + startMatch[0].length;
|
||
// The literal ends at `};` followed by the export default line.
|
||
// Look for `};\n\nexport default` (matches our generator's output).
|
||
const endMatch = src.slice(startIdx).match(/\n};\s*\n\s*export default/);
|
||
if (!endMatch) {
|
||
throw new Error(`${fp}: could not find end of literal (};\\nexport default)`);
|
||
}
|
||
const literalSrc = src.slice(
|
||
startIdx,
|
||
startIdx + endMatch.index + "\n}".length,
|
||
);
|
||
// Duplicate-key guard. A repeated key in a locale literal is valid JS
|
||
// (the later one silently wins), so the vm eval below — and the coverage
|
||
// diff that follows — can't see it: it just quietly overrides a
|
||
// translation. Scan the raw literal source (one key per line in the
|
||
// generated files) for repeats before evaluating.
|
||
{
|
||
const keyRe = /^\s*"?([A-Za-z][A-Za-z0-9_.-]*)"?\s*:/gm;
|
||
const seen = new Set();
|
||
const dups = new Set();
|
||
let km;
|
||
while ((km = keyRe.exec(literalSrc))) {
|
||
if (seen.has(km[1])) dups.add(km[1]);
|
||
seen.add(km[1]);
|
||
}
|
||
if (dups.size > 0) {
|
||
throw new Error(
|
||
`${fp}: duplicate locale key(s) — a dup silently overrides a ` +
|
||
`translation: ${[...dups].sort().join(", ")}`,
|
||
);
|
||
}
|
||
}
|
||
const dict = vm.runInNewContext(`(${literalSrc})`, Object.create(null), {
|
||
timeout: 1000,
|
||
});
|
||
translations[code] = dict;
|
||
}
|
||
if (!translations.en) {
|
||
throw new Error("locales dir is missing en.ts (English is required)");
|
||
}
|
||
return translations;
|
||
}
|
||
|
||
function loadAllowlist() {
|
||
if (!fs.existsSync(ALLOWLIST_PATH)) return {};
|
||
try {
|
||
return JSON.parse(fs.readFileSync(ALLOWLIST_PATH, "utf8"));
|
||
} catch (e) {
|
||
throw new Error(`could not parse ${ALLOWLIST_PATH}: ${e.message}`);
|
||
}
|
||
}
|
||
|
||
/** Allowlist entries can be either a flat array of missing keys
|
||
* (legacy bootstrap shape) or an object with explicit `missing` and
|
||
* `stale` arrays. The object form is what `--update-allowlist`
|
||
* produces; both are accepted on read so older allowlist files
|
||
* written by the bootstrap step keep working. */
|
||
function readEntry(entry) {
|
||
if (Array.isArray(entry)) return { missing: entry, stale: [], untranslated: [] };
|
||
return {
|
||
missing: entry?.missing ?? [],
|
||
stale: entry?.stale ?? [],
|
||
untranslated: entry?.untranslated ?? [],
|
||
};
|
||
}
|
||
|
||
/**
|
||
* Is this en value PROSE — i.e. would a translator be expected to change
|
||
* it?
|
||
*
|
||
* Key presence alone is a bad proxy for "translated": a locale file can
|
||
* carry every key in en.ts and still be byte-identical English for
|
||
* hundreds of them. That is exactly what happened here — 18 locales
|
||
* reported 100% coverage while ~220 keys each were untouched English,
|
||
* because the gate below only ever compared key SETS.
|
||
*
|
||
* The heuristic: two or more ASCII words of 3+ letters means prose.
|
||
* That exempts the large tail of values it is correct to leave alone —
|
||
* units ("MB/s", "GHz"), acronyms ("CPU", "SoC", "M.2 SSD"), bare
|
||
* interpolations ("{n}"), and protocol literals ("pxplay:"). Anything
|
||
* the heuristic gets wrong (proper nouns like "PlayStation Store")
|
||
* goes in the `untranslated` allowlist bucket, same as every other
|
||
* exception this gate tracks.
|
||
*/
|
||
function isProse(value) {
|
||
return (value.match(/[A-Za-z]{3,}/g) ?? []).length >= 2;
|
||
}
|
||
|
||
function computeMissingPerLang(translations, allowlist) {
|
||
const en = translations.en;
|
||
if (!en) throw new Error("i18n.ts: missing 'en' language");
|
||
const enKeys = new Set(Object.keys(en));
|
||
const result = {};
|
||
for (const [lang, dict] of Object.entries(translations)) {
|
||
if (lang === "en") continue;
|
||
const langKeys = new Set(Object.keys(dict));
|
||
const entry = readEntry(allowlist[lang]);
|
||
const allowedMissing = new Set(entry.missing);
|
||
const allowedStale = new Set(entry.stale);
|
||
const allowedUntranslated = new Set(entry.untranslated);
|
||
const missing = [...enKeys]
|
||
.filter((k) => !langKeys.has(k) && !allowedMissing.has(k))
|
||
.sort();
|
||
const stale = [...langKeys]
|
||
.filter((k) => !enKeys.has(k) && !allowedStale.has(k))
|
||
.sort();
|
||
const untranslated = [...enKeys]
|
||
.filter(
|
||
(k) =>
|
||
langKeys.has(k) &&
|
||
dict[k] === en[k] &&
|
||
isProse(en[k]) &&
|
||
!allowedUntranslated.has(k),
|
||
)
|
||
.sort();
|
||
result[lang] = { missing, stale, untranslated };
|
||
}
|
||
return result;
|
||
}
|
||
|
||
function computeAllForLang(translations) {
|
||
const enKeys = new Set(Object.keys(translations.en ?? {}));
|
||
const result = {};
|
||
for (const [lang, dict] of Object.entries(translations)) {
|
||
if (lang === "en") continue;
|
||
const langKeys = new Set(Object.keys(dict));
|
||
const missing = [...enKeys].filter((k) => !langKeys.has(k)).sort();
|
||
const stale = [...langKeys].filter((k) => !enKeys.has(k)).sort();
|
||
const untranslated = [...enKeys]
|
||
.filter(
|
||
(k) =>
|
||
langKeys.has(k) &&
|
||
dict[k] === translations.en[k] &&
|
||
isProse(translations.en[k]),
|
||
)
|
||
.sort();
|
||
if (missing.length > 0 || stale.length > 0 || untranslated.length > 0) {
|
||
result[lang] = { missing, stale, untranslated };
|
||
}
|
||
}
|
||
return result;
|
||
}
|
||
|
||
/**
|
||
* Scan `client/src` for i18n keys referenced via `tr("literal", …)` (and
|
||
* `t(lang, "literal", …)`) and return the set of those NOT present in en.ts.
|
||
*
|
||
* Why this matters: the per-language coverage check above only validates
|
||
* translations OF en.ts keys. A key used at a call site with an inline
|
||
* fallback — `tr("foo", undefined, "Foo")` — but never added to en.ts is
|
||
* INVISIBLE to that check, yet it renders the English fallback for every
|
||
* user (including non-English locales) and can never be translated. This
|
||
* catches that class (fixed a batch of 70 such keys in the PR that added
|
||
* this guard). Multi-line `tr(\n "key"…)` calls are matched too.
|
||
*
|
||
* Only LITERAL first-arg keys are checked; dynamically-built keys
|
||
* (`tr(\`x.${k}\`)`) can't be statically resolved and are skipped — same
|
||
* limitation find-orphan-i18n.mjs documents. `foo`/`x` are the doc-comment
|
||
* example keys in i18n.ts / lang.ts and are ignored.
|
||
*/
|
||
function findUsedButMissing(enKeys) {
|
||
const SRC_DIR = path.join(repoRoot, "client/src");
|
||
const IGNORE = new Set(["foo", "x"]);
|
||
const used = new Set();
|
||
// tr( <ws/nl> "key" — and t( <ws> ident/"lang", <ws/nl> "key"
|
||
const trPat = /\btr\(\s*"([a-zA-Z0-9_.]+)"/g;
|
||
const walk = (dir) => {
|
||
for (const ent of fs.readdirSync(dir, { withFileTypes: true })) {
|
||
const fp = path.join(dir, ent.name);
|
||
if (ent.isDirectory()) {
|
||
if (ent.name === "i18n" && dir.endsWith("/src")) continue; // skip locale defs
|
||
walk(fp);
|
||
} else if (/\.tsx?$/.test(ent.name)) {
|
||
const txt = fs.readFileSync(fp, "utf8");
|
||
let m;
|
||
while ((m = trPat.exec(txt)) !== null) used.add(m[1]);
|
||
}
|
||
}
|
||
};
|
||
walk(SRC_DIR);
|
||
return [...used]
|
||
.filter((k) => !IGNORE.has(k) && !enKeys.has(k))
|
||
.sort();
|
||
}
|
||
|
||
const translations = loadTranslations();
|
||
|
||
// Gate: any key USED at a call site but absent from en.ts. Runs before the
|
||
// per-language coverage check (and is skipped in --update-allowlist bootstrap).
|
||
if (!updateAllowlist) {
|
||
const enKeys = new Set(Object.keys(translations.en ?? {}));
|
||
const usedButMissing = findUsedButMissing(enKeys);
|
||
if (usedButMissing.length > 0) {
|
||
process.stderr.write(
|
||
`[i18n-coverage] failed: ${usedButMissing.length} key(s) used via tr("…") but NOT in en.ts.\n` +
|
||
"These render their English fallback for EVERY locale and can never be translated.\n" +
|
||
"Add each to client/src/i18n/locales/en.ts (then allowlist for other locales):\n",
|
||
);
|
||
for (const k of usedButMissing) process.stderr.write(` ${k}\n`);
|
||
process.exit(1);
|
||
}
|
||
}
|
||
|
||
if (updateAllowlist) {
|
||
const all = computeAllForLang(translations);
|
||
// Keep langs + their key arrays sorted for stable diffs.
|
||
const sorted = Object.fromEntries(
|
||
Object.keys(all)
|
||
.sort()
|
||
.map((lang) => [lang, all[lang]]),
|
||
);
|
||
fs.writeFileSync(
|
||
ALLOWLIST_PATH,
|
||
`${JSON.stringify(sorted, null, 2)}\n`,
|
||
"utf8",
|
||
);
|
||
process.stdout.write(
|
||
`[i18n-coverage] wrote allowlist for ${Object.keys(sorted).length} language(s) to ${path.relative(repoRoot, ALLOWLIST_PATH)}\n`,
|
||
);
|
||
process.exit(0);
|
||
}
|
||
|
||
const allowlist = loadAllowlist();
|
||
const perLang = computeMissingPerLang(translations, allowlist);
|
||
|
||
let failed = false;
|
||
const lines = [];
|
||
for (const [lang, info] of Object.entries(perLang)) {
|
||
const missCount = info.missing.length;
|
||
const staleCount = info.stale.length;
|
||
const untransCount = info.untranslated.length;
|
||
const entry = readEntry(allowlist[lang]);
|
||
const allowCount =
|
||
entry.missing.length + entry.stale.length + entry.untranslated.length;
|
||
if (missCount === 0 && staleCount === 0 && untransCount === 0) {
|
||
if (report) {
|
||
lines.push(
|
||
` ${lang.padEnd(8)} ok (${allowCount} allowlisted)`,
|
||
);
|
||
}
|
||
continue;
|
||
}
|
||
failed = true;
|
||
lines.push(
|
||
` ${lang.padEnd(8)} missing=${missCount} stale=${staleCount} ` +
|
||
`untranslated=${untransCount} allowlisted=${allowCount}`,
|
||
);
|
||
for (const k of info.missing.slice(0, 20)) {
|
||
lines.push(` missing: ${k}`);
|
||
}
|
||
if (info.missing.length > 20) {
|
||
lines.push(` … and ${info.missing.length - 20} more`);
|
||
}
|
||
for (const k of info.stale.slice(0, 20)) {
|
||
lines.push(` stale: ${k}`);
|
||
}
|
||
if (info.stale.length > 20) {
|
||
lines.push(` … and ${info.stale.length - 20} more`);
|
||
}
|
||
for (const k of info.untranslated.slice(0, 20)) {
|
||
lines.push(` untranslated (still English): ${k}`);
|
||
}
|
||
if (info.untranslated.length > 20) {
|
||
lines.push(` … and ${info.untranslated.length - 20} more`);
|
||
}
|
||
}
|
||
|
||
if (failed) {
|
||
process.stderr.write("[i18n-coverage] failed:\n");
|
||
process.stderr.write(`${lines.join("\n")}\n`);
|
||
process.stderr.write(
|
||
"\n fix: translate the keys above OR add them to scripts/i18n-known-missing.json\n",
|
||
);
|
||
process.stderr.write(
|
||
" bootstrap (caution): node scripts/i18n-coverage.mjs --update-allowlist\n",
|
||
);
|
||
process.exit(1);
|
||
}
|
||
|
||
if (report) {
|
||
process.stdout.write("[i18n-coverage] all languages within allowlist budget\n");
|
||
process.stdout.write(`${lines.join("\n")}\n`);
|
||
} else {
|
||
process.stdout.write(
|
||
`[i18n-coverage] ok (${Object.keys(translations).length} languages)\n`,
|
||
);
|
||
}
|