From 6c781571c8f2de2ef1337a568aa2ad8bed320e88 Mon Sep 17 00:00:00 2001 From: Stephen Zhou <38493346+hyoban@users.noreply.github.com> Date: Mon, 29 Dec 2025 12:42:54 +0800 Subject: [PATCH] add script to for translations --- web/scripts/analyze-i18n-diff.ts | 297 +++++++++++++++++++++++++++++++ 1 file changed, 297 insertions(+) create mode 100644 web/scripts/analyze-i18n-diff.ts diff --git a/web/scripts/analyze-i18n-diff.ts b/web/scripts/analyze-i18n-diff.ts new file mode 100644 index 0000000000..6f8749f310 --- /dev/null +++ b/web/scripts/analyze-i18n-diff.ts @@ -0,0 +1,297 @@ +/** + * This script compares i18n keys between current branch (flat JSON) and main branch (nested TS). + * + * It checks: + * 1. All keys from main branch exist in current branch + * 2. Values for existing keys haven't changed + * 3. Lists newly added keys and values + * + * Usage: npx tsx scripts/analyze-i18n-diff.ts + */ + +import { execSync } from 'node:child_process' +import * as fs from 'node:fs' +import * as path from 'node:path' +import { fileURLToPath } from 'node:url' + +const __filename = fileURLToPath(import.meta.url) +const __dirname = path.dirname(__filename) + +const I18N_DIR = path.join(__dirname, '../i18n/en-US') +const LOCALE = 'en-US' + +type TranslationValue = string | string[] + +type FlatTranslation = { + [key: string]: TranslationValue +} + +type NestedTranslation = { + [key: string]: string | string[] | NestedTranslation +} + +type AnalysisResult = { + file: string + missingKeys: string[] + changedValues: { key: string, oldValue: TranslationValue, newValue: TranslationValue }[] + newKeys: { key: string, value: TranslationValue }[] +} + +/** + * Flatten nested object to dot-separated keys + * Arrays are preserved as-is (not split into .0, .1, etc.) + */ +function flattenObject(obj: NestedTranslation, prefix = ''): FlatTranslation { + const result: FlatTranslation = {} + + for (const [key, value] of Object.entries(obj)) { + const newKey = prefix ? `${prefix}.${key}` : key + + if (typeof value === 'string') { + result[newKey] = value + } + else if (Array.isArray(value)) { + // Preserve arrays as-is + result[newKey] = value as string[] + } + else if (typeof value === 'object' && value !== null) { + Object.assign(result, flattenObject(value as NestedTranslation, newKey)) + } + } + + return result +} + +/** + * Compare two translation values (string or array) + */ +function valuesEqual(a: TranslationValue, b: TranslationValue): boolean { + if (typeof a === 'string' && typeof b === 'string') { + return a === b + } + if (Array.isArray(a) && Array.isArray(b)) { + if (a.length !== b.length) + return false + return a.every((item, index) => item === b[index]) + } + return false +} + +/** + * Format value for display + */ +function formatValue(value: TranslationValue): string { + if (Array.isArray(value)) { + return `[${value.map(v => `"${v}"`).join(', ')}]` + } + return `"${value}"` +} + +/** + * Parse TS file content to extract the translation object + */ +function parseTsContent(content: string): NestedTranslation { + // Remove 'const translation = ' and 'export default translation' + let cleaned = content + .replace(/const\s+translation\s*=\s*/, '') + .replace(/export\s+default\s+translation\s*(?:;\s*)?$/, '') + .trim() + + // Remove trailing semicolon if present + if (cleaned.endsWith(';')) + cleaned = cleaned.slice(0, -1) + + // Use Function constructor to safely evaluate the object literal + // This handles JS object syntax like unquoted keys, template literals, etc. + try { + // eslint-disable-next-line no-new-func, sonarjs/code-eval + const fn = new Function(`return (${cleaned})`) + return fn() as NestedTranslation + } + catch (e) { + console.error('Failed to parse TS content:', e) + console.error('Content preview:', cleaned.slice(0, 200)) + return {} + } +} + +/** + * Get file content from main branch + */ +function getMainBranchFile(filePath: string): string | null { + try { + const relativePath = `web/i18n/${LOCALE}/${filePath}` + // eslint-disable-next-line sonarjs/os-command + return execSync(`git show main:${relativePath}`, { + encoding: 'utf-8', + stdio: ['pipe', 'pipe', 'pipe'], + }) + } + catch { + return null + } +} + +/** + * Get list of translation files + */ +function getTranslationFiles(): string[] { + const files = fs.readdirSync(I18N_DIR) + return files.filter(f => f.endsWith('.json')).map(f => f.replace('.json', '')) +} + +/** + * Analyze a single translation file + */ +function analyzeFile(baseName: string): AnalysisResult { + const result: AnalysisResult = { + file: baseName, + missingKeys: [], + changedValues: [], + newKeys: [], + } + + // Read current branch JSON file + const jsonPath = path.join(I18N_DIR, `${baseName}.json`) + const currentContent = JSON.parse(fs.readFileSync(jsonPath, 'utf-8')) as Record + + // Read main branch TS file + const tsContent = getMainBranchFile(`${baseName}.ts`) + if (!tsContent) { + // New file, all keys are new + for (const [key, value] of Object.entries(currentContent)) { + result.newKeys.push({ key, value }) + } + return result + } + + // Parse and flatten the TS content + const nestedObj = parseTsContent(tsContent) + const mainFlat = flattenObject(nestedObj) + + // Check for missing keys (in main but not in current) + for (const key of Object.keys(mainFlat)) { + if (!(key in currentContent)) { + result.missingKeys.push(key) + } + } + + // Check for changed values + for (const [key, oldValue] of Object.entries(mainFlat)) { + if (key in currentContent && !valuesEqual(currentContent[key], oldValue)) { + result.changedValues.push({ + key, + oldValue, + newValue: currentContent[key], + }) + } + } + + // Find new keys (in current but not in main) + for (const [key, value] of Object.entries(currentContent)) { + if (!(key in mainFlat)) { + result.newKeys.push({ key, value }) + } + } + + return result +} + +/** + * Main analysis function + */ +function main() { + console.log('šŸ” Analyzing i18n differences between current branch (flat JSON) and main branch (nested TS)...\n') + + const files = getTranslationFiles() + const allResults: AnalysisResult[] = [] + + let totalMissing = 0 + let totalChanged = 0 + let totalNew = 0 + + for (const file of files) { + const result = analyzeFile(file) + allResults.push(result) + + totalMissing += result.missingKeys.length + totalChanged += result.changedValues.length + totalNew += result.newKeys.length + } + + // Summary + console.log('šŸ“Š Summary') + console.log('='.repeat(60)) + console.log(`Total files analyzed: ${files.length}`) + console.log(`Missing keys (in main but not in current): ${totalMissing}`) + console.log(`Changed values: ${totalChanged}`) + console.log(`New keys: ${totalNew}`) + console.log() + + // Detailed report + if (totalMissing > 0) { + console.log('\nāŒ MISSING KEYS (exist in main but not in current branch)') + console.log('='.repeat(60)) + for (const result of allResults) { + if (result.missingKeys.length > 0) { + console.log(`\nšŸ“ ${result.file}:`) + for (const key of result.missingKeys) { + console.log(` - ${key}`) + } + } + } + } + + if (totalChanged > 0) { + console.log('\nāš ļø CHANGED VALUES (same key, different value)') + console.log('='.repeat(60)) + for (const result of allResults) { + if (result.changedValues.length > 0) { + console.log(`\nšŸ“ ${result.file}:`) + for (const { key, oldValue, newValue } of result.changedValues) { + console.log(` Key: ${key}`) + console.log(` Old: ${formatValue(oldValue)}`) + console.log(` New: ${formatValue(newValue)}`) + console.log() + } + } + } + } + + if (totalNew > 0) { + console.log('\n✨ NEW KEYS (exist in current branch but not in main)') + console.log('='.repeat(60)) + for (const result of allResults) { + if (result.newKeys.length > 0) { + console.log(`\nšŸ“ ${result.file}:`) + for (const { key, value } of result.newKeys) { + console.log(` + ${key}: ${formatValue(value)}`) + } + } + } + } + + // Write detailed report to JSON file + const reportPath = path.join(__dirname, '../i18n-analysis-report.json') + fs.writeFileSync(reportPath, JSON.stringify({ + summary: { + totalFiles: files.length, + missingKeys: totalMissing, + changedValues: totalChanged, + newKeys: totalNew, + }, + details: allResults, + }, null, 2)) + + console.log(`\nšŸ“„ Detailed report written to: i18n-analysis-report.json`) + + // Exit with error code if there are missing keys + if (totalMissing > 0) { + console.log('\nāš ļø Warning: Some keys are missing in the current branch!') + process.exit(1) + } + + console.log('\nāœ… All keys from main branch exist in current branch.') +} + +main()