Files
MyIP/tests/locale-packs.test.js
jason5ng32andClaude Fable 5 e61a1992f3 Feat(i18n): empty-value skeletons with scaffolding and build-time pruning
A beta pack is now en's full skeleton with untranslated values left as
"", so translation review is always a `"" -> text` diff and English left
in place can't masquerade as translated. pnpm i18n-new scaffolds a pack
and its registry line (--privacy / --checklist for the optional files,
which ship finished or not at all); pnpm i18n-sync realigns packs when
en changes. The gate flips accordingly (exact key parity everywhere, ""
legal only in beta main packs) and a Vite plugin prunes empty values at
build time, so shipped chunks carry only translated strings and ""
behaves exactly like an absent key at runtime.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-22 21:17:20 +08:00

166 lines
7.7 KiB
JavaScript

// The hard gate for translations: what a locale pack may and may not do.
// Every rule is expressed against en, the reference pack — a translation may
// lag behind it, never contradict it.
//
// Packs are skeletons: a main pack has exactly en's keys, and an untranslated
// string is "" rather than a missing key, so a review diff reads `"" → text`
// and nothing can hide between "not translated yet" and "forgot". `pnpm
// i18n-new` writes the skeleton, `pnpm i18n-sync` keeps it aligned, and
// vite.config.js strips the empties on the way into the bundle.
//
// The main pack is the one that ships half-done: beta locales may leave any
// value "". The two optional files are all-or-nothing — they render as a
// whole, so a beta locale either finishes one or leaves it out, and a full
// locale ships all three. Coverage progress is not a failure: run
// `pnpm i18n-status` to see how far along a language is.
import assert from 'node:assert/strict';
import { describe, it } from 'node:test';
import fs from 'node:fs';
import {
LOCALE_CODES,
FULL_LOCALE_CODES,
FALLBACK_LOCALE,
} from '../common/locale-registry.js';
const localesDir = new URL('../frontend/locales/', import.meta.url);
// The three per-locale datasets. `optional` ones may be left out by a beta
// locale — but never left half-translated: neither has a per-key fallback of
// its own, so what ships is one file's worth of language.
const DATASETS = [
{ name: 'main pack', dir: '', optional: false },
{ name: 'privacy copy', dir: 'privacy/', optional: true },
{ name: 'security checklist', dir: 'security-checklist/', optional: true },
];
const packUrl = (dir, code) => new URL(`${dir}${code}.json`, localesDir);
const packExists = (dir, code) => fs.existsSync(packUrl(dir, code));
const readPack = (dir, code) => JSON.parse(fs.readFileSync(packUrl(dir, code), 'utf8'));
// path → leaf value. Arrays flatten by index, so an entry added or dropped
// mid-array reads as a changed key rather than a silent re-alignment.
const flatten = (value, prefix = '', out = new Map()) => {
for (const [key, child] of Object.entries(value)) {
const path = prefix ? `${prefix}.${key}` : key;
if (child && typeof child === 'object') flatten(child, path, out);
else out.set(path, child);
}
return out;
};
const isBlank = (value) => String(value ?? '').trim() === '';
const placeholders = (value) => new Set(String(value).match(/\{[^}]*\}/g) ?? []);
const reference = new Map(DATASETS.map(({ dir }) => [dir, flatten(readPack(dir, FALLBACK_LOCALE))]));
const translations = LOCALE_CODES.filter((code) => code !== FALLBACK_LOCALE);
const isFull = (code) => FULL_LOCALE_CODES.includes(code);
describe('locale packs — registry and files agree', () => {
it('every registered locale ships a main pack', () => {
for (const code of LOCALE_CODES) {
assert.ok(packExists('', code),
`frontend/locales/${code}.json is missing — scaffold it with \`pnpm i18n-new ${code}\``);
}
});
it('every main pack in the folder is a registered locale', () => {
const onDisk = fs.readdirSync(localesDir)
.filter((name) => name.endsWith('.json'))
.map((name) => name.replace(/\.json$/, ''));
for (const code of onDisk) {
assert.ok(LOCALE_CODES.includes(code),
`frontend/locales/${code}.json has no entry in common/locale-registry.js`);
}
});
it('en ships all three datasets — it is what everything falls back to', () => {
for (const { name, dir } of DATASETS) {
assert.ok(packExists(dir, FALLBACK_LOCALE), `en is missing its ${name}`);
}
});
});
describe('locale packs — rules every translation follows', () => {
for (const { name, dir, optional } of DATASETS) {
for (const code of translations) {
if (!packExists(dir, code)) continue;
const en = reference.get(dir);
const pack = flatten(readPack(dir, code));
const label = `${dir}${code}.json`;
// Every pack that exists is a skeleton of en: same keys, no more,
// no fewer. What may be left "" is what differs below.
it(`${label} (${name}) has exactly en's keys`, () => {
const extra = [...pack.keys()].filter((key) => !en.has(key));
assert.deepEqual(extra, [], `${label}: keys absent from en — a typo'd path is the usual cause`);
const missing = [...en.keys()].filter((key) => !pack.has(key));
assert.deepEqual(missing, [], optional
? `${label}: keys missing against en — finish the file or leave it out entirely`
: `${label}: keys missing against en — run \`pnpm i18n-sync\`, untranslated values are ""`);
});
it(`${label} (${name}) invents no placeholder en doesn't have`, () => {
for (const [key, value] of pack) {
if (isBlank(value)) continue; // untranslated: nothing to check
const allowed = placeholders(en.get(key) ?? '');
for (const token of placeholders(value)) {
assert.ok(allowed.has(token),
`${label}: ${key} uses ${token}, which en doesn't provide`);
}
}
});
// The optional files load whole, with no per-key fallback inside
// them, so one that exists must be finished.
if (optional) {
it(`${label} (${name}) is complete — it ships whole or not at all`, () => {
for (const [key, value] of pack) {
if (!isBlank(value) || isBlank(en.get(key))) continue;
assert.fail(`${label}: ${key} is empty — finish the file or leave it out entirely`);
}
});
}
if (!optional && isFull(code)) {
it(`${label} (${name}) leaves nothing untranslated — ${code} is a full locale`, () => {
for (const [key, value] of pack) {
if (!isBlank(value) || isBlank(en.get(key))) continue;
assert.fail(`${label}: ${key} is empty — a full locale translates everything`);
}
});
}
}
}
// Slugs are URLs and priorities drive the badge colors — both are data, not
// copy, and the tool reads them positionally against every locale's file.
for (const code of translations) {
if (!packExists('security-checklist/', code)) continue;
it(`security-checklist/${code}.json keeps en's slugs and priorities`, () => {
const en = reference.get('security-checklist/');
const pack = flatten(readPack('security-checklist/', code));
for (const [key, value] of en) {
if (!/(^|\.)(slug|priority)$/.test(key)) continue;
assert.equal(pack.get(key), value, `security-checklist/${code}.json: ${key} was translated`);
}
});
}
});
describe('locale packs — full locales are complete', () => {
for (const code of FULL_LOCALE_CODES) {
if (code === FALLBACK_LOCALE) continue;
for (const { name, dir } of DATASETS) {
it(`${dir}${code}.json (${name}) covers every en key`, () => {
assert.ok(packExists(dir, code),
`${code} is a full locale but has no ${name} — ship the file or mark it beta`);
const pack = flatten(readPack(dir, code));
const missing = [...reference.get(dir).keys()].filter((key) => !pack.has(key));
assert.deepEqual(missing, [], `${dir}${code}.json: keys missing against en`);
});
}
}
});