mirror of https://github.com/langgenius/dify.git
407 lines
11 KiB
TypeScript
407 lines
11 KiB
TypeScript
/**
|
||
* This script compares i18n keys between current branch (flat JSON) and main branch (nested TS).
|
||
*
|
||
* It checks:
|
||
* 1. All namespaces from main branch have corresponding JSON files
|
||
* 2. No TS files exist in current branch (all should be converted to JSON)
|
||
* 3. All keys from main branch exist in current branch
|
||
* 4. Values for existing keys haven't changed
|
||
* 5. 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 = `./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', ''))
|
||
}
|
||
|
||
/**
|
||
* Get list of namespaces from main branch (ts files)
|
||
*/
|
||
function getMainBranchNamespaces(): string[] {
|
||
try {
|
||
const relativePath = `./i18n/${LOCALE}`
|
||
// eslint-disable-next-line sonarjs/os-command
|
||
const output = execSync(`git ls-tree --name-only main ${relativePath}/`, {
|
||
encoding: 'utf-8',
|
||
stdio: ['pipe', 'pipe', 'pipe'],
|
||
})
|
||
// eslint-disable-next-line sonarjs/os-command
|
||
return output
|
||
.trim()
|
||
.split('\n')
|
||
.filter(f => f.endsWith('.ts'))
|
||
.map(f => path.basename(f, '.ts'))
|
||
}
|
||
catch {
|
||
return []
|
||
}
|
||
}
|
||
|
||
type NamespaceCheckResult = {
|
||
mainNamespaces: string[]
|
||
currentJsonFiles: string[]
|
||
currentTsFiles: string[]
|
||
missingJsonFiles: string[]
|
||
unexpectedTsFiles: string[]
|
||
}
|
||
|
||
/**
|
||
* Check namespace file consistency between main and current branch
|
||
*/
|
||
function checkNamespaceFiles(): NamespaceCheckResult {
|
||
const mainNamespaces = getMainBranchNamespaces()
|
||
const currentFiles = fs.readdirSync(I18N_DIR)
|
||
|
||
const currentJsonFiles = currentFiles
|
||
.filter(f => f.endsWith('.json'))
|
||
.map(f => f.replace('.json', ''))
|
||
|
||
const currentTsFiles = currentFiles
|
||
.filter(f => f.endsWith('.ts'))
|
||
.map(f => f.replace('.ts', ''))
|
||
|
||
// Check which namespaces from main are missing json files
|
||
const missingJsonFiles = mainNamespaces.filter(ns => !currentJsonFiles.includes(ns))
|
||
|
||
// ts files should not exist in current branch
|
||
const unexpectedTsFiles = currentTsFiles
|
||
|
||
return {
|
||
mainNamespaces,
|
||
currentJsonFiles,
|
||
currentTsFiles,
|
||
missingJsonFiles,
|
||
unexpectedTsFiles,
|
||
}
|
||
}
|
||
|
||
/**
|
||
* 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<string, TranslationValue>
|
||
|
||
// 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')
|
||
|
||
// Check namespace file consistency first
|
||
console.log('📂 Checking namespace files...')
|
||
console.log('='.repeat(60))
|
||
const nsCheck = checkNamespaceFiles()
|
||
|
||
console.log(`Namespaces in main branch (ts files): ${nsCheck.mainNamespaces.length}`)
|
||
console.log(`JSON files in current branch: ${nsCheck.currentJsonFiles.length}`)
|
||
console.log(`TS files in current branch: ${nsCheck.currentTsFiles.length}`)
|
||
|
||
let hasNamespaceError = false
|
||
|
||
if (nsCheck.missingJsonFiles.length > 0) {
|
||
console.log('\n❌ Missing JSON files (namespace exists in main but no corresponding JSON):')
|
||
for (const ns of nsCheck.missingJsonFiles) {
|
||
console.log(` - ${ns}.json (was ${ns}.ts in main)`)
|
||
}
|
||
hasNamespaceError = true
|
||
}
|
||
else {
|
||
console.log('\n✅ All namespaces from main branch have corresponding JSON files')
|
||
}
|
||
|
||
if (nsCheck.unexpectedTsFiles.length > 0) {
|
||
console.log('\n❌ Unexpected TS files (should be deleted):')
|
||
for (const ns of nsCheck.unexpectedTsFiles) {
|
||
console.log(` - ${ns}.ts`)
|
||
}
|
||
hasNamespaceError = true
|
||
}
|
||
else {
|
||
console.log('✅ No TS files in current branch (all converted to JSON)')
|
||
}
|
||
|
||
console.log()
|
||
|
||
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('📊 Key Analysis 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,
|
||
},
|
||
namespaceCheck: {
|
||
mainNamespaces: nsCheck.mainNamespaces,
|
||
currentJsonFiles: nsCheck.currentJsonFiles,
|
||
missingJsonFiles: nsCheck.missingJsonFiles,
|
||
unexpectedTsFiles: nsCheck.unexpectedTsFiles,
|
||
},
|
||
details: allResults,
|
||
}, null, 2))
|
||
|
||
console.log(`\n📄 Detailed report written to: i18n-analysis-report.json`)
|
||
|
||
// Exit with error code if there are issues
|
||
if (hasNamespaceError) {
|
||
console.log('\n⚠️ Warning: Namespace file issues detected!')
|
||
process.exit(1)
|
||
}
|
||
|
||
if (totalMissing > 0) {
|
||
console.log('\n⚠️ Warning: Some keys are missing in the current branch!')
|
||
process.exit(1)
|
||
}
|
||
|
||
console.log('\n✅ All namespace files and keys from main branch exist in current branch.')
|
||
}
|
||
|
||
main()
|