mirror of
https://github.com/tinyhumansai/openhuman.git
synced 2026-07-27 21:08:00 +00:00
fix(i18n): translate locale fallback strings (#3004)
This commit is contained in:
+28
-168
@@ -1,17 +1,15 @@
|
||||
#!/usr/bin/env -S pnpm exec tsx
|
||||
/**
|
||||
* i18n-coverage — surface missing / incomplete / unused / drifted translation keys.
|
||||
* i18n-coverage — surface missing / extra / unused / untranslated translation keys.
|
||||
*
|
||||
* Source of truth: app/src/lib/i18n/chunks/en-{1..5}.ts (aggregated by app/src/lib/i18n/en.ts)
|
||||
* Translations: app/src/lib/i18n/chunks/<locale>-{1..5}.ts (aggregated by <locale>.ts)
|
||||
* Source of truth: app/src/lib/i18n/en.ts (single file, one flat key→string map)
|
||||
* Translations: app/src/lib/i18n/<locale>.ts (single file per locale)
|
||||
* Locale list: app/src/lib/i18n/types.ts (Locale union)
|
||||
*
|
||||
* Reports, per locale:
|
||||
* - missing chunk files
|
||||
* - missing keys (in en, absent in locale) — overall + per chunk
|
||||
* - missing keys (in en, absent in locale)
|
||||
* - extra keys (in locale, absent in en)
|
||||
* - placeholder/untranslated entries (value identical to English)
|
||||
* - per-chunk drift (key belongs to en-N but locale put it in <locale>-M, M≠N)
|
||||
*
|
||||
* Repo-wide:
|
||||
* - unused keys (defined in en, never referenced via t('…') / t("…") in app/src)
|
||||
@@ -19,7 +17,7 @@
|
||||
* Usage: pnpm exec tsx scripts/i18n-coverage.ts [--json] [--locale es,fr] [--no-unused] [--out <dir>]
|
||||
*
|
||||
* With --out <dir>, writes one JSON per non-English locale (<dir>/<locale>.json) containing
|
||||
* categorized work-lists for translators (missing, extra, drift, untranslated with en value).
|
||||
* categorized work-lists for translators (missing, extra, untranslated with en value).
|
||||
*/
|
||||
|
||||
import { promises as fs } from "node:fs";
|
||||
@@ -29,9 +27,7 @@ import { fileURLToPath, pathToFileURL } from "node:url";
|
||||
const __filename = fileURLToPath(import.meta.url);
|
||||
const ROOT = path.resolve(path.dirname(__filename), "..");
|
||||
const I18N_DIR = path.join(ROOT, "app/src/lib/i18n");
|
||||
const CHUNKS_DIR = path.join(I18N_DIR, "chunks");
|
||||
const APP_SRC = path.join(ROOT, "app/src");
|
||||
const CHUNK_COUNT = 5;
|
||||
|
||||
const ALL_LOCALES = [
|
||||
"en",
|
||||
@@ -46,6 +42,7 @@ const ALL_LOCALES = [
|
||||
"ru",
|
||||
"id",
|
||||
"it",
|
||||
"ko",
|
||||
"pl",
|
||||
] as const;
|
||||
type Locale = (typeof ALL_LOCALES)[number];
|
||||
@@ -113,21 +110,8 @@ function parseArgs(argv: string[]): CliOptions {
|
||||
return opts;
|
||||
}
|
||||
|
||||
async function fileExists(p: string): Promise<boolean> {
|
||||
try {
|
||||
await fs.access(p);
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
async function loadChunk(
|
||||
locale: Locale,
|
||||
n: number,
|
||||
): Promise<Record<string, string> | null> {
|
||||
const p = path.join(CHUNKS_DIR, `${locale}-${n}.ts`);
|
||||
if (!(await fileExists(p))) return null;
|
||||
async function loadLocale(locale: Locale): Promise<Record<string, string>> {
|
||||
const p = path.join(I18N_DIR, `${locale}.ts`);
|
||||
const mod = await import(pathToFileURL(p).href);
|
||||
const val = mod.default;
|
||||
if (!val || typeof val !== "object") {
|
||||
@@ -136,44 +120,10 @@ async function loadChunk(
|
||||
return val as Record<string, string>;
|
||||
}
|
||||
|
||||
interface LocaleData {
|
||||
chunks: Array<Record<string, string> | null>; // index 0..CHUNK_COUNT-1
|
||||
flat: Record<string, string>;
|
||||
keyToChunk: Map<string, number>; // 1-indexed chunk number
|
||||
missingChunks: number[]; // 1-indexed
|
||||
}
|
||||
|
||||
async function loadLocale(locale: Locale): Promise<LocaleData> {
|
||||
const chunks: Array<Record<string, string> | null> = [];
|
||||
const flat: Record<string, string> = {};
|
||||
const keyToChunk = new Map<string, number>();
|
||||
const missingChunks: number[] = [];
|
||||
|
||||
for (let n = 1; n <= CHUNK_COUNT; n++) {
|
||||
const chunk = await loadChunk(locale, n);
|
||||
chunks.push(chunk);
|
||||
if (chunk === null) {
|
||||
missingChunks.push(n);
|
||||
continue;
|
||||
}
|
||||
for (const [k, v] of Object.entries(chunk)) {
|
||||
// Last write wins (matches the runtime spread order in <locale>.ts).
|
||||
flat[k] = v;
|
||||
keyToChunk.set(k, n);
|
||||
}
|
||||
}
|
||||
return { chunks, flat, keyToChunk, missingChunks };
|
||||
}
|
||||
|
||||
async function walkSourceFiles(dir: string, out: string[]): Promise<void> {
|
||||
const entries = await fs.readdir(dir, { withFileTypes: true });
|
||||
for (const e of entries) {
|
||||
if (
|
||||
e.name === "node_modules" ||
|
||||
e.name === "__tests__" ||
|
||||
e.name === "lib/i18n"
|
||||
)
|
||||
continue;
|
||||
if (e.name === "node_modules" || e.name === "__tests__") continue;
|
||||
const p = path.join(dir, e.name);
|
||||
if (e.isDirectory()) {
|
||||
// Skip the i18n directory itself — we don't count the definitions as usages.
|
||||
@@ -206,25 +156,18 @@ async function collectUsedKeys(): Promise<Set<string>> {
|
||||
|
||||
interface LocaleReport {
|
||||
locale: Locale;
|
||||
missingChunks: number[];
|
||||
totalKeys: number;
|
||||
missingKeys: string[];
|
||||
extraKeys: string[];
|
||||
untranslatedKeys: string[]; // value === english value (excluding intentional brand strings)
|
||||
driftedKeys: Array<{
|
||||
key: string;
|
||||
expectedChunk: number;
|
||||
actualChunk: number;
|
||||
}>;
|
||||
perChunk: Array<{ chunk: number; missing: number; total: number }>;
|
||||
untranslatedKeys: string[]; // value === english value
|
||||
}
|
||||
|
||||
function diffKeys(
|
||||
en: LocaleData,
|
||||
other: LocaleData,
|
||||
en: Record<string, string>,
|
||||
other: Record<string, string>,
|
||||
): { missing: string[]; extra: string[] } {
|
||||
const enKeys = new Set(Object.keys(en.flat));
|
||||
const otherKeys = new Set(Object.keys(other.flat));
|
||||
const enKeys = new Set(Object.keys(en));
|
||||
const otherKeys = new Set(Object.keys(other));
|
||||
const missing: string[] = [];
|
||||
const extra: string[] = [];
|
||||
for (const k of enKeys) if (!otherKeys.has(k)) missing.push(k);
|
||||
@@ -234,10 +177,13 @@ function diffKeys(
|
||||
return { missing, extra };
|
||||
}
|
||||
|
||||
function findUntranslated(en: LocaleData, other: LocaleData): string[] {
|
||||
function findUntranslated(
|
||||
en: Record<string, string>,
|
||||
other: Record<string, string>,
|
||||
): string[] {
|
||||
const out: string[] = [];
|
||||
for (const [k, v] of Object.entries(other.flat)) {
|
||||
const enV = en.flat[k];
|
||||
for (const [k, v] of Object.entries(other)) {
|
||||
const enV = en[k];
|
||||
if (enV === undefined) continue;
|
||||
if (v === enV && v.trim() !== "") out.push(k);
|
||||
}
|
||||
@@ -245,35 +191,6 @@ function findUntranslated(en: LocaleData, other: LocaleData): string[] {
|
||||
return out;
|
||||
}
|
||||
|
||||
function findDrift(en: LocaleData, other: LocaleData) {
|
||||
const out: Array<{
|
||||
key: string;
|
||||
expectedChunk: number;
|
||||
actualChunk: number;
|
||||
}> = [];
|
||||
for (const [k, actual] of other.keyToChunk) {
|
||||
const expected = en.keyToChunk.get(k);
|
||||
if (expected !== undefined && expected !== actual) {
|
||||
out.push({ key: k, expectedChunk: expected, actualChunk: actual });
|
||||
}
|
||||
}
|
||||
out.sort((a, b) => a.key.localeCompare(b.key));
|
||||
return out;
|
||||
}
|
||||
|
||||
function perChunkMissing(en: LocaleData, other: LocaleData) {
|
||||
const out: Array<{ chunk: number; missing: number; total: number }> = [];
|
||||
for (let n = 1; n <= CHUNK_COUNT; n++) {
|
||||
const enChunk = en.chunks[n - 1] ?? {};
|
||||
const otherChunk = other.chunks[n - 1] ?? {};
|
||||
const enKeys = Object.keys(enChunk);
|
||||
let missing = 0;
|
||||
for (const k of enKeys) if (!(k in otherChunk)) missing++;
|
||||
out.push({ chunk: n, missing, total: enKeys.length });
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
function formatReport(
|
||||
reports: LocaleReport[],
|
||||
unusedKeys: string[] | null,
|
||||
@@ -283,23 +200,11 @@ function formatReport(
|
||||
lines.push("");
|
||||
for (const r of reports) {
|
||||
lines.push(`## ${r.locale} (${r.totalKeys} keys)`);
|
||||
if (r.missingChunks.length) {
|
||||
lines.push(
|
||||
` ! missing chunk files: ${r.missingChunks.map((n) => `${r.locale}-${n}.ts`).join(", ")}`,
|
||||
);
|
||||
}
|
||||
lines.push(` missing: ${r.missingKeys.length}`);
|
||||
lines.push(` extra: ${r.extraKeys.length}`);
|
||||
lines.push(
|
||||
` untranslated: ${r.untranslatedKeys.length} (value identical to English)`,
|
||||
);
|
||||
lines.push(
|
||||
` drifted chunks: ${r.driftedKeys.length} (key in wrong chunk N)`,
|
||||
);
|
||||
const pc = r.perChunk
|
||||
.map((c) => `${c.chunk}:${c.total - c.missing}/${c.total}`)
|
||||
.join(" ");
|
||||
lines.push(` per-chunk: ${pc}`);
|
||||
if (r.missingKeys.length) {
|
||||
const preview = r.missingKeys.slice(0, 15).join(", ");
|
||||
const more =
|
||||
@@ -314,16 +219,6 @@ function formatReport(
|
||||
r.extraKeys.length > 15 ? `, … (+${r.extraKeys.length - 15} more)` : "";
|
||||
lines.push(` extra[head]: ${preview}${more}`);
|
||||
}
|
||||
if (r.driftedKeys.length) {
|
||||
const sample = r.driftedKeys
|
||||
.slice(0, 5)
|
||||
.map(
|
||||
(d) =>
|
||||
`${d.key} (en-${d.expectedChunk} → ${r.locale}-${d.actualChunk})`,
|
||||
)
|
||||
.join("; ");
|
||||
lines.push(` drift[head]: ${sample}`);
|
||||
}
|
||||
lines.push("");
|
||||
}
|
||||
if (unusedKeys) {
|
||||
@@ -343,30 +238,6 @@ async function main() {
|
||||
const opts = parseArgs(process.argv.slice(2));
|
||||
|
||||
const en = await loadLocale("en");
|
||||
if (en.missingChunks.length) {
|
||||
console.error(
|
||||
`! English chunks missing: ${en.missingChunks.join(", ")} — cannot continue.`,
|
||||
);
|
||||
process.exit(2);
|
||||
}
|
||||
|
||||
// Guard against drift between en.ts (runtime source of truth) and the en-N.ts chunks.
|
||||
// Both must agree key-for-key or downstream tests / translator workflows will diverge.
|
||||
const enAggregateModule = (await import(
|
||||
pathToFileURL(path.join(I18N_DIR, "en.ts")).href
|
||||
)) as { default: Record<string, string> };
|
||||
const enTsKeys = new Set(Object.keys(enAggregateModule.default));
|
||||
const enChunkKeys = new Set(Object.keys(en.flat));
|
||||
const inEnTsNotChunks = [...enTsKeys].filter((k) => !enChunkKeys.has(k));
|
||||
const inChunksNotEnTs = [...enChunkKeys].filter((k) => !enTsKeys.has(k));
|
||||
if (inEnTsNotChunks.length || inChunksNotEnTs.length) {
|
||||
console.error(
|
||||
`! Drift between en.ts and en-N.ts chunks:\n` +
|
||||
` in en.ts only: ${inEnTsNotChunks.join(", ") || "(none)"}\n` +
|
||||
` in chunks only: ${inChunksNotEnTs.join(", ") || "(none)"}`,
|
||||
);
|
||||
process.exit(2);
|
||||
}
|
||||
|
||||
const reports: LocaleReport[] = [];
|
||||
for (const locale of opts.locales) {
|
||||
@@ -375,13 +246,10 @@ async function main() {
|
||||
const { missing, extra } = diffKeys(en, data);
|
||||
reports.push({
|
||||
locale,
|
||||
missingChunks: data.missingChunks,
|
||||
totalKeys: Object.keys(data.flat).length,
|
||||
totalKeys: Object.keys(data).length,
|
||||
missingKeys: missing,
|
||||
extraKeys: extra,
|
||||
untranslatedKeys: findUntranslated(en, data),
|
||||
driftedKeys: findDrift(en, data),
|
||||
perChunk: perChunkMissing(en, data),
|
||||
});
|
||||
}
|
||||
|
||||
@@ -391,25 +259,21 @@ async function main() {
|
||||
const data = await loadLocale(r.locale);
|
||||
const untranslated = r.untranslatedKeys.map((k) => ({
|
||||
key: k,
|
||||
en: en.flat[k],
|
||||
current: data.flat[k],
|
||||
en: en[k],
|
||||
current: data[k],
|
||||
}));
|
||||
const missing = r.missingKeys.map((k) => ({ key: k, en: en.flat[k] }));
|
||||
const extra = r.extraKeys.map((k) => ({ key: k, current: data.flat[k] }));
|
||||
const drift = r.driftedKeys;
|
||||
const missing = r.missingKeys.map((k) => ({ key: k, en: en[k] }));
|
||||
const extra = r.extraKeys.map((k) => ({ key: k, current: data[k] }));
|
||||
const out = {
|
||||
locale: r.locale,
|
||||
generatedAt: new Date().toISOString(),
|
||||
counts: {
|
||||
total: r.totalKeys,
|
||||
missing: missing.length,
|
||||
extra: extra.length,
|
||||
drift: drift.length,
|
||||
untranslated: untranslated.length,
|
||||
},
|
||||
missing,
|
||||
extra,
|
||||
drift,
|
||||
untranslated,
|
||||
};
|
||||
const file = path.join(opts.outDir, `${r.locale}.json`);
|
||||
@@ -421,7 +285,7 @@ async function main() {
|
||||
let unused: string[] | null = null;
|
||||
if (opts.scanUnused) {
|
||||
const used = await collectUsedKeys();
|
||||
unused = Object.keys(en.flat)
|
||||
unused = Object.keys(en)
|
||||
.filter((k) => !used.has(k))
|
||||
.sort();
|
||||
}
|
||||
@@ -430,7 +294,7 @@ async function main() {
|
||||
console.log(
|
||||
JSON.stringify(
|
||||
{
|
||||
enKeyCount: Object.keys(en.flat).length,
|
||||
enKeyCount: Object.keys(en).length,
|
||||
locales: reports,
|
||||
unusedKeys: unused,
|
||||
},
|
||||
@@ -443,11 +307,7 @@ async function main() {
|
||||
}
|
||||
|
||||
const localeFailure = reports.some(
|
||||
(r) =>
|
||||
r.missingChunks.length ||
|
||||
r.missingKeys.length ||
|
||||
r.extraKeys.length ||
|
||||
r.driftedKeys.length,
|
||||
(r) => r.missingKeys.length || r.extraKeys.length,
|
||||
);
|
||||
const unusedFailure = opts.strictUnused && (unused?.length ?? 0) > 0;
|
||||
process.exit(localeFailure || unusedFailure ? 1 : 0);
|
||||
|
||||
Reference in New Issue
Block a user