refactor: unify i18n function calls and simplify scripts

- Replace dynamic t(lang.labelKey) with static t(lang.label) using helper functions
- Add getLanguageLabel/getLangLabel/getLocaleLabel helper functions for switch-based label lookup
- Simplify translation check scripts to only detect literal string calls
- Fix namespace lookup for dotted namespaces like 'memorize.review'
This commit is contained in:
2026-03-18 08:13:58 +08:00
parent 286add7fff
commit 1ef337801d
6 changed files with 239 additions and 342 deletions

View File

@@ -10,77 +10,44 @@ const SRC_DIR = "./src";
const MESSAGES_DIR = "./messages";
const ALL_LOCALES = ["en-US", "zh-CN", "ja-JP", "ko-KR", "de-DE", "fr-FR", "it-IT", "ug-CN"];
interface TranslationUsage {
file: string;
line: number;
namespace: string;
key: string;
isDynamic: boolean;
}
function parseStringLiteral(s: string): string | null {
function parseString(s: string): string | null {
s = s.trim();
if (s.startsWith('"') && s.endsWith('"')) return s.slice(1, -1);
if (s.startsWith("'") && s.endsWith("'")) return s.slice(1, -1);
if (s.startsWith("`") && s.endsWith("`")) {
return s.includes("${") || s.includes("+") ? null : s.slice(1, -1);
if ((s.startsWith('"') && s.endsWith('"')) || (s.startsWith("'") && s.endsWith("'"))) {
return s.slice(1, -1);
}
if (s.startsWith("`") && s.endsWith("`") && !s.includes("${")) {
return s.slice(1, -1);
}
return null;
}
function extractTranslationBindings(content: string): Map<string, string> {
function getBindings(content: string): Map<string, string> {
const bindings = new Map<string, string>();
const pattern = /(?:const|let|var)\s+(\w+)\s*=\s*(?:await\s+)?(?:useTranslations|getTranslations)\s*\(\s*([^)]*)\s*\)/g;
const patterns = [
/(?:const|let|var)\s+(\w+)\s*=\s*(?:await\s+)?(?:useTranslations|getTranslations)\s*\(\s*([^)]*)\s*\)/g,
/(?:const|let|var)\s*\{\s*(\w+)\s*\}\s*=\s*await\s+getTranslations\s*\(\s*([^)]*)\s*\)/g,
];
for (const pattern of patterns) {
let match;
while ((match = pattern.exec(content)) !== null) {
const varName = match[1];
const arg = match[2].trim();
if (arg === "") {
bindings.set(varName, "__ROOT__");
} else {
const ns = parseStringLiteral(arg);
if (ns !== null) bindings.set(varName, ns);
}
}
let match;
while ((match = pattern.exec(content)) !== null) {
const varName = match[1];
const arg = match[2].trim();
bindings.set(varName, arg ? parseString(arg) || "" : "__ROOT__");
}
return bindings;
}
function extractTranslationCalls(
content: string,
filePath: string,
bindings: Map<string, string>
): TranslationUsage[] {
const usages: TranslationUsage[] = [];
function getUsages(content: string, file: string): { file: string; line: number; ns: string; key: string }[] {
const usages: { file: string; line: number; ns: string; key: string }[] = [];
const bindings = getBindings(content);
const lines = content.split("\n");
for (let i = 0; i < lines.length; i++) {
const line = lines[i];
const lineNum = i + 1;
for (const [varName, namespace] of bindings) {
const callPattern = new RegExp(
`\\b${varName}\\s*\\(\\s*("[^"]*"|'[^']*'|\`[^\`]*\`)(?:\\s*,|\\s*\\))`,
"g"
);
for (const [varName, ns] of bindings) {
const pattern = new RegExp(`\\b${varName}\\s*\\(\\s*("[^"]*"|'[^']*'|\`[^\`]*\`)(?:\\s*,|\\s*\\))`, "g");
let match;
while ((match = callPattern.exec(line)) !== null) {
const arg = match[1];
const key = parseStringLiteral(arg);
if (key !== null) {
usages.push({ file: filePath, line: lineNum, namespace, key, isDynamic: false });
} else {
usages.push({ file: filePath, line: lineNum, namespace, key: arg, isDynamic: true });
}
while ((match = pattern.exec(line)) !== null) {
const key = parseString(match[1]);
if (key) usages.push({ file, line: i + 1, ns, key });
}
}
}
@@ -88,153 +55,88 @@ function extractTranslationCalls(
return usages;
}
function getAllFiles(dir: string, extensions: string[]): string[] {
function getFiles(dir: string): string[] {
const files: string[] = [];
if (!fs.existsSync(dir)) return files;
for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
const fullPath = path.join(dir, entry.name);
if (entry.isDirectory()) {
files.push(...getAllFiles(fullPath, extensions));
} else if (entry.isFile() && extensions.some(ext => entry.name.endsWith(ext))) {
files.push(fullPath);
}
const p = path.join(dir, entry.name);
if (entry.isDirectory()) files.push(...getFiles(p));
else if (entry.isFile() && /\.(tsx?|ts)$/.test(entry.name)) files.push(p);
}
return files;
}
function navigateToObject(obj: Record<string, unknown>, path: string): unknown {
if (typeof obj[path] !== "undefined") return obj[path];
function keyExists(key: string, ns: string, trans: Record<string, unknown>): boolean {
let obj: unknown;
let current: unknown = obj;
for (const part of path.split(".")) {
if (typeof current !== "object" || current === null) return undefined;
current = (current as Record<string, unknown>)[part];
}
return current;
}
function keyExists(key: string, namespace: string, translations: Record<string, unknown>): boolean {
let targetObj: unknown;
if (namespace === "__ROOT__") {
targetObj = translations;
if (ns === "__ROOT__") {
obj = trans;
} else {
targetObj = navigateToObject(translations, namespace);
obj = trans[ns];
if (typeof obj !== "object" || obj === null) {
obj = trans;
for (const part of ns.split(".")) {
if (typeof obj !== "object" || obj === null) return false;
obj = (obj as Record<string, unknown>)[part];
}
}
}
if (typeof targetObj !== "object" || targetObj === null) return false;
if (typeof obj !== "object" || obj === null) return false;
const target = targetObj as Record<string, unknown>;
if (typeof target[key] === "string") return true;
let current: unknown = target;
for (const part of key.split(".")) {
if (typeof current !== "object" || current === null) return false;
current = (current as Record<string, unknown>)[part];
if (typeof obj !== "object" || obj === null) return false;
obj = (obj as Record<string, unknown>)[part];
}
return typeof current === "string";
return typeof obj === "string";
}
function main() {
const targetLocale = process.argv[2];
const localesToCheck = targetLocale ? [targetLocale] : ALL_LOCALES;
const locales = process.argv[2] ? [process.argv[2]] : ALL_LOCALES;
console.log("Scanning source files...\n");
const files = getFiles(SRC_DIR);
const usages: { file: string; line: number; ns: string; key: string }[] = [];
const sourceFiles = getAllFiles(SRC_DIR, [".tsx", ".ts"]);
const allUsages: TranslationUsage[] = [];
for (const filePath of sourceFiles) {
const content = fs.readFileSync(filePath, "utf-8");
const bindings = extractTranslationBindings(content);
const usages = extractTranslationCalls(content, filePath, bindings);
allUsages.push(...usages);
for (const f of files) {
usages.push(...getUsages(fs.readFileSync(f, "utf-8"), f));
}
const uniqueUsages = new Map<string, TranslationUsage>();
for (const usage of allUsages) {
const key = `${usage.file}:${usage.line}:${usage.namespace}:${usage.key}`;
uniqueUsages.set(key, usage);
const unique = new Map<string, { file: string; line: number; ns: string; key: string }>();
for (const u of usages) {
unique.set(`${u.file}:${u.line}:${u.ns}:${u.key}`, u);
}
const dedupedUsages = Array.from(uniqueUsages.values());
console.log(`Scanned ${files.length} files, ${unique.size} usages\n`);
console.log(`Found ${sourceFiles.length} files, ${dedupedUsages.length} translation usages\n`);
for (const locale of localesToCheck) {
console.log(`\n${"=".repeat(60)}`);
console.log(`Locale: ${locale}`);
console.log("=".repeat(60));
for (const locale of locales) {
console.log(`\n${"=".repeat(50)}\nLocale: ${locale}\n${"=".repeat(50)}`);
const translationPath = path.join(MESSAGES_DIR, `${locale}.json`);
if (!fs.existsSync(translationPath)) {
console.log(`File not found: ${translationPath}`);
const filePath = path.join(MESSAGES_DIR, `${locale}.json`);
if (!fs.existsSync(filePath)) {
console.log(`File not found: ${filePath}`);
continue;
}
let translations: Record<string, unknown>;
try {
translations = JSON.parse(fs.readFileSync(translationPath, "utf-8"));
} catch (e) {
console.log(`Failed to parse: ${translationPath}`);
console.log(e);
continue;
}
const trans = JSON.parse(fs.readFileSync(filePath, "utf-8"));
const missing = Array.from(unique.values()).filter(u => !keyExists(u.key, u.ns, trans));
const missing: TranslationUsage[] = [];
const dynamic: TranslationUsage[] = [];
for (const usage of dedupedUsages) {
if (usage.isDynamic) {
dynamic.push(usage);
} else if (!keyExists(usage.key, usage.namespace, translations)) {
missing.push(usage);
}
}
if (missing.length === 0 && dynamic.length === 0) {
if (missing.length === 0) {
console.log("All translations exist!");
} else {
if (missing.length > 0) {
console.log(`\nMissing ${missing.length} translations:\n`);
const byFile = new Map<string, TranslationUsage[]>();
for (const usage of missing) {
if (!byFile.has(usage.file)) byFile.set(usage.file, []);
byFile.get(usage.file)!.push(usage);
}
for (const [file, usages] of byFile) {
console.log(file);
for (const usage of usages) {
const ns = usage.namespace === "__ROOT__" ? "(root)" : usage.namespace;
console.log(` L${usage.line} [${ns}] ${usage.key}`);
}
console.log();
}
console.log(`\nMissing ${missing.length} translations:\n`);
const byFile = new Map<string, typeof missing>();
for (const u of missing) {
if (!byFile.has(u.file)) byFile.set(u.file, []);
byFile.get(u.file)!.push(u);
}
if (dynamic.length > 0) {
console.log(`\n${dynamic.length} dynamic keys (manual review):\n`);
const byFile = new Map<string, TranslationUsage[]>();
for (const usage of dynamic) {
if (!byFile.has(usage.file)) byFile.set(usage.file, []);
byFile.get(usage.file)!.push(usage);
}
for (const [file, usages] of byFile) {
console.log(file);
for (const usage of usages) {
const ns = usage.namespace === "__ROOT__" ? "(root)" : usage.namespace;
console.log(` L${usage.line} [${ns}] ${usage.key}`);
}
for (const [file, list] of byFile) {
console.log(file);
for (const u of list) {
console.log(` L${u.line} [${u.ns === "__ROOT__" ? "root" : u.ns}] ${u.key}`);
}
console.log();
}
}
}