// ======================================== // Translation Validation Script // ======================================== // Checks that en/ and zh/ translation files have matching keys import { readFileSync, readdirSync } from 'fs'; import { join, dirname } from 'path'; import { fileURLToPath } from 'url'; interface TranslationEntry { key: string; path: string[]; } interface ValidationResult { isValid: boolean; errors: string[]; warnings: string[]; missingKeys: { en: string[]; zh: string[]; }; extraKeys: { en: string[]; zh: string[]; }; } // ES module equivalent of __dirname const __filename = fileURLToPath(import.meta.url); const __dirname = dirname(__filename); const LOCALES_DIR = join(__dirname, '../src/locales'); const SUPPORTED_LOCALES = ['en', 'zh'] as const; /** * Recursively get all translation keys and values from a nested object */ function flattenObject(obj: Record, prefix = ''): Map { const map = new Map(); for (const key in obj) { const fullKey = prefix ? `${prefix}.${key}` : key; const value = obj[key]; if (typeof value === 'object' && value !== null && !Array.isArray(value)) { const nestedMap = flattenObject(value as Record, fullKey); nestedMap.forEach((v, k) => map.set(k, v)); } else if (typeof value === 'string') { map.set(fullKey, value); } } return map; } /** * Load and parse a JSON file */ function loadJsonFile(filePath: string): Record { try { const content = readFileSync(filePath, 'utf-8'); return JSON.parse(content); } catch (error) { console.error(`Error loading ${filePath}:`, error); return {}; } } /** * Get all translation keys and values for a locale */ function getLocaleKeys(locale: string): Map { const localeDir = join(LOCALES_DIR, locale); const map = new Map(); try { const files = readdirSync(localeDir).filter((f) => f.endsWith('.json')); for (const file of files) { const filePath = join(localeDir, file); const content = loadJsonFile(filePath); const flatMap = flattenObject(content); flatMap.forEach((v, k) => map.set(k, v)); } } catch (error) { console.error(`Error reading locale directory for ${locale}:`, error); } return map; } /** * Check if a value is a non-translatable (numbers, symbols, placeholders only) */ function isNonTranslatable(value: string): boolean { // Check if it's just numbers, symbols, or contains only placeholders like {count}, {name}, etc. const nonTranslatablePattern = /^[0-9%\$#\-\+\=\[\]{}()\/\\.,:;!?<>|"'\s_@*~`^&]*$/; return nonTranslatablePattern.test(value) && !/[a-zA-Z\u4e00-\u9fa5]/.test(value); } /** * Compare translation keys and values between locales */ function compareTranslations(): ValidationResult { const result: ValidationResult = { isValid: true, errors: [], warnings: [], missingKeys: { en: [], zh: [] }, extraKeys: { en: [], zh: [] }, }; // Get keys and values for each locale const enMap = getLocaleKeys('en'); const zhMap = getLocaleKeys('zh'); // Get all unique keys const allKeys = new Set([...enMap.keys(), ...zhMap.keys()]); // Find keys missing in Chinese for (const key of enMap.keys()) { if (!zhMap.has(key)) { result.missingKeys.zh.push(key); result.isValid = false; } } // Find keys missing in English for (const key of zhMap.keys()) { if (!enMap.has(key)) { result.missingKeys.en.push(key); result.isValid = false; } } // Check for untranslated values (identical en and zh values) for (const key of allKeys) { const enValue = enMap.get(key); const zhValue = zhMap.get(key); if (enValue && zhValue && enValue === zhValue) { // Skip if the value is non-translatable (numbers, symbols, etc.) if (!isNonTranslatable(enValue)) { result.warnings.push( `Untranslated value in zh/ for key "${key}": en="${enValue}" == zh="${zhValue}"` ); } } } return result; } /** * Display validation results */ function displayResults(result: ValidationResult): void { console.log('\n=== Translation Validation Report ===\n'); if (result.isValid) { console.log('Status: PASSED'); console.log('All translation keys are synchronized between en/ and zh/ locales.\n'); } else { console.log('Status: FAILED'); console.log('Translation keys are not synchronized.\n'); } // Display missing keys if (result.missingKeys.zh.length > 0) { console.log(`Keys missing in zh/ (${result.missingKeys.zh.length}):`); result.missingKeys.zh.forEach((key) => console.log(` - ${key}`)); console.log(''); } if (result.missingKeys.en.length > 0) { console.log(`Keys missing in en/ (${result.missingKeys.en.length}):`); result.missingKeys.en.forEach((key) => console.log(` - ${key}`)); console.log(''); } // Display untranslated values warnings const untranslatedWarnings = result.warnings.filter(w => w.startsWith('Untranslated value')); if (untranslatedWarnings.length > 0) { console.log(`Untranslated values in zh/ (${untranslatedWarnings.length}):`); untranslatedWarnings.forEach((warning) => console.log(` ⚠️ ${warning}`)); console.log(''); } // Display other warnings const otherWarnings = result.warnings.filter(w => !w.startsWith('Untranslated value')); if (otherWarnings.length > 0) { console.log('Warnings:'); otherWarnings.forEach((warning) => console.log(` ⚠️ ${warning}`)); console.log(''); } // Display errors if (result.errors.length > 0) { console.log('Errors:'); result.errors.forEach((error) => console.log(` ❌ ${error}`)); console.log(''); } console.log('=====================================\n'); } /** * Main validation function */ function main(): void { console.log('Validating translations...\n'); // Check if locale directories exist for (const locale of SUPPORTED_LOCALES) { const localePath = join(LOCALES_DIR, locale); // Note: In a real script, you'd use fs.existsSync here // For now, we'll let the error be caught in getLocaleKeys } // Compare translations const result = compareTranslations(); // Display results displayResults(result); // Exit with appropriate code process.exit(result.isValid ? 0 : 1); } // Run the validation main();