hana-cli
Version:
HANA Developer Command Line Interface
276 lines (230 loc) • 7.87 kB
JavaScript
/**
* Validates i18n translation files for completeness and consistency.
*
* This script checks that:
* 1. All translation features have complete language file sets (English + all configured locales)
* 2. All keys are present in all language files for a feature
* 3. Key naming is consistent with the descriptive pattern
* 4. No orphaned or incomplete language files exist
*
* Usage: node scripts/validate-i18n.js [--fix] [--quiet]
*
* Exit codes:
* 0 = All validations passed
* 1 = Validation failures found
* 2 = Invalid arguments or file system error
*/
import fs from 'fs'
import path from 'path'
import { fileURLToPath } from 'url'
const __dirname = path.dirname(fileURLToPath(import.meta.url))
const I18N_DIR = path.join(__dirname, '../_i18n')
const LANGUAGES = ['', '_de', '_es', '_fr', '_pt', '_ja', '_ko', '_zh', '_hi', '_pl']
const LANGUAGE_NAMES = { '': 'English', '_de': 'German', '_es': 'Spanish', '_fr': 'French', '_pt': 'Portuguese', '_ja': 'Japanese', '_ko': 'Korean', '_zh': 'Simplified Chinese', '_hi': 'Hindi', '_pl': 'Polish' }
// Parse arguments
const args = process.argv.slice(2)
const shouldFix = args.includes('--fix')
const isQuiet = args.includes('--quiet')
// State tracking
let errorCount = 0
let warningCount = 0
const errors = []
const warnings = []
/**
* Log an error and track it
*/
function logError(message) {
errorCount++
errors.push(message)
if (!isQuiet) console.error(`❌ ERROR: ${message}`)
}
/**
* Log a warning and track it
*/
function logWarning(message) {
warningCount++
warnings.push(message)
if (!isQuiet) console.warn(`⚠️ WARNING: ${message}`)
}
/**
* Log info (only if not quiet mode)
*/
function logInfo(message) {
if (!isQuiet) console.log(`ℹ️ ${message}`)
}
/**
* Log success (only if not quiet mode)
*/
function logSuccess(message) {
if (!isQuiet) console.log(`✅ ${message}`)
}
/**
* Parse a properties file and return key-value pairs and key order
*/
function parsePropertiesFile(filePath) {
try {
const content = fs.readFileSync(filePath, 'utf-8')
const keys = new Set()
const keyOrder = []
const pairs = {}
const lines = content.split('\n')
for (const line of lines) {
const trimmed = line.trim()
// Skip empty lines and comments
if (!trimmed || trimmed.startsWith('#')) continue
const eqIndex = trimmed.indexOf('=')
if (eqIndex === -1) continue
const key = trimmed.substring(0, eqIndex).trim()
const value = trimmed.substring(eqIndex + 1).trim()
if (key && !keys.has(key)) {
keys.add(key)
keyOrder.push(key)
pairs[key] = value
}
}
return { keys, keyOrder, pairs }
} catch (error) {
logError(`Failed to read file ${filePath}: ${error.message}`)
return { keys: new Set(), keyOrder: [], pairs: {} }
}
}
/**
* Validate key naming follows the descriptive pattern
*/
function isValidKeyName(key) {
// Pattern: camelCase starting with feature name
// Examples: backupCommand, importError, syncWarning, examplesSearch
// Must start with lowercase letter, followed by alphanumerics and camelCase
return /^[a-z][a-zA-Z0-9]*$/.test(key)
}
/**
* Get feature name from filename (e.g., "backup" from "backup_de.properties")
*/
function getFeatureName(filename) {
return filename.replace(/(_[a-z]{2})?\.properties$/, '')
}
/**
* Main validation function
*/
function validateI18n() {
if (!fs.existsSync(I18N_DIR)) {
logError(`i18n directory not found: ${I18N_DIR}`)
return false
}
// Get all property files
const files = fs.readdirSync(I18N_DIR).filter(f => f.endsWith('.properties'))
if (files.length === 0) {
logError('No .properties files found in _i18n directory')
return false
}
// Group files by feature name
const featureMap = new Map()
for (const file of files) {
const featureName = getFeatureName(file)
const langCode = file.substring(featureName.length).replace('.properties', '')
if (!featureMap.has(featureName)) {
featureMap.set(featureName, {})
}
featureMap.get(featureName)[langCode] = file
}
logInfo(`Found ${featureMap.size} features with translatable content`)
logInfo(`Processing: ${Array.from(featureMap.keys()).join(', ')}`)
console.log('')
// Validate each feature
for (const [featureName, filesMap] of featureMap) {
validateFeature(featureName, filesMap)
}
console.log('')
console.log('═══════════════════════════════════════')
console.log(`Validation completed:`)
console.log(` Errors: ${errorCount}`)
console.log(` Warnings: ${warningCount}`)
console.log('═══════════════════════════════════════')
if (errorCount > 0) {
console.log('\nErrors:')
errors.forEach(err => console.log(` • ${err}`))
}
if (warningCount > 0) {
console.log('\nWarnings:')
warnings.forEach(warn => console.log(` • ${warn}`))
}
return errorCount === 0
}
/**
* Validate a single feature's language files
*/
function validateFeature(featureName, filesMap) {
logInfo(`Validating feature: ${featureName}`)
// Check all required language files exist
const missingLangs = []
for (const langCode of LANGUAGES) {
if (!filesMap[langCode]) {
missingLangs.push(LANGUAGE_NAMES[langCode])
}
}
if (missingLangs.length > 0) {
logError(`Feature '${featureName}' missing language files: ${missingLangs.join(', ')}`)
return
}
// Parse all files
const fileParsed = {}
for (const langCode of LANGUAGES) {
const filePath = path.join(I18N_DIR, filesMap[langCode])
fileParsed[langCode] = parsePropertiesFile(filePath)
}
// Get keys from English base file
const baseKeys = fileParsed[''].keys
if (baseKeys.size === 0) {
logWarning(`Feature '${featureName}' has no keys in English file`)
return
}
logInfo(` Keys found in ${featureName}: ${baseKeys.size}`)
// Check key consistency across all languages
let allKeysMatch = true
const keyMismatches = {}
for (const langCode of LANGUAGES) {
if (langCode === '') continue // Skip English (it's the base)
const langKeys = fileParsed[langCode].keys
const langName = LANGUAGE_NAMES[langCode]
// Check for missing keys in language file
const missingInLang = [...baseKeys].filter(k => !langKeys.has(k))
if (missingInLang.length > 0) {
allKeysMatch = false
logError(`Feature '${featureName}' missing keys in ${langName}: ${missingInLang.join(', ')}`)
keyMismatches[langCode] = missingInLang
}
// Check for extra keys in language file
const extraInLang = [...langKeys].filter(k => !baseKeys.has(k))
if (extraInLang.length > 0) {
allKeysMatch = false
logError(`Feature '${featureName}' has extra keys in ${langName}: ${extraInLang.join(', ')}`)
}
}
// Validate key naming convention
for (const key of baseKeys) {
if (!isValidKeyName(key)) {
logWarning(`Feature '${featureName}' has non-standard key name: '${key}' (should be camelCase like 'featureCommand')`)
}
}
// Check for empty values
for (const langCode of LANGUAGES) {
const langName = LANGUAGE_NAMES[langCode]
const pairs = fileParsed[langCode].pairs
for (const [key, value] of Object.entries(pairs)) {
if (!value || value.trim() === '') {
logError(`Feature '${featureName}' has empty value for key '${key}' in ${langName}`)
}
}
}
if (allKeysMatch && missingLangs.length === 0) {
logSuccess(`Feature '${featureName}' is valid and complete`)
}
}
// Run validation
const isValid = validateI18n()
if (!isValid) {
process.exit(1)
}
logSuccess('All i18n validations passed!')
process.exit(0)