From badbce34e10f4e36bc833529371a23738f361067 Mon Sep 17 00:00:00 2001 From: Gauvino Date: Mon, 1 Jun 2026 11:41:44 +0200 Subject: [PATCH] ci(i18n): add unused & missing translation-key check to Quality Gate Dependency-free Bun script (bun run i18n:check, added to the linting matrix): flags keys referenced in code but missing from en.json, and keys in en.json referenced nowhere. Handles dynamic t(\prefix.\\) and keys stored as string constants (exact delimited-literal match). Source-only: only en.json is checked/edited; Crowdin syncs the other locales. Planned-feature keys are allow-listed in scripts/i18n-keys.config.json. --- .github/workflows/linting.yml | 1 + package.json | 2 + scripts/check-i18n-keys.mjs | 261 ++++++++++++++++++++++++++++++++++ scripts/i18n-keys.config.json | 43 ++++++ 4 files changed, 307 insertions(+) create mode 100644 scripts/check-i18n-keys.mjs create mode 100644 scripts/i18n-keys.config.json diff --git a/.github/workflows/linting.yml b/.github/workflows/linting.yml index 50013ba2b..4e1718dc3 100644 --- a/.github/workflows/linting.yml +++ b/.github/workflows/linting.yml @@ -97,6 +97,7 @@ jobs: - "check" - "format" - "typecheck" + - "i18n:check" steps: - name: "๐Ÿ“ฅ Checkout PR code" diff --git a/package.json b/package.json index ccabd2f47..64b8724c5 100644 --- a/package.json +++ b/package.json @@ -22,6 +22,8 @@ "lint": "biome check --write --unsafe --max-diagnostics 1000", "format": "biome format --write .", "doctor": "expo-doctor", + "i18n:check": "bun scripts/check-i18n-keys.mjs", + "i18n:fix-unused": "bun scripts/check-i18n-keys.mjs --fix-unused", "test": "bun run typecheck && bun run lint && bun run format && bun run doctor", "postinstall": "patch-package" }, diff --git a/scripts/check-i18n-keys.mjs b/scripts/check-i18n-keys.mjs new file mode 100644 index 000000000..c41105a67 --- /dev/null +++ b/scripts/check-i18n-keys.mjs @@ -0,0 +1,261 @@ +#!/usr/bin/env bun +/** + * i18n key checker for Streamyfin. + * + * Detects: + * - MISSING keys: a static `t("a.b.c")` / `i18nKey="a.b.c"` referenced in the code + * that does not exist in the source locale (translations/en.json). These are bugs โ€” + * the app renders the raw key. Always fails CI. + * - UNUSED (dead) keys: a key in the source locale that is referenced nowhere in the + * code, neither statically nor via a detected dynamic prefix (`t(`a.b.${x}`)`). + * These are dead weight that also clutter every locale on Crowdin. + * + * Dynamic usage is handled conservatively: + * - `t(`prefix.${x}`)` -> every key starting with `prefix.` is considered used. + * - `t(`${x}`)` -> fully dynamic, reported for manual review, never used to + * whitelist keys (in Streamyfin these are user-defined section + * titles, not translation keys). + * - Edge cases the static scan cannot see can be allow-listed in the config file. + * + * Usage: + * bun scripts/check-i18n-keys.mjs # report + exit 1 on missing OR unused + * bun scripts/check-i18n-keys.mjs --unused=warn # exit 1 only on missing; unused = warning + * bun scripts/check-i18n-keys.mjs --unused=off # ignore unused entirely + * bun scripts/check-i18n-keys.mjs --json # machine-readable output + * bun scripts/check-i18n-keys.mjs --fix-unused # remove dead keys from en.json (Crowdin syncs the rest) + */ + +import { + existsSync, + readdirSync, + readFileSync, + statSync, + writeFileSync, +} from "node:fs"; +import { extname, join, relative } from "node:path"; + +const ROOT = process.cwd(); +const args = process.argv.slice(2); +const flag = (name, def) => { + const a = args.find((x) => x === `--${name}` || x.startsWith(`--${name}=`)); + if (!a) return def; + const [, v] = a.split("="); + return v === undefined ? true : v; +}; +const UNUSED_MODE = String(flag("unused", "error")); // error | warn | off +const JSON_OUT = !!flag("json", false); +const FIX_UNUSED = !!flag("fix-unused", false); + +// ---- config ---- +const CONFIG_PATH = join(ROOT, "scripts", "i18n-keys.config.json"); +const DEFAULT_CONFIG = { + localesDir: "translations", + sourceLocale: "en", + // Scan the whole repo by default so keys referenced outside the obvious dirs + // (e.g. packages/, key constants in utils/atoms) are not wrongly flagged as dead. + srcDirs: ["."], + srcExtensions: [".ts", ".tsx", ".js", ".jsx", ".mjs", ".cjs"], + excludeDirs: [ + "node_modules", + "ios", + "android", + ".expo", + ".git", + "dist", + "build", + "translations", + "scripts", + ], + // Keys (or glob-ish prefixes ending with .* or *) known to be used dynamically / externally. + ignoreUnused: [], +}; +const config = existsSync(CONFIG_PATH) + ? { ...DEFAULT_CONFIG, ...JSON.parse(readFileSync(CONFIG_PATH, "utf8")) } + : DEFAULT_CONFIG; + +// ---- helpers ---- +const flatten = (obj, prefix = "", out = {}) => { + for (const [k, v] of Object.entries(obj)) { + const key = prefix ? `${prefix}.${k}` : k; + if (v && typeof v === "object" && !Array.isArray(v)) flatten(v, key, out); + else out[key] = v; + } + return out; +}; + +const globMatch = (key, pattern) => { + if (pattern.endsWith(".*")) + return key === pattern.slice(0, -2) || key.startsWith(pattern.slice(0, -1)); + if (pattern.endsWith("*")) return key.startsWith(pattern.slice(0, -1)); + return key === pattern; +}; + +const walk = (dir, files = []) => { + let entries; + try { + entries = readdirSync(dir); + } catch { + return files; + } + for (const name of entries) { + const full = join(dir, name); + let st; + try { + st = statSync(full); + } catch { + continue; + } + if (st.isDirectory()) { + if (config.excludeDirs.includes(name)) continue; + walk(full, files); + } else if (config.srcExtensions.includes(extname(name))) { + files.push(full); + } + } + return files; +}; + +// ---- load source keys ---- +const sourcePath = join(ROOT, config.localesDir, `${config.sourceLocale}.json`); +const sourceKeys = Object.keys( + flatten(JSON.parse(readFileSync(sourcePath, "utf8"))), +); +const sourceKeySet = new Set(sourceKeys); + +// ---- scan code ---- +const STATIC_RE = /\bt\(\s*(['"])((?:\\.|(?!\1).)+?)\1/g; // t("a.b") / t('a.b') +const TPL_STATIC_RE = /\bt\(\s*`([^`$]+)`/g; // t(`a.b`) no interpolation +const TPL_DYN_RE = /\bt\(\s*`([^`$]*)\$\{/g; // t(`a.b.${x}`) -> prefix "a.b." +const I18NKEY_RE = /\bi18nKey\s*=\s*(?:\{\s*)?(['"])((?:\\.|(?!\1).)+?)\1/g; // +const KEY_SHAPE = /^[A-Za-z0-9_]+(\.[A-Za-z0-9_]+)+$/; // dotted key, e.g. home.x.y + +const usedStatic = new Set(); // keys passed to t(...) / i18nKey โ€” used for MISSING detection +const dynamicPrefixes = new Set(); +const fullyDynamic = []; // { file, line } +let codeBlob = ""; // all source text โ€” searched for delimited key literals + +const files = config.srcDirs.flatMap((d) => + walk(join(ROOT, d === "." ? "" : d) || ROOT), +); +for (const file of files) { + const text = readFileSync(file, "utf8"); + codeBlob += `\n${text}`; + for (const m of text.matchAll(STATIC_RE)) usedStatic.add(m[2]); + for (const m of text.matchAll(TPL_STATIC_RE)) usedStatic.add(m[1]); + for (const m of text.matchAll(I18NKEY_RE)) usedStatic.add(m[2]); + for (const m of text.matchAll(TPL_DYN_RE)) { + const prefix = m[1]; + if (prefix?.includes(".")) dynamicPrefixes.add(prefix); + else { + const idx = text.slice(0, m.index).split("\n").length; + fullyDynamic.push({ file: relative(ROOT, file), line: idx }); + } + } +} + +const prefixList = [...dynamicPrefixes]; +// A key counts as used if its EXACT delimited literal ("k" / 'k' / `k`) appears anywhere in +// the code (covers t("k"), , and keys stored as bare string constants in +// arrays/config then resolved via t(variable)), or it is reached via a dynamic prefix, or +// explicitly allow-listed. Delimited search avoids substring false-matches (e.g. a.b vs a.b_c). +const literalUsed = (key) => + codeBlob.includes(`"${key}"`) || + codeBlob.includes(`'${key}'`) || + codeBlob.includes(`\`${key}\``); +const isUsed = (key) => + literalUsed(key) || + prefixList.some((p) => key.startsWith(p)) || + config.ignoreUnused.some((g) => globMatch(key, g)); + +// ---- compute ---- +const unused = sourceKeys.filter((k) => !isUsed(k)).sort(); +const missing = [...usedStatic] + .filter( + (k) => + KEY_SHAPE.test(k) && + !sourceKeySet.has(k) && + !prefixList.some((p) => k.startsWith(p)), + ) + .sort(); + +// ---- optional fix: strip dead keys from all locale files ---- +const removeKey = (obj, parts) => { + const [head, ...rest] = parts; + if (!(head in obj)) return; + if (rest.length === 0) { + delete obj[head]; + return; + } + removeKey(obj[head], rest); + if ( + obj[head] && + typeof obj[head] === "object" && + Object.keys(obj[head]).length === 0 + ) + delete obj[head]; +}; +if (FIX_UNUSED && unused.length) { + // Only edit the SOURCE locale (en.json). Crowdin owns the target locales and removes + // the keys from them automatically on the next sync once they disappear from the source. + const data = JSON.parse(readFileSync(sourcePath, "utf8")); + for (const key of unused) removeKey(data, key.split(".")); + writeFileSync(sourcePath, `${JSON.stringify(data, null, 2)}\n`); + console.log( + `๐Ÿงน Removed ${unused.length} dead key(s) from ${config.sourceLocale}.json (Crowdin will sync the other locales).`, + ); +} + +// ---- report ---- +if (JSON_OUT) { + console.log( + JSON.stringify( + { + sourceKeys: sourceKeys.length, + missing, + unused, + dynamicPrefixes: prefixList, + fullyDynamic, + }, + null, + 2, + ), + ); +} else { + console.log( + `๐Ÿ”‘ i18n key check โ€” source: ${relative(ROOT, sourcePath)} (${sourceKeys.length} keys), scanned ${files.length} files`, + ); + if (prefixList.length) + console.log( + ` dynamic prefixes treated as used: ${prefixList.map((p) => `${p}*`).join(", ")}`, + ); + if (fullyDynamic.length) + console.log( + ` โš ๏ธ ${fullyDynamic.length} fully-dynamic t(\`\${โ€ฆ}\`) call(s) (not key-based, manual review): ${fullyDynamic.map((d) => `${d.file}:${d.line}`).join(", ")}`, + ); + + if (missing.length) { + console.log( + `\nโŒ MISSING keys (used in code, absent from ${config.sourceLocale}.json) โ€” ${missing.length}:`, + ); + for (const k of missing) console.log(` - ${k}`); + } else console.log("\nโœ… No missing keys."); + + if (UNUSED_MODE !== "off") { + if (unused.length) { + console.log( + `\n${UNUSED_MODE === "error" ? "โŒ" : "โš ๏ธ "} UNUSED keys (in ${config.sourceLocale}.json, referenced nowhere) โ€” ${unused.length}:`, + ); + for (const k of unused) console.log(` - ${k}`); + console.log( + `\n โ†’ remove with: bun scripts/check-i18n-keys.mjs --fix-unused`, + ); + console.log( + ` โ†’ or allow-list a dynamic key in scripts/i18n-keys.config.json ("ignoreUnused").`, + ); + } else console.log("\nโœ… No unused keys."); + } +} + +const fail = + missing.length > 0 || (UNUSED_MODE === "error" && unused.length > 0); +process.exit(fail ? 1 : 0); diff --git a/scripts/i18n-keys.config.json b/scripts/i18n-keys.config.json new file mode 100644 index 000000000..2170e27db --- /dev/null +++ b/scripts/i18n-keys.config.json @@ -0,0 +1,43 @@ +{ + "localesDir": "translations", + "sourceLocale": "en", + "srcDirs": [ + "app", + "components", + "hooks", + "providers", + "utils", + "modules", + "packages", + "constants" + ], + "srcExtensions": [".ts", ".tsx", ".js", ".jsx", ".mjs", ".cjs"], + "excludeDirs": [ + "node_modules", + "ios", + "android", + ".expo", + ".git", + "dist", + "build", + "translations" + ], + "_ignoreUnusedNote": "Keys for planned features that are intentionally kept in en.json but not yet wired in code. They are exempt from the unused-key check until implemented. Remove an entry once its feature ships and uses the key.", + "ignoreUnused": [ + "watchlists.add_to_watchlist", + "watchlists.remove_from_watchlist", + "watchlists.create_one_first", + "watchlists.no_compatible_watchlists", + "pin.confirm_pin", + "pin.pins_dont_match", + "player.search_subtitles", + "player.subtitle_search", + "player.subtitle_download_hint", + "player.subtitle_tracks", + "player.using_jellyfin_server", + "player.swipe_down_settings", + "player.stopPlayback", + "player.stopPlayingTitle", + "player.stopPlayingConfirm" + ] +}