Let a locale ship without every string translated (#286)

Follow-up to #285. Three corrections that rode in with the Japanese locale.

1. The ja key-parity check failed the build whenever en.js had a key ja.js
   lacked. i18n.js lookup() is already
   `registry[lang]?.[key] ?? fallback[key] ?? key`, so an untranslated string
   renders in English and nothing is broken by a gap - the only effect was
   that adding any English string blocked CI until a Japanese translation
   existed. It also singled out one locale; es/fr/de/pt/hi/it were never held
   to it, and hi.js is a deliberate skeleton whose own header explains that
   every key falls back to English on purpose.

   Replaced with two checks over EVERY locale: a locale may not define a key
   that English does not (dead weight after a rename, and fixable by whoever
   touched the file, whatever language they speak), and coverage is printed
   rather than gated. Help tips still have to exist everywhere - that test is
   unchanged and still fails.

   Current coverage: ja 100%, es 65.5%, fr/de/pt 63.7%, it 59.5%, hi 0%.

2. Applying the strict half to all locales immediately found
   add_display.smart_tv_note living in fr, pt, it and de but not in en.js and
   referenced by no view - a string dropped from English that left four
   translations behind. Removed.

3. The new timezone test restored process.env.TZ by assigning the saved value
   back. When TZ was not set to begin with - which is the case in CI - that
   assigns undefined, which writes the STRING "undefined"; Node cannot parse
   it and silently falls back to UTC for the rest of the process. Every test
   after it in that file is date arithmetic. It now deletes the key when it
   was previously unset.

4. package-lock.json removed from .gitignore. server/package-lock.json is
   tracked, so the rule was inert, but it would silently prevent a future
   lockfile and works against the SBOM and reproducible-install setup added
   in #282.


Claude-Session: https://claude.ai/code/session_014kfhrUPit5MCqxeTQyqr56

Co-authored-by: Dan Walters <dan.walters@bytetinker.net>
Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
screentinker 2026-08-18 09:37:56 -05:00 committed by GitHub
parent 70af0d0194
commit 0511e9b5bb
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
7 changed files with 43 additions and 13 deletions

1
.gitignore vendored
View file

@ -63,4 +63,3 @@ audit/
# Generated by scripts/license-check.js --sbom (CI publishes it as a release asset)
sbom/
*.cdx.json
package-lock.json

View file

@ -1204,6 +1204,5 @@ export default {
'add_display.web_player': 'Web-Player',
'add_display.raspberry_pi': 'Raspberry Pi',
'add_display.windows': 'Windows',
'add_display.smart_tv_note': 'Smart TVs (LG/Samsung): öffnen Sie den integrierten Browser und navigieren Sie zu <code style="background:var(--bg-input,#0f172a);padding:1px 4px;border-radius:3px">/player</code>',
'add_display.pair_btn': 'Bildschirm koppeln',
};

View file

@ -1204,6 +1204,5 @@ export default {
'add_display.web_player': 'Lecteur web',
'add_display.raspberry_pi': 'Raspberry Pi',
'add_display.windows': 'Windows',
'add_display.smart_tv_note': 'Smart TVs (LG/Samsung) : ouvrez le navigateur intégré et allez à <code style="background:var(--bg-input,#0f172a);padding:1px 4px;border-radius:3px">/player</code>',
'add_display.pair_btn': 'Apparier l\'écran',
};

View file

@ -1162,6 +1162,5 @@ export default {
'add_display.web_player': 'Web Player',
'add_display.raspberry_pi': 'Raspberry Pi',
'add_display.windows': 'Windows',
'add_display.smart_tv_note': 'Smart TV (LG/Samsung): apri il browser integrato e vai su <code style="background:var(--bg-input,#0f172a);padding:1px 4px;border-radius:3px">/player</code>',
'add_display.pair_btn': 'Associa Schermo',
};

View file

@ -1204,6 +1204,5 @@ export default {
'add_display.web_player': 'Player web',
'add_display.raspberry_pi': 'Raspberry Pi',
'add_display.windows': 'Windows',
'add_display.smart_tv_note': 'Smart TVs (LG/Samsung): abra o navegador integrado e vá para <code style="background:var(--bg-input,#0f172a);padding:1px 4px;border-radius:3px">/player</code>',
'add_display.pair_btn': 'Parear tela',
};

View file

@ -102,13 +102,43 @@ test('every help tip is translated in every active locale', () => {
`these tips fall back to English:\n ${missing.join('\n ')}`);
});
test('Japanese mirrors every key in the English source', () => {
const ja = fs.readFileSync(path.join(FRONTEND, 'i18n', 'ja.js'), 'utf8');
const japaneseKeys = new Set([...ja.matchAll(/^\s*'([^']+)'\s*:/gm)].map(m => m[1]));
assert.deepEqual([...defined].filter(k => !japaneseKeys.has(k)), [],
'ja.js is missing keys from en.js');
assert.deepEqual([...japaneseKeys].filter(k => !defined.has(k)), [],
'ja.js contains keys that do not exist in en.js');
// Every locale shipped in frontend/js/i18n. Keep in step with the registry in i18n.js.
const ACTIVE_LOCALES = ['es', 'fr', 'de', 'pt', 'hi', 'it', 'ja'];
test('a locale never defines a key that English does not', () => {
// The half of parity that is ALWAYS actionable: a key in a locale file that no longer exists in
// en.js is dead weight or a typo left behind by a rename, and whoever touched that file can fix
// it without speaking the language. Missing keys are the other direction - see below.
for (const locale of ACTIVE_LOCALES) {
const src = fs.readFileSync(path.join(FRONTEND, 'i18n', `${locale}.js`), 'utf8');
const keys = new Set([...src.matchAll(/^\s*'([^']+)'\s*:/gm)].map(m => m[1]));
assert.deepEqual([...keys].filter(k => !defined.has(k)), [],
`${locale}.js defines keys that do not exist in en.js`);
}
});
test('translation coverage is reported, but an untranslated string is not a build failure', () => {
// ⚠️ WHY A MISSING TRANSLATION DOES NOT FAIL THE BUILD.
//
// i18n.js lookup() is `registry[lang]?.[key] ?? fallback[key] ?? key`, so an untranslated string
// already renders in English. Nothing is broken by a gap.
//
// Making the gap fatal - as the first version of the Japanese check did - means every new English
// string blocks CI until someone who reads that language is available. That is a guarantee we
// cannot keep, and it puts the cost on whoever is shipping the feature rather than on whoever can
// actually translate. It also singled out one locale: es, fr, de, pt, hi and it were never held
// to it.
//
// So: report the number, do not gate on it. The strings that genuinely must exist everywhere are
// the help tips, and they have their own test above, which does fail.
for (const locale of ACTIVE_LOCALES) {
const src = fs.readFileSync(path.join(FRONTEND, 'i18n', `${locale}.js`), 'utf8');
const keys = new Set([...src.matchAll(/^\s*'([^']+)'\s*:/gm)].map(m => m[1]));
const missing = [...defined].filter(k => !keys.has(k));
const pct = ((defined.size - missing.length) / defined.size * 100).toFixed(1);
console.log(` ${locale}: ${defined.size - missing.length}/${defined.size} (${pct}%)` +
(missing.length ? ` - ${missing.length} fall back to English` : ''));
}
});
test('a tip marker in a view always names a real string', () => {

View file

@ -38,6 +38,10 @@ const mk = (recurrence, startISO, recurrenceEnd = null) => ({
const weekdaysOf = (events) => events.map(e => new Date(e.instance_start).getDay()).sort();
test('a date-only week anchor retains its calendar day west of UTC', () => {
// ⚠️ Record WHETHER it was set, not just its value. `process.env.TZ = undefined` writes the
// STRING "undefined", which Node cannot parse and silently resolves to UTC - changing the zone
// for every test that runs after this one in this file, all of which are date arithmetic.
const hadTz = Object.prototype.hasOwnProperty.call(process.env, 'TZ');
const originalTz = process.env.TZ;
process.env.TZ = 'America/Los_Angeles';
try {
@ -50,7 +54,8 @@ test('a date-only week anchor retains its calendar day west of UTC', () => {
assert.equal(selected.getDate(), 9);
assert.equal(selected.getDay(), 0, 'Sunday remains Sunday');
} finally {
process.env.TZ = originalTz;
if (hadTz) process.env.TZ = originalTz;
else delete process.env.TZ;
}
});