Files
AstrBot/tests/checkTranslations.ts
T
LIghtJUNction 5ea790d045 chore: misc fixes and new test files
- fix(openai_source): remove dead code comment
- fix(common.ts): use resolveApiUrl helper for live-log endpoint
- style(mdi-subset): clean up icon CSS
- feat: add translation check script and unit tests
2026-03-29 15:40:58 +08:00

286 lines
8.1 KiB
TypeScript

/**
* Translation Key Consistency Checker
*
* This script checks that all translation keys are present in all locale files.
* Missing keys are reported with their file paths.
*
* Usage:
* npx tsx tests/checkTranslations.ts
* npx tsx tests/checkTranslations.ts --verbose
*/
import { readdir, readFile } from "fs/promises";
import { join, relative } from "path";
const LOCALES_DIR = join(__dirname, "../dashboard/src/i18n/locales");
const LOCALES = ["en-US", "zh-CN", "ru-RU"];
interface TranslationMap {
[key: string]: string | TranslationMap;
}
interface FileReport {
file: string;
missingKeys: string[];
}
interface LocaleReport {
locale: string;
baseMissingKeys: string[];
crossLocaleMissing: Map<string, string[]>; // key -> [files missing it]
}
/**
* Recursively extract all dot-notation keys from a translation object
*/
function extractKeys(obj: TranslationMap, prefix = ""): Set<string> {
const keys = new Set<string>();
for (const [key, value] of Object.entries(obj)) {
const fullKey = prefix ? `${prefix}.${key}` : key;
if (typeof value === "object" && value !== null && !Array.isArray(value)) {
// Recurse into nested objects
const nestedKeys = extractKeys(value as TranslationMap, fullKey);
nestedKeys.forEach((k) => keys.add(k));
} else {
keys.add(fullKey);
}
}
return keys;
}
/**
* Check for missing keys between two translation maps
*/
function findMissingKeys(source: Set<string>, target: Set<string>): string[] {
const missing: string[] = [];
for (const key of source) {
if (!target.has(key)) {
missing.push(key);
}
}
return missing.sort();
}
/**
* Load and parse a translation JSON file
*/
async function loadTranslationFile(filePath: string): Promise<TranslationMap | null> {
try {
const content = await readFile(filePath, "utf-8");
return JSON.parse(content);
} catch (error) {
console.error(`Error loading ${filePath}: ${error}`);
return null;
}
}
/**
* Get all JSON files in a directory recursively
*/
async function getJsonFiles(dir: string, baseDir = ""): Promise<string[]> {
const files: string[] = [];
const entries = await readdir(dir, { withFileTypes: true });
for (const entry of entries) {
const fullPath = join(dir, entry.name);
const relativePath = baseDir ? `${baseDir}/${entry.name}` : entry.name;
if (entry.isDirectory()) {
const nestedFiles = await getJsonFiles(fullPath, relativePath);
files.push(...nestedFiles);
} else if (entry.name.endsWith(".json")) {
files.push(fullPath);
}
}
return files;
}
/**
* Get relative path from locales directory
* Normalizes the path to compare across locales
*/
function getRelativePath(filePath: string): string {
// Remove locale prefix to get the relative path
// e.g., /path/to/locales/en-US/features/test.json -> features/test.json
const relativePath = relative(LOCALES_DIR, filePath);
const parts = relativePath.split("/");
// If the first part is a locale name (en-US, zh-CN, ru-RU), remove it
if (parts.length > 1 && LOCALES.includes(parts[0])) {
return parts.slice(1).join("/");
}
return relativePath;
}
/**
* Main check function
*/
async function checkTranslations(verbose = false): Promise<void> {
console.log("🔍 Checking translation key consistency...\n");
// Load base locale (English) first
const baseLocale = "en-US";
const baseDir = join(LOCALES_DIR, baseLocale);
const baseFiles = await getJsonFiles(baseDir);
console.log(`📁 Base locale: ${baseLocale} (${baseFiles.length} files)\n`);
// Build base translations map
const baseTranslations = new Map<string, Set<string>>(); // file -> keys
for (const file of baseFiles) {
const relativePath = getRelativePath(file);
const content = await loadTranslationFile(file);
if (content) {
const keys = extractKeys(content);
baseTranslations.set(relativePath, keys);
if (verbose) {
console.log(` ✅ ${relativePath}: ${keys.size} keys`);
}
}
}
// Check each other locale
for (const locale of LOCALES) {
if (locale === baseLocale) continue;
console.log(`\n🌐 Checking locale: ${locale}`);
const localeDir = join(LOCALES_DIR, locale);
const localeFiles = await getJsonFiles(localeDir);
const localeTranslations = new Map<string, Set<string>>();
const missingFiles: string[] = [];
const crossLocaleMissing = new Map<string, string[]>();
// Load locale translations
for (const file of localeFiles) {
const relativePath = getRelativePath(file);
const content = await loadTranslationFile(file);
if (content) {
const keys = extractKeys(content);
localeTranslations.set(relativePath, keys);
}
}
// Check for missing files in this locale
for (const [baseFile] of baseTranslations) {
if (!localeTranslations.has(baseFile)) {
missingFiles.push(baseFile);
if (!crossLocaleMissing.has(baseFile)) {
crossLocaleMissing.set(baseFile, []);
}
crossLocaleMissing.get(baseFile)!.push(`${locale} (missing file)`);
}
}
// Check for missing keys in each file
for (const [baseFile, baseKeys] of baseTranslations) {
const localeKeys = localeTranslations.get(baseFile);
if (!localeKeys) continue; // Already reported as missing file
const missing = findMissingKeys(baseKeys, localeKeys);
if (missing.length > 0) {
for (const key of missing) {
if (!crossLocaleMissing.has(key)) {
crossLocaleMissing.set(key, []);
}
crossLocaleMissing.get(key)!.push(`${locale}: ${baseFile}`);
}
}
}
// Report results
if (missingFiles.length > 0) {
console.log(`\n ⚠️ Missing files in ${locale}:`);
for (const file of missingFiles) {
console.log(` - ${file}`);
}
}
if (crossLocaleMissing.size > 0) {
console.log(`\n ⚠️ Missing keys in ${locale}:`);
let keyCount = 0;
for (const [key, locations] of crossLocaleMissing) {
if (locations.some((l) => l.includes("missing file"))) continue; // Skip file-level issues
keyCount++;
console.log(`\n Key: ${key}`);
for (const loc of locations) {
console.log(` → ${loc}`);
}
}
console.log(`\n 📊 Total missing keys in ${locale}: ${crossLocaleMissing.size}`);
} else {
console.log(` ✅ All keys present in ${locale}`);
}
}
// Summary
console.log("\n" + "=".repeat(60));
console.log("📋 Summary");
console.log("=".repeat(60));
console.log(`\nBase locale: ${baseLocale}`);
console.log(`Locales checked: ${LOCALES.filter((l) => l !== baseLocale).join(", ")}`);
console.log(`Files in base: ${baseFiles.length}`);
// Count total missing across all non-base locales
let totalMissingKeys = 0;
for (const locale of LOCALES) {
if (locale === baseLocale) continue;
const localeDir = join(LOCALES_DIR, locale);
const localeFiles = await getJsonFiles(localeDir);
const localeTranslations = new Map<string, Set<string>>();
for (const file of localeFiles) {
const relativePath = getRelativePath(file);
const content = await loadTranslationFile(file);
if (content) {
const keys = extractKeys(content);
localeTranslations.set(relativePath, keys);
}
}
for (const [baseFile, baseKeys] of baseTranslations) {
const localeKeys = localeTranslations.get(baseFile);
if (!localeKeys) continue;
const missing = findMissingKeys(baseKeys, localeKeys);
totalMissingKeys += missing.length;
}
}
console.log(`\nTotal missing translation keys: ${totalMissingKeys}`);
if (totalMissingKeys === 0) {
console.log("\n🎉 All translation keys are complete!");
} else {
console.log("\n⚠️ Please add the missing translation keys to complete the localization.");
}
console.log("\n💡 To run with verbose output: npx tsx tests/checkTranslations.ts --verbose");
}
// Parse command line arguments
const verbose = process.argv.includes("--verbose") || process.argv.includes("-v");
// Run the check
checkTranslations(verbose).catch(console.error);