mirror of
https://github.com/jhd3197/ServerKit.git
synced 2026-11-12 15:03:55 +09:00
`--locales <dir>` points the four assertions at another tree's bundles, so an extension repo verifies its own translations with the panel's check instead of copying it -- copying is how the vendor shims drifted. The languages.json cross-check is skipped there, since an extension ships bundles rather than a language list. It also now reports keys present in en.json but absent from a translation. Those render English rather than failing, so nothing was catching a bundle that was quietly half-finished -- which is exactly the state a translation pass starts in and needs to be able to see. Used to verify all 13 extension Spanish bundles (2,073 keys) at full parity. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
143 lines
5.4 KiB
JavaScript
143 lines
5.4 KiB
JavaScript
#!/usr/bin/env node
|
|
// Plan 79 D2 — locale-file integrity.
|
|
//
|
|
// Four assertions, each guarding a failure that is invisible until a user in
|
|
// that language hits it:
|
|
//
|
|
// 1. No orphan keys. A key in es.json that no longer exists in en.json is
|
|
// dead weight at best, and at worst hides that the English moved on.
|
|
// 2. No missing interpolation placeholders. A translation that drops
|
|
// `{{count}}` renders a sentence with a hole in it; one that keeps a
|
|
// placeholder English no longer passes renders literal braces to a user.
|
|
// 3. Same shape. A leaf in one file and a namespace in another means one of
|
|
// the two renders "[object Object]".
|
|
// 4. The manifest and the shipped files agree. A language advertised in the
|
|
// selector with no bundle silently falls back to English, and the user
|
|
// cannot tell the difference between "not translated" and "broken".
|
|
//
|
|
// Coverage (how much of the app is keyed at all) is the separate
|
|
// check-i18n-literals ratchet; this script is about the files being coherent.
|
|
//
|
|
// Usage (from frontend/):
|
|
// node scripts/check-i18n.mjs
|
|
|
|
import { readFileSync, readdirSync } from 'node:fs';
|
|
import { basename, dirname, extname, join, resolve } from 'node:path';
|
|
import { fileURLToPath } from 'node:url';
|
|
|
|
const here = dirname(fileURLToPath(import.meta.url));
|
|
const root = resolve(here, '..');
|
|
|
|
// `--locales <dir>` checks another tree's bundles — an extension repo's own
|
|
// locales/. Same four assertions; extensions get orphan and placeholder drift
|
|
// exactly like the panel does, and a dropped {{count}} is just as invisible
|
|
// there. `--no-manifest` skips the languages.json cross-check, which only the
|
|
// panel has (an extension ships bundles, not a language list).
|
|
const localesArg = process.argv.includes('--locales')
|
|
? process.argv[process.argv.indexOf('--locales') + 1]
|
|
: null;
|
|
const skipManifest = process.argv.includes('--no-manifest') || Boolean(localesArg);
|
|
const localesDir = localesArg
|
|
? resolve(process.cwd(), localesArg)
|
|
: resolve(root, 'src', 'i18n', 'locales');
|
|
const manifestFile = resolve(root, 'src', 'i18n', 'languages.json');
|
|
|
|
const DEFAULT_LOCALE = 'en';
|
|
const PLACEHOLDER = /\{\{\s*(\w+)[^}]*\}\}/g;
|
|
|
|
function readJson(path) {
|
|
return JSON.parse(readFileSync(path, 'utf8'));
|
|
}
|
|
|
|
/** Flatten a nested locale tree into dotted key -> string. */
|
|
function flatten(node, prefix = '', out = new Map()) {
|
|
for (const [key, value] of Object.entries(node)) {
|
|
const path = prefix ? `${prefix}.${key}` : key;
|
|
if (value && typeof value === 'object' && !Array.isArray(value)) {
|
|
flatten(value, path, out);
|
|
} else {
|
|
out.set(path, value);
|
|
}
|
|
}
|
|
return out;
|
|
}
|
|
|
|
function placeholders(value) {
|
|
return new Set([...String(value).matchAll(PLACEHOLDER)].map((match) => match[1]));
|
|
}
|
|
|
|
const problems = [];
|
|
|
|
const shipped = readdirSync(localesDir)
|
|
.filter((name) => extname(name) === '.json')
|
|
.map((name) => basename(name, '.json'));
|
|
|
|
// 4. manifest <-> files (panel only)
|
|
if (!skipManifest) {
|
|
const manifest = readJson(manifestFile);
|
|
const declared = manifest.languages.map((language) => language.code);
|
|
for (const code of declared) {
|
|
if (!shipped.includes(code)) {
|
|
problems.push(`languages.json advertises '${code}' but src/i18n/locales/${code}.json is missing`);
|
|
}
|
|
}
|
|
for (const code of shipped) {
|
|
if (!declared.includes(code)) {
|
|
problems.push(`src/i18n/locales/${code}.json ships but languages.json does not advertise '${code}'`);
|
|
}
|
|
}
|
|
}
|
|
|
|
const base = flatten(readJson(join(localesDir, `${DEFAULT_LOCALE}.json`)));
|
|
|
|
for (const code of shipped.filter((name) => name !== DEFAULT_LOCALE)) {
|
|
const translated = flatten(readJson(join(localesDir, `${code}.json`)));
|
|
|
|
for (const [key, value] of translated) {
|
|
// 1. orphans
|
|
if (!base.has(key)) {
|
|
problems.push(`${code}.json: '${key}' does not exist in ${DEFAULT_LOCALE}.json`);
|
|
continue;
|
|
}
|
|
// 3. shape
|
|
if (typeof value !== 'string') {
|
|
problems.push(`${code}.json: '${key}' is ${typeof value}, expected a string`);
|
|
continue;
|
|
}
|
|
// 2. placeholders
|
|
const expected = placeholders(base.get(key));
|
|
const actual = placeholders(value);
|
|
for (const name of expected) {
|
|
if (!actual.has(name)) {
|
|
problems.push(`${code}.json: '${key}' drops the {{${name}}} placeholder`);
|
|
}
|
|
}
|
|
for (const name of actual) {
|
|
if (!expected.has(name)) {
|
|
problems.push(`${code}.json: '${key}' introduces an unknown {{${name}}} placeholder`);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
if (problems.length) {
|
|
console.error('\ni18n locale check failed:\n');
|
|
for (const problem of problems) console.error(` ${problem}`);
|
|
console.error('');
|
|
process.exit(1);
|
|
}
|
|
|
|
for (const code of shipped.filter((name) => name !== DEFAULT_LOCALE)) {
|
|
const translated = flatten(readJson(join(localesDir, `${code}.json`)));
|
|
const missing = [...base.keys()].filter((key) => !translated.has(key));
|
|
if (missing.length) {
|
|
console.log(` ${code}: ${missing.length} key(s) not translated yet `
|
|
+ `(they render English) — e.g. ${missing.slice(0, 3).join(', ')}`);
|
|
}
|
|
}
|
|
|
|
const translatedCount = shipped.filter((name) => name !== DEFAULT_LOCALE).length;
|
|
console.log(
|
|
`✓ i18n locales: ${base.size} keys, ${translatedCount} translated locale(s), no orphans or placeholder drift.`,
|
|
);
|