mirror of
https://github.com/MHSanaei/3x-ui.git
synced 2026-08-10 05:10:58 +00:00
chore(i18n): delete 230 dead translation keys and guard against new ones (#6132)
* chore(i18n): delete 230 dead translation keys and guard against new ones The 13 locale files carried 230 keys (11% of the set) that nothing in the frontend or Go sources references — leftovers of renamed features (the email notifier reuses tgbot.messages.* for subjects, the old email.subject*/title* set was orphaned; likewise menu.*, the clients bulk-copy strings, and the secAlert* family). Nothing detected this: a missing key falls back to en-US and an unused key fails nothing. A new test now fails the build when an en-US key has no reference in frontend/src or internal Go sources (dynamic keys are covered by harvesting concatenation and template-literal prefixes), and pins that all 13 locales carry exactly the en-US key set, so parity drift surfaces at test time instead of as a silent fallback. Each locale shrinks by the same 230 keys; net -2,900 lines across the translation set. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(i18n): restore the 29 live remarkVars keys, match whole tokens, unmask 9 more From review: the template-literal harvester required the prefix to end on a dot, so pages.hosts.remarkVars.desc${token} harvested nothing and all 29 desc* tooltip keys were wrongly deleted — and the guard shared the flawed logic, so CI stayed green while the Hosts page would have shown raw key names in 13 languages. Restored from the parent commit; the harvester now requires at least one dot but not a trailing one. Also from review: references are matched as whole dotted tokens instead of substrings (a dead key can no longer hide behind a longer sibling — that unmasked 9 more genuinely dead keys, each verified by hand before deletion), and the test excludes itself from the scan so its own prose cannot whitelist a subtree. Net: -210 keys per locale instead of the previous -230. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> --------- Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,91 @@
|
||||
import { readFileSync, readdirSync, statSync } from 'node:fs';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import { join, resolve } from 'node:path';
|
||||
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
/*
|
||||
* Guards the 13-locale translation set two ways: every key in en-US must be
|
||||
* referenced somewhere in the frontend or Go sources (dead keys accumulate
|
||||
* silently — this test deleted over two hundred of them when it was
|
||||
* introduced), and every locale must carry exactly the en-US key set
|
||||
* (missing keys fall back to en-US at runtime, so nothing else fails the
|
||||
* build when a translation is forgotten).
|
||||
*
|
||||
* References are matched as whole dotted tokens, not substrings, so a dead
|
||||
* key cannot hide behind a longer live sibling. Dynamically built keys are
|
||||
* covered by harvesting the string-literal prefixes that appear next to
|
||||
* concatenation or template-literal interpolation; the prefix needs at
|
||||
* least one dot but need not end on one, so a literal that stops mid-leaf
|
||||
* right before the interpolation still keeps its subtree alive. This file
|
||||
* excludes itself from the scan so the prose above cannot whitelist
|
||||
* anything.
|
||||
*/
|
||||
|
||||
const repoRoot = resolve(process.cwd(), '..');
|
||||
const translationDir = join(repoRoot, 'internal', 'web', 'translation');
|
||||
const selfPath = fileURLToPath(import.meta.url);
|
||||
|
||||
function flattenKeys(obj: Record<string, unknown>, prefix = ''): string[] {
|
||||
const keys: string[] = [];
|
||||
for (const [k, v] of Object.entries(obj)) {
|
||||
const key = `${prefix}${k}`;
|
||||
if (v !== null && typeof v === 'object') {
|
||||
keys.push(...flattenKeys(v as Record<string, unknown>, `${key}.`));
|
||||
} else {
|
||||
keys.push(key);
|
||||
}
|
||||
}
|
||||
return keys;
|
||||
}
|
||||
|
||||
function collectSources(dir: string, exts: string[], out: string[]): void {
|
||||
for (const entry of readdirSync(dir)) {
|
||||
if (['node_modules', 'dist', 'generated', '.git', '.local', 'storybook-static'].includes(entry)) continue;
|
||||
const full = join(dir, entry);
|
||||
if (statSync(full).isDirectory()) {
|
||||
collectSources(full, exts, out);
|
||||
} else if (exts.some((ext) => entry.endsWith(ext)) && resolve(full) !== selfPath) {
|
||||
out.push(readFileSync(full, 'utf8'));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
describe('i18n keys', () => {
|
||||
const enUS = JSON.parse(readFileSync(join(translationDir, 'en-US.json'), 'utf8'));
|
||||
const enKeys = flattenKeys(enUS);
|
||||
|
||||
const sources: string[] = [];
|
||||
collectSources(join(repoRoot, 'frontend', 'src'), ['.ts', '.tsx'], sources);
|
||||
collectSources(join(repoRoot, 'internal'), ['.go'], sources);
|
||||
const blob = sources.join('\n');
|
||||
|
||||
const tokens = new Set(blob.match(/[A-Za-z][A-Za-z0-9_.]*/g) ?? []);
|
||||
|
||||
const prefixes: string[] = [];
|
||||
for (const match of blob.matchAll(/['"`]([A-Za-z][A-Za-z0-9_]*(?:\.[A-Za-z0-9_]+)+\.?)['"`]\s*\+/g)) {
|
||||
prefixes.push(match[1]);
|
||||
}
|
||||
for (const match of blob.matchAll(/[`']([A-Za-z][A-Za-z0-9_]*(?:\.[A-Za-z0-9_]+)+\.?)\$\{/g)) {
|
||||
prefixes.push(match[1]);
|
||||
}
|
||||
|
||||
it('every en-US key is referenced by the frontend or Go sources', () => {
|
||||
const dead = enKeys.filter(
|
||||
(key) => !tokens.has(key) && !prefixes.some((p) => key.startsWith(p)),
|
||||
);
|
||||
expect(dead, `dead i18n keys (delete from all 13 locales):\n ${dead.join('\n ')}`).toEqual([]);
|
||||
});
|
||||
|
||||
it('every locale carries exactly the en-US key set', () => {
|
||||
const enSet = new Set(enKeys);
|
||||
for (const file of readdirSync(translationDir)) {
|
||||
if (!file.endsWith('.json') || file === 'en-US.json') continue;
|
||||
const keys = new Set(flattenKeys(JSON.parse(readFileSync(join(translationDir, file), 'utf8'))));
|
||||
const missing = enKeys.filter((k) => !keys.has(k));
|
||||
const orphans = [...keys].filter((k) => !enSet.has(k));
|
||||
expect(missing, `${file} is missing keys:\n ${missing.join('\n ')}`).toEqual([]);
|
||||
expect(orphans, `${file} has keys absent from en-US:\n ${orphans.join('\n ')}`).toEqual([]);
|
||||
}
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user