UNPKG

i18ntk

Version:

i18n Tool Kit - Zero-dependency internationalization toolkit for setup, scanning, analysis, validation, auto translation, fixing, reporting, and runtime translation loading.

1,266 lines (1,100 loc) 44.6 kB
#!/usr/bin/env node /** * I18NTK TRANSLATION VALIDATION TOOLKIT * * This script validates translation files for completeness, consistency, * and structural integrity across all languages. * * Usage: * npm run i18ntk:validate * npm run i18ntk:validate -- --strict * npm run i18ntk:validate -- --language=de * npm run i18ntk:validate -- --source-dir=./src/i18n/locales * * Alternative direct usage: * node i18ntk-validate.js */ // Check for uppercase command usage and provide helpful error const commandLine = process.argv.join(' '); const isUppercase = /NPX I18NTK|NPM I18NTK/i.test(commandLine); if (isUppercase) { console.error('❌ Invalid command format detected!'); console.error(''); console.error('📝 Please use lowercase commands:'); console.error(' npx i18ntk [command] [options]'); console.error(' npm run i18ntk:[command]'); console.error(''); console.error('💡 Examples:'); console.error(' npx i18ntk validate'); console.error(' npx i18ntk init --languages=de,es'); console.error(' npm run i18ntk:manage'); console.error(''); console.error('📖 For more information, run: npx i18ntk --help'); process.exit(1); } const fs = require('fs'); const path = require('path'); const { loadTranslations, t } = require('../utils/i18n-helper'); const configManager = require('../utils/config-manager'); const SecurityUtils = require('../utils/security'); const AdminCLI = require('../utils/admin-cli'); const watchLocales = require('../utils/watch-locales'); const { getGlobalReadline, closeGlobalReadline } = require('../utils/cli'); const { getUnifiedConfig, parseCommonArgs, displayHelp, validateSourceDir, displayPaths } = require('../utils/config-helper'); const { isInteractive } = require('../utils/prompt-helper'); const I18nInitializer = require('./i18ntk-init'); const JsonOutput = require('../utils/json-output'); const ExitCodes = require('../utils/exit-codes'); const SetupEnforcer = require('../utils/setup-enforcer'); const { detectTranslationContentRisks } = require('../utils/validation-risk'); // Ensure setup is complete before running (async () => { try { await SetupEnforcer.checkSetupCompleteAsync(); } catch (error) { console.error('Setup check failed:', error.message); process.exit(1); } })(); loadTranslations('en', path.resolve(__dirname, '..', 'ui-locales')); class I18nValidator { constructor(config = {}) { this.config = config; this.errors = []; this.warnings = []; this.keyNamingViolations = []; this.rl = null; this.initialized = false; this.pathsDisplayed = false; this.validationBannerDisplayed = false; } async initialize() { try { if (this.initialized) { return; } // Initialize i18n with UI language first const args = this.parseArgs(); if (args.help) { displayHelp('i18ntk-validate', { 'setup-admin': 'Configure admin PIN protection', 'disable-admin': 'Disable admin PIN protection', 'admin-status': 'Check admin PIN status' }); process.exit(0); } const baseConfig = await getUnifiedConfig('validate', args); this.config = { ...baseConfig, ...(this.config || {}) }; const uiLanguage = (this.config && this.config.uiLanguage) || 'en'; loadTranslations(uiLanguage, path.resolve(__dirname, '..', 'ui-locales')); SecurityUtils.logSecurityEvent( 'I18n validator initializing', 'info', { message: 'Initializing I18n validator' } ); // Use the i18n directory for language files this.sourceDir = this.config.localesDir || this.config.i18nDir || this.config.sourceDir; this.i18nDir = this.config.localesDir || this.config.i18nDir || this.config.sourceDir; this.sourceLanguageDir = path.join(this.sourceDir, this.config.sourceLanguage); try { if (!SecurityUtils.safeExistsSync(this.sourceDir, process.cwd())) { const err = new Error(`Locale directory not found: ${this.sourceDir}`); err.exitCode = 2; throw err; } } catch (err) { if (!isInteractive(args)) { throw err; } console.log(t('init.requiredTitle')); console.log(t('init.requiredBody')); const answer = await this.prompt(t('init.promptRunNow')); if (answer.trim().toLowerCase() === 'y') { const initializer = new I18nInitializer(this.config); await initializer.run({ fromMenu: true }); } else { console.warn(t('config.dirFallbackWarning', { dir: this.sourceDir, fallback: this.sourceLanguageDir }) || `Warning: Directory ${this.sourceDir} not found. Using ${this.sourceLanguageDir}.`); if (!SecurityUtils.safeExistsSync(this.sourceLanguageDir, process.cwd())) { SecurityUtils.safeMkdirSync(this.sourceLanguageDir, process.cwd(), { recursive: true }); } } } displayPaths({ sourceDir: this.sourceDir, i18nDir: this.i18nDir, outputDir: this.config.outputDir }); this.pathsDisplayed = true; this.initialized = true; SecurityUtils.logSecurityEvent( 'I18n validator initialized successfully', 'info', { message: 'I18n validator initialized successfully' } ); } catch (error) { SecurityUtils.logSecurityEvent( 'I18n validator initialization error', 'error', { message: `Validator initialization error: ${error.message}` } ); throw error; } } initReadline() { return getGlobalReadline(); } prompt(question) { return new Promise(resolve => { const rl = getGlobalReadline(); rl.question(question, answer => { resolve(answer); }); }); } closeReadline() { closeGlobalReadline(); } // Parse command line arguments parseArgs() { try { const baseArgs = parseCommonArgs(process.argv.slice(2)); // Handle shorthand language flags const args = process.argv.slice(2); args.forEach(arg => { const sanitizedArg = SecurityUtils.sanitizeInput(arg); if (sanitizedArg.startsWith('--enforce-key-style')) { const val = arg.split('=')[1]; baseArgs.enforceKeyStyle = val === undefined ? true : val !== 'false'; } else if (sanitizedArg.startsWith('--') && !sanitizedArg.includes('=')) { const key = sanitizedArg.substring(2); if (['en', 'de', 'es', 'fr', 'it', 'pt', 'nl', 'pl', 'sv', 'uk', 'cs', 'tr', 'ru', 'ja', 'ko', 'zh', 'ar', 'hi', 'th', 'vi', 'he', 'el', 'hu'].includes(key)) { baseArgs.uiLanguage = key; } } }); return baseArgs; } catch (error) { throw error; } } // Add error addError(message, details = {}) { this.errors.push({ message, details, type: 'error' }); } // Add warning addWarning(message, details = {}) { this.warnings.push({ message, details, type: 'warning' }); } // Get all available languages isExcludedLanguageDirectory(name) { if (!name || typeof name !== 'string') return true; const lowered = name.toLowerCase(); return lowered.startsWith('backup-') || lowered === 'backup' || lowered === 'backups' || lowered === 'i18ntk-backups' || lowered === 'reports' || lowered === 'i18ntk-reports'; } getAvailableLanguages() { try { if (!SecurityUtils.safeExistsSync(this.sourceDir)) { throw new Error(`Source directory not found: ${this.sourceDir}`); } const items = SecurityUtils.safeReaddirSync(this.sourceDir, process.cwd(), { withFileTypes: true }); if (!items) { throw new Error(`Source directory not found: ${this.sourceDir}`); } const languages = items .filter(item => { return item.isDirectory() && item.name !== this.config.sourceLanguage && !this.isExcludedLanguageDirectory(item.name); }).map(item => item.name); return languages; } catch (error) { throw error; } } // Get all JSON files from a language directory getLanguageFiles(language) { try { const sanitizedLanguage = SecurityUtils.sanitizeInput(language); const languageDir = path.join(this.sourceDir, sanitizedLanguage); if (!SecurityUtils.safeExistsSync(languageDir)) { return []; } const items = SecurityUtils.safeReaddirSync(languageDir, process.cwd(), { withFileTypes: true }); if (!items) return []; const files = items .filter(item => { return item.isFile() && item.name.endsWith('.json') && (!Array.isArray(this.config.excludeFiles) || !this.config.excludeFiles.includes(item.name)); }).map(item => item.name); return files; } catch (error) { throw error; } } // Get all keys recursively from an object getAllKeys(obj, prefix = '', onlyLeaves = true) { const keys = new Set(); for (const [key, value] of Object.entries(obj)) { const fullKey = prefix ? `${prefix}.${key}` : key; if (value && typeof value === 'object' && !Array.isArray(value)) { const nestedKeys = this.getAllKeys(value, fullKey, onlyLeaves); nestedKeys.forEach(k => keys.add(k)); // Only add parent key if we want all keys (not leaves-only) if (!onlyLeaves) { keys.add(fullKey); } } else { keys.add(fullKey); } } return keys; } // Get value by key path getValueByPath(obj, keyPath) { // Ensure keyPath is a string const keyPathStr = String(keyPath || ''); const keys = keyPathStr.split('.'); let current = obj; for (const key of keys) { if (current && typeof current === 'object' && key in current) { current = current[key]; } else { return undefined; } } return current; } // Validate JSON file syntax async validateJsonSyntax(filePath) { try { const content = SecurityUtils.safeReadFileSync(filePath, path.dirname(filePath), 'utf8'); const parsed = SecurityUtils.safeParseJSON(content); SecurityUtils.logSecurityEvent( t('validate.jsonValidated'), 'info', { message: `JSON syntax validated: ${filePath}` } ); return { valid: true, data: parsed }; } catch (error) { SecurityUtils.logSecurityEvent( t('validate.jsonValidationError'), 'error', { message: `JSON validation error: ${error.message}` } ); return { valid: false, error: error.message, line: error.message.match(/line (\d+)/)?.[1] || 'unknown' }; } } // Validate structural consistency validateStructure(sourceObj, targetObj, language, fileName) { const sourceKeys = this.getAllKeys(sourceObj); const targetKeys = this.getAllKeys(targetObj); const missingKeys = [...sourceKeys].filter(key => !targetKeys.has(key)); const extraKeys = [...targetKeys].filter(key => !sourceKeys.has(key)); // Report missing keys as errors missingKeys.forEach(key => { this.addError( `Missing key in ${language}/${fileName}`, { key, language, fileName } ); }); // Report extra keys as warnings extraKeys.forEach(key => { this.addWarning( `Extra key in ${language}/${fileName}`, { key, language, fileName } ); }); return { isConsistent: missingKeys.length === 0 && extraKeys.length === 0, missingKeys, extraKeys, sourceKeyCount: sourceKeys.size, targetKeyCount: targetKeys.size }; } extractPlaceholders(value, patterns = []) { const placeholders = new Set(); if (value === null || value === undefined) return placeholders; const valueStr = String(value); patterns.forEach(p => { try { const reg = new RegExp(p, 'g'); let m; while ((m = reg.exec(valueStr)) !== null) { placeholders.add(m[0]); } } catch (e) { // skip invalid patterns } }); return placeholders; } getGenericPlaceholders(value) { if (value === null || value === undefined) return new Set(); const valueStr = String(value); return new Set(valueStr.match(/%s|\{\d+\}|\{\{[^}]+\}\}|\{[^}]+\}/g) || []); } checkPlaceholders(source, target, language, fileName, prefix = '') { if (typeof source === 'string' && typeof target === 'string') { const srcPatterns = (this.config.placeholderStyles && this.config.placeholderStyles[this.config.sourceLanguage]) || []; const tgtPatterns = (this.config.placeholderStyles && this.config.placeholderStyles[language]) || []; const srcPH = new Set([ ...this.extractPlaceholders(source, srcPatterns), ...this.getGenericPlaceholders(source) ]); const tgtPH = new Set([ ...this.extractPlaceholders(target, tgtPatterns), ...this.getGenericPlaceholders(target) ]); if (tgtPatterns.length) { const allowed = this.extractPlaceholders(target, tgtPatterns); this.getGenericPlaceholders(target).forEach(ph => { if (!allowed.has(ph)) { this.addError(`Disallowed placeholder style in ${language}/${fileName}`, { key: prefix, placeholder: ph }); } }); } if (srcPH.size !== tgtPH.size || [...srcPH].some(p => !tgtPH.has(p))) { this.addError(`Placeholder style mismatch in ${language}/${fileName}`, { key: prefix }); } } else if (source && typeof source === 'object' && !Array.isArray(source)) { for (const key of Object.keys(source)) { if (target && Object.prototype.hasOwnProperty.call(target, key)) { this.checkPlaceholders( source[key], target[key], language, fileName, prefix ? `${prefix}.${key}` : key ); } } } } detectRiskyKeys(obj, language, fileName, prefix = '') { for (const [key, value] of Object.entries(obj || {})) { const fullKey = prefix ? `${prefix}.${key}` : key; if (typeof value === 'string') { const issues = detectTranslationContentRisks(value, { keyPath: fullKey, sourceLanguage: this.config.sourceLanguage, targetLanguage: language, allowedEnglishTerms: this.config.allowedEnglishTerms, englishThresholdPercent: this.config.englishContentThresholdPercent }); issues.forEach(issue => { const reporter = this.config.strictMode ? this.addError.bind(this) : this.addWarning.bind(this); const message = issue.type === 'english_content' ? `Possible untranslated English content in ${language}/${fileName}` : `Potential risky content in ${language}/${fileName}`; reporter(message, { key: fullKey, value, ...issue }); }); } else if (value && typeof value === 'object' && !Array.isArray(value)) { this.detectRiskyKeys(value, language, fileName, fullKey); } } } // Validate translation completeness validateTranslation(obj, language, fileName, prefix = '') { let totalKeys = 0; let translatedKeys = 0; let issues = []; for (const [key, value] of Object.entries(obj)) { const fullKey = prefix ? `${prefix}.${key}` : key; if (value && typeof value === 'object' && !Array.isArray(value)) { const nested = this.validateTranslation(value, language, fileName, fullKey); totalKeys += nested.totalKeys; translatedKeys += nested.translatedKeys; issues.push(...nested.issues); } else if (typeof value === 'string') { totalKeys++; const markers = this.config.notTranslatedMarkers || [this.config.notTranslatedMarker]; if (markers.some(m => value === m)) { issues.push({ type: 'not_translated', key: fullKey, value, language, fileName }); } else if (value === '') { issues.push({ type: 'empty_value', key: fullKey, value, language, fileName }); } else if (markers.some(m => value.includes(m))) { issues.push({ type: 'partial_translation', key: fullKey, value, language, fileName }); } else { translatedKeys++; } } } return { totalKeys, translatedKeys, issues }; } // Validate a single language async validateLanguage(language) { try { SecurityUtils.logSecurityEvent( t('validate.languageValidation'), 'info', { message: `Validating language: ${language}` } ); const sanitizedLanguage = SecurityUtils.sanitizeInput(language); const languageDir = path.join(this.sourceDir, sanitizedLanguage); const sourceFiles = this.getLanguageFiles(this.config.sourceLanguage); const targetFiles = this.getLanguageFiles(sanitizedLanguage); const validation = { language: sanitizedLanguage, files: {}, summary: { totalFiles: sourceFiles.length, validFiles: 0, totalKeys: 0, sourceTotalKeys: 0, translatedKeys: 0, missingFiles: [], syntaxErrors: [], structuralIssues: [], translationIssues: [] } }; // Check for missing language directory if (!SecurityUtils.safeExistsSync(languageDir)) { this.addError( `Language directory missing: ${sanitizedLanguage}`, { language: sanitizedLanguage, expectedPath: languageDir } ); return validation; } // Validate each file for (const fileName of sourceFiles) { const sourceFilePath = path.join(this.sourceLanguageDir, fileName); const targetFilePath = path.join(languageDir, fileName); // Check if source file exists if (!SecurityUtils.safeExistsSync(sourceFilePath)) { this.addWarning( `Source file missing: ${this.config.sourceLanguage}/${fileName}`, { fileName, language: this.config.sourceLanguage } ); continue; } // Check if target file exists if (!SecurityUtils.safeExistsSync(targetFilePath)) { this.addError( `Translation file missing: ${language}/${fileName}`, { fileName, language, expectedPath: targetFilePath } ); validation.summary.missingFiles.push(fileName); continue; } // Validate JSON syntax for both files const sourceValidation = await this.validateJsonSyntax(sourceFilePath); const targetValidation = await this.validateJsonSyntax(targetFilePath); if (!sourceValidation.valid) { this.addError( `Invalid JSON syntax in source file: ${this.config.sourceLanguage}/${fileName}`, { fileName, language: this.config.sourceLanguage, error: sourceValidation.error } ); validation.summary.syntaxErrors.push({ fileName, type: 'source', error: sourceValidation.error }); continue; } if (!targetValidation.valid) { this.addError( `Invalid JSON syntax in target file: ${sanitizedLanguage}/${fileName}`, { fileName, language: sanitizedLanguage, error: targetValidation.error } ); validation.summary.syntaxErrors.push({ fileName, type: 'target', error: targetValidation.error }); continue; } // Use parsed data from validation const sourceContent = sourceValidation.data; const targetContent = targetValidation.data; // Validate structure const structural = this.validateStructure(sourceContent, targetContent, language, fileName); // Check key naming conventions if (this.config.enforceKeyStyle) { const keyNamingResult = this.validateKeyNaming(sourceContent); keyNamingResult.violations.forEach(v => { this.addWarning( `Key naming violation in ${language}/${fileName}`, { key: v.key, suggestedFix: v.suggestedFix, reason: v.reason, style: keyNamingResult.style } ); this.keyNamingViolations.push({ language, fileName, key: v.key, suggestedFix: v.suggestedFix, reason: v.reason, style: keyNamingResult.style }); }); } // Validate translations const translations = this.validateTranslation(targetContent, language, fileName); this.checkPlaceholders(sourceContent, targetContent, language, fileName); this.detectRiskyKeys(targetContent, language, fileName); // Store file validation results validation.files[fileName] = { status: 'validated', structural, translations, sourceFilePath, targetFilePath }; // Update summary validation.summary.validFiles++; validation.summary.totalKeys += translations.totalKeys; validation.summary.translatedKeys += translations.translatedKeys; // Count source locale leaf keys as reference total if (sourceContent) { validation.summary.sourceTotalKeys += this.getAllKeys(sourceContent).size; } if (!structural.isConsistent) { validation.summary.structuralIssues.push({ fileName, missingKeys: structural.missingKeys.length, extraKeys: structural.extraKeys.length }); } validation.summary.translationIssues.push(...translations.issues); } // Calculate completion percentage against source locale total const refTotal = validation.summary.sourceTotalKeys || validation.summary.totalKeys; validation.summary.percentage = refTotal > 0 ? Math.round((validation.summary.translatedKeys / refTotal) * 100) : 0; return validation; } catch (error) { SecurityUtils.logSecurityEvent(t('validate.languageValidationError'), 'error', { language: language, error: error.message, timestamp: new Date().toISOString() }); this.addError( `Language validation failed for ${language}: ${error.message}`, { language, error: error.message } ); return { language: language, files: {}, summary: { totalFiles: 0, validFiles: 0, totalKeys: 0, translatedKeys: 0, missingFiles: [], syntaxErrors: [], structuralIssues: [], translationIssues: [], percentage: 0 } }; } } // Check for unused translation keys (basic implementation) checkUnusedKeys(language) { // Note: For comprehensive unused key detection, use the dedicated // usage analysis script: i18ntk-usage.js const warnings = []; // This method provides basic validation only // For detailed usage analysis, run: node i18ntk-usage.js return warnings; } validateKeyNaming(sourceObj, style) { const keyStyle = style || this.config.keyStyle || 'dot.notation'; const allKeys = this.getAllKeys(sourceObj); const hybridDotSnake = /^[a-z0-9]+(?:_[a-z0-9]+)*(?:\.[a-z0-9]+(?:_[a-z0-9]+)*)*$/; const validators = { 'dot.notation': hybridDotSnake, 'snake_case': hybridDotSnake, 'camelCase': /^[a-z][a-zA-Z0-9]*$/, 'kebab-case': /^[a-z][a-z0-9]*(-[a-z][a-z0-9]*)*$/, 'flat': /^[a-zA-Z][a-zA-Z0-9]*$/ }; const regex = validators[keyStyle]; if (!regex) { return { violations: [], totalKeys: allKeys.size, violationCount: 0, style: keyStyle }; } const violations = []; for (const key of allKeys) { const sanitizedKey = SecurityUtils.sanitizeInput(key); const testKey = keyStyle === 'flat' ? sanitizedKey.split('.').pop() : sanitizedKey; if (!regex.test(testKey)) { violations.push({ key: sanitizedKey, suggestedFix: this.suggestKeyFix(sanitizedKey, keyStyle), reason: `Key "${sanitizedKey}" does not match "${keyStyle}" naming convention` }); } } return { violations, totalKeys: allKeys.size, violationCount: violations.length, style: keyStyle }; } suggestKeyFix(key, style) { const sanitizedKey = SecurityUtils.sanitizeInput(key); const segments = []; const rawTokens = sanitizedKey.split(/[._\-]/); for (const token of rawTokens) { if (!token) continue; const camelTokens = token.split(/(?=[A-Z])/).filter(Boolean); segments.push(...camelTokens); } if (segments.length === 0) { return sanitizedKey; } switch (style) { case 'dot.notation': return segments.map(s => s.toLowerCase()).join('.'); case 'snake_case': return segments.map(s => s.toLowerCase()).join('_'); case 'camelCase': return segments.map((s, i) => i === 0 ? s.toLowerCase() : s.charAt(0).toUpperCase() + s.slice(1).toLowerCase()).join(''); case 'kebab-case': return segments.map(s => s.toLowerCase()).join('-'); case 'flat': return segments.map(s => s.toLowerCase()).join(''); default: return sanitizedKey; } } // Show help message showHelp() { console.log(t('validate.help_message')); } saveValidationSummaryReport(results = {}, success = true) { try { const outputDir = path.resolve(this.config.outputDir || './i18ntk-reports'); SecurityUtils.safeMkdirSync(outputDir, process.cwd(), { recursive: true }); const timestamp = new Date().toISOString(); const safeTimestamp = timestamp.replace(/[:.]/g, '-'); const reportPath = path.join(outputDir, `validation-summary-${safeTimestamp}.txt`); const lines = []; lines.push('I18NTK Validation Summary'); lines.push('========================'); lines.push(`Generated: ${timestamp}`); lines.push(`Result: ${success ? 'PASS' : 'FAIL'}`); lines.push(`Errors: ${this.errors.length}`); lines.push(`Warnings: ${this.warnings.length}`); lines.push(''); lines.push('Language Results'); lines.push('----------------'); Object.entries(results).forEach(([language, validation]) => { const summary = validation?.summary || {}; lines.push(`${language}: ${summary.percentage || 0}% (${summary.translatedKeys || 0}/${summary.sourceTotalKeys || summary.totalKeys || 0} keys vs source)`); }); SecurityUtils.safeWriteFileSync(reportPath, lines.join('\n') + '\n', process.cwd(), 'utf8'); return reportPath; } catch (error) { return null; } } // Main validation process async validate() { try { const args = this.parseArgs(); const jsonOutput = new JsonOutput('validate'); if (!args.json) { console.log(''); console.log(t('validate.title')); if (!this.validationBannerDisplayed) { console.log(t('validate.message')); } // Delete old validation report if it exists const reportPath = path.join(process.cwd(), 'validation-report.txt'); const validatedPath = SecurityUtils.validatePath(reportPath, process.cwd()); if (validatedPath && SecurityUtils.safeExistsSync(validatedPath, process.cwd())) { SecurityUtils.safeUnlinkSync(validatedPath, process.cwd()); console.log(t('validate.deletedOldReport')); SecurityUtils.logSecurityEvent(t('validate.fileDeleted'), 'info', { path: reportPath, timestamp: new Date().toISOString() }); } } // Handle UI language change if (args.uiLanguage) { loadTranslations(args.uiLanguage, path.resolve(__dirname, '..', 'ui-locales'));} const localeDirArg = args.i18nDir || (!args.i18nDir && args.sourceDir ? args.sourceDir : null); if (localeDirArg) { this.config.i18nDir = localeDirArg; this.sourceDir = path.resolve(localeDirArg); this.i18nDir = this.sourceDir; this.sourceLanguageDir = path.join(this.sourceDir, this.config.sourceLanguage); } if (args.strictMode) { this.config.strictMode = true; } if (!args.json) { if (!this.pathsDisplayed) { console.log(t('validate.sourceDirectory', { dir: this.sourceDir })); } console.log(t('validate.sourceLanguage', { sourceLanguage: this.config.sourceLanguage })); console.log(t('validate.strictMode', { mode: this.config.strictMode ? 'ON' : 'OFF' })); console.log(''); } // Validate source language directory exists SecurityUtils.validatePath(this.sourceLanguageDir); if (!SecurityUtils.safeExistsSync(this.sourceLanguageDir)) { const error = t('validate.sourceLanguageDirectoryNotFound', { sourceDir: this.sourceLanguageDir }) || 'Source language directory not found'; this.addError(error, { sourceLanguage: this.config.sourceLanguage }); SecurityUtils.logSecurityEvent(t('validate.validationError'), 'error', { error: 'Source language directory not found', path: this.sourceLanguageDir, timestamp: new Date().toISOString() }); if (args.json) { jsonOutput.setStatus('error', error); console.log(JSON.stringify(jsonOutput.getOutput(), null, args.indent || 2)); return { success: false, error }; } throw new Error(error); } // Get available languages including source language const availableLanguages = this.getAvailableLanguages(); // Filter languages if specified const targetLanguages = args.language ? [args.language].filter(lang => availableLanguages.includes(lang)) : availableLanguages; if (args.language && targetLanguages.length === 0) { const error = `Specified language '${args.language}' not found`; this.addError(error, { requestedLanguage: args.language, availableLanguages }); if (args.json) { jsonOutput.setStatus('error', error); console.log(JSON.stringify(jsonOutput.getOutput(), null, args.indent || 2)); return { success: false, error }; } throw new Error(error); } if (!args.language && targetLanguages.length === 0) { const message = t('validate.noTargetLanguages') || 'No target languages configured; skipping target validation.'; if (args.json) { jsonOutput.setStatus('ok', message); console.log(JSON.stringify(jsonOutput.getOutput(), null, args.indent || 2)); return { success: true, message }; } console.log(message); return { success: true, message }; } if (!args.json) { console.log(t('validate.validatingLanguages', { langs: targetLanguages.join(', ') })); console.log(''); } const results = {}; let totalErrors = 0; let totalWarnings = 0; // Validate each language for (const language of targetLanguages) { if (!args.json) { console.log(t('validate.validatingLanguage', { lang: language })); } const validation = await this.validateLanguage(language); results[language] = validation; if (!args.json) { // Display brief progress indicator const { summary } = validation; const status = summary.syntaxErrors.length > 0 ? '❌' : summary.missingFiles.length > 0 ? '⚠️' : '✅'; console.log(` ${status} ${language}: ${summary.percentage}% (${summary.translatedKeys}/${summary.sourceTotalKeys || summary.totalKeys} keys vs source)`); } // Aggregate issues for JSON output totalErrors += validation.errors?.length || 0; totalWarnings += validation.warnings?.length || 0; } // Prepare JSON output if (args.json) { const hasErrors = this.errors.length > 0; const hasWarnings = this.warnings.length > 0; jsonOutput.setStatus( hasErrors ? 'error' : hasWarnings ? 'warn' : 'ok', hasErrors ? 'Validation failed' : hasWarnings ? 'Validation completed with warnings' : 'Validation passed' ); jsonOutput.addStats({ errors: this.errors.length, warnings: this.warnings.length, languages: targetLanguages.length, files: Object.values(results).reduce((sum, lang) => sum + (lang.files?.length || 0), 0) }); // Add issues from errors and warnings [...this.errors, ...this.warnings].forEach(issue => { jsonOutput.addIssue({ type: issue.message.includes('not found') ? 'missing' : issue.message.includes('syntax') ? 'syntax' : 'warning', message: issue.message, details: issue.details }); }); // Add per-language results jsonOutput.addData({ results }); console.log(JSON.stringify(jsonOutput.getOutput(), null, args.indent || 2)); return { success: !hasErrors, errors: this.errors.length, warnings: this.warnings.length, results }; } console.log(''); console.log(t('validate.separator')); // Overall summary const hasErrors = this.errors.length > 0; const hasWarnings = this.warnings.length > 0; console.log(t('validate.validationSummary')); console.log(t('validate.totalErrors', { count: this.errors.length })); console.log(t('validate.totalWarnings', { count: this.warnings.length })); // Show errors if (hasErrors) { console.log(''); console.log(t('validate.separator')); console.log(t('validate.errorsSection')); console.log(''); this.errors.forEach((error, index) => { console.log(` ❌ ${error.message}`); if (error.details && Object.keys(error.details).length > 0) { console.log(` 📄 Details: ${JSON.stringify(error.details, null, 2)}`); } console.log(''); }); } // Show warnings if (hasWarnings) { console.log(''); console.log(t('validate.separator')); console.log(t('validate.warningsSection')); console.log(''); this.warnings.forEach((warning, index) => { console.log(` ⚠️ ${warning.message}`); if (warning.details && Object.keys(warning.details).length > 0) { console.log(` 📄 Details: ${JSON.stringify(warning.details, null, 2)}`); } console.log(''); }); } // Key naming violations summary if (this.keyNamingViolations.length > 0) { console.log(''); console.log(t('validate.separator')); console.log('🔑 Key Naming Convention Violations'); console.log(''); const displayStyle = this.config.keyStyle || 'dot.notation'; console.log(` Expected style: ${displayStyle}`); console.log(` Violations found: ${this.keyNamingViolations.length}`); console.log(''); console.log(' Suggested fixes:'); this.keyNamingViolations.slice(0, 10).forEach((v, i) => { console.log(` ${i + 1}. "${v.key}""${v.suggestedFix}" (${v.language}/${v.fileName})`); }); if (this.keyNamingViolations.length > 10) { console.log(` ... and ${this.keyNamingViolations.length - 10} more`); } console.log(''); console.log(' 💡 Tip: Use i18ntk:fix-keys to auto-fix or manually rename keys.'); } // Recommendations console.log(''); console.log(t('validate.separator')); console.log(t('validate.recommendationsSection')); if (hasErrors) { console.log(''); console.log(t('validate.resolveMissingFilesAndSyntaxErrors')); console.log(t('validate.fixStructuralInconsistencies')); console.log(t('validate.completeMissingTranslations')); console.log(t('validate.rerunValidation')); } else if (hasWarnings) { console.log(''); console.log(t('validate.addressWarnings')); console.log(t('validate.reviewWarnings')); console.log(t('validate.considerRunningWithStrict')); } else { console.log(''); console.log(t('validate.allValidationsPassed')); console.log(t('validate.considerRunningUsageAnalysis')); } // Exit with appropriate code const success = !hasErrors && (!hasWarnings || !this.config.strictMode); const summaryReportPath = this.saveValidationSummaryReport(results, success); if (summaryReportPath) { console.log(''); console.log(`📄 Validation summary report saved: ${summaryReportPath}`); } return { success, errors: this.errors.length, warnings: this.warnings.length, results }; } catch (error) { console.error(t("validate.validation_failed", { error: error.message })); return { success: false, error: error.message }; } } /** * Run method for compatibility with manager */ async run(options = {}) { const { fromMenu = false } = options; const args = this.parseArgs(); try { // Ensure config is always initialized if (!this.config) { this.config = {}; } if (!this.initialized) { await this.initialize(); } this.config.enforceKeyStyle = args.enforceKeyStyle !== undefined ? args.enforceKeyStyle : this.config.enforceKeyStyle; // Skip admin authentication when called from menu if (!fromMenu) { // Check admin authentication for sensitive operations (only when called directly and not in no-prompt mode) const AdminAuth = require('../utils/admin-auth'); const adminAuth = new AdminAuth(); await adminAuth.initialize(); const isCalledDirectly = require.main === module; const isRequired = await adminAuth.isAuthRequired(); if (isRequired && isCalledDirectly && !args.noPrompt) { console.log('\n' + t('adminCli.authRequiredForOperation', { operation: 'validate translations' })); const cliHelper = require('../utils/cli-helper'); const pin = await cliHelper.promptPin(t('adminCli.enterPin')); const isValid = await adminAuth.verifyPin(pin); this.closeReadline(); if (!isValid) { console.log(t('adminCli.invalidPin')); if (!fromMenu) process.exit(ExitCodes.SECURITY_VIOLATION); return { success: false, error: 'Authentication failed' }; } console.log(t('adminCli.authenticationSuccess')); } } const execute = async () => { console.log('\n' + t('validate.startingValidationProcess')); this.validationBannerDisplayed = true; SecurityUtils.logSecurityEvent( t('validate.runStarted'), 'info', { message: 'Starting validation run' } ); const result = await this.validate(); if (result.success) { console.log(t('validate.validationProcessCompletedSuccessfully')); } else { console.log('❌ Validation failed.'); } SecurityUtils.logSecurityEvent( result.success ? t('validate.runCompleted') : t('validate.runError'), result.success ? 'info' : 'error', { message: result.success ? 'Validation run completed successfully' : 'Validation run failed' } ); return result; }; if (args.watch) { await execute(); let running = false; watchLocales(this.sourceDir, async () => { if (running) return; running = true; try { await execute(); } finally { running = false; } }); console.log('👀 Watching for translation changes. Press Ctrl+C to exit.'); return { watching: true }; } return await execute(); } catch (error) { console.error(t('validate.validationError', { error: error.message })); console.error(t('validate.stackTrace', { stack: error.stack })); SecurityUtils.logSecurityEvent( t('validate.runError'), 'error', { message: `Validation run failed: ${error.message}` } ); throw error; } } } module.exports = I18nValidator; if (require.main === module) { (async () => { try { // Initialize translations for CLI usage const config = configManager.getConfig(); const uiLanguage = config.language || 'en'; loadTranslations(uiLanguage, path.resolve(__dirname, '..', 'ui-locales')); SecurityUtils.logSecurityEvent(t('validate.scriptExecution'), 'info', { script: 'i18ntk-validate.js', timestamp: new Date().toISOString() }); const validator = new I18nValidator(); await validator.initialize(); const args = validator.parseArgs(); if (args.help) { validator.showHelp(); process.exit(0); } if (args.setupAdmin) { await AdminCLI.setupAdmin(); process.exit(0); } if (args.disableAdmin) { await AdminCLI.disableAdmin(); process.exit(0); } if (args.adminStatus) { await AdminCLI.showStatus(); process.exit(0); } if (AdminCLI.requiresAuth('validate')) { const authenticated = await AdminCLI.authenticate(); if (!authenticated) { console.log(t('validate.authenticationFailed')); const { closeGlobalReadline } = require('../utils/cli'); closeGlobalReadline(); process.exit(ExitCodes.SECURITY_VIOLATION); } } const result = await validator.run(); const runArgs = validator.parseArgs(); if (!runArgs.watch) { SecurityUtils.logSecurityEvent(t('validate.validationCompleted'), 'info', { success: result.success, errors: result.errors || 0, warnings: result.warnings || 0, timestamp: new Date().toISOString() }); process.exit(result.success ? ExitCodes.SUCCESS : ExitCodes.VALIDATION_FAILED); } } catch (error) { console.error(t('validate.fatalValidationError', { error: error.message })); console.error(t('validate.stackTrace', { stack: error.stack })); SecurityUtils.logSecurityEvent(t('validate.validationError'), 'error', { error: error.message, stack: error.stack, timestamp: new Date().toISOString() }); process.exit(error.exitCode || ExitCodes.CONFIG_ERROR); } })(); }