i18ntk
Version:
i18ntk (i18n Toolkit) - A comprehensive, enterprise-grade internationalization (i18n) management toolkit for JavaScript/TypeScript projects with advanced analysis, validation, and automation features
844 lines (709 loc) • 28.8 kB
JavaScript
/**
* I18N 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
*/
const fs = require('fs');
const path = require('path');
const { loadTranslations, t } = require('../utils/i18n-helper');
const settingsManager = require('../settings/settings-manager');
const SecurityUtils = require('../utils/security');
const AdminCLI = require('../utils/admin-cli');
// Get configuration from settings manager
async function getConfig(t) {
try {
SecurityUtils.logSecurityEvent(t('validate.configAccess'), 'info', 'Accessing configuration for validation');
const settings = settingsManager.getSettings();
// Check for per-script directory override, fallback to global sourceDir
const sourceDir = settings.scriptDirectories?.validate || settings.sourceDir || './locales';
const config = {
sourceDir: sourceDir,
sourceLanguage: settings.sourceLanguage || 'en',
notTranslatedMarker: settings.notTranslatedMarker || settings.processing?.notTranslatedMarker || 'NOT_TRANSLATED',
excludeFiles: settings.excludeFiles || settings.processing?.excludeFiles || ['.DS_Store', 'Thumbs.db'],
strictMode: settings.strictMode || settings.processing?.strictMode || false,
uiLanguage: settings.language || 'en'
};
// Validate configuration
SecurityUtils.validateConfig(config);
SecurityUtils.logSecurityEvent(t('validate.configValidated'), 'info', 'Configuration validated successfully');
return config;
} catch (error) {
SecurityUtils.logSecurityEvent(t('validate.configError'), 'error', `Configuration error: ${error.message}`);
throw error;
}
}
class I18nValidator {
constructor(config = {}) {
this.config = config;
this.errors = [];
this.warnings = [];
this.t = null;
this.rl = null;
}
async initialize() {
try {
// Initialize i18n with UI language first
const tempConfig = await getConfig(t);
const uiLanguage = SecurityUtils.sanitizeInput(tempConfig.uiLanguage || 'en');
loadTranslations(uiLanguage);
this.t = t; // Assign the translation function
SecurityUtils.logSecurityEvent(this.t('validate.validatorInit'), 'info', 'Initializing I18n validator');
const defaultConfig = await getConfig(this.t);
this.config = { ...defaultConfig, ...this.config };
// Validate configuration values
if (!this.config.sourceDir) {
throw new Error(this.t('validate.sourceDirNotConfigured') || 'Source directory not configured');
}
if (!this.config.sourceLanguage) {
throw new Error(this.t('validate.sourceLanguageNotConfigured') || 'Source language not configured');
}
// Validate and resolve paths
const resolvedSourceDir = path.resolve(this.config.sourceDir);
this.sourceDir = resolvedSourceDir;
this.sourceLanguageDir = path.join(this.sourceDir, this.config.sourceLanguage);
// Verify translation function is working
if (typeof this.t !== 'function') {
throw new Error(this.t('validate.translationFunctionNotInitialized') || 'Translation function not properly initialized');
}
SecurityUtils.logSecurityEvent(this.t('validate.validatorInitialized'), 'info', 'I18n validator initialized successfully');
} catch (error) {
SecurityUtils.logSecurityEvent(this.t('validate.validatorInitError'), 'error', `Validator initialization error: ${error.message}`);
throw error;
}
}
initReadline() {
const readline = require('readline');
return readline.createInterface({
input: process.stdin,
output: process.stdout
});
}
prompt(question) {
return new Promise((resolve) => {
const rl = this.rl || this.initReadline();
if (!this.rl) this.rl = rl;
rl.question(question, (answer) => {
if (!this.rl) {
rl.close();
}
resolve(answer);
});
});
}
closeReadline() {
if (this.rl) {
this.rl.close();
this.rl = null;
}
}
// Parse command line arguments
parseArgs() {
try {
SecurityUtils.logSecurityEvent(this.t('validate.argsParsing'), 'info', 'Parsing command line arguments');
const args = process.argv.slice(2);
const parsed = {};
args.forEach(arg => {
const sanitizedArg = SecurityUtils.sanitizeInput(arg);
if (sanitizedArg.startsWith('--')) {
const [key, value] = sanitizedArg.substring(2).split('=');
const sanitizedKey = SecurityUtils.sanitizeInput(key);
const sanitizedValue = value ? SecurityUtils.sanitizeInput(value) : true;
if (sanitizedKey === 'language') {
parsed.language = sanitizedValue;
} else if (sanitizedKey === 'source-dir') {
parsed.sourceDir = sanitizedValue;
} else if (sanitizedKey === 'strict') {
parsed.strictMode = true;
} else if (sanitizedKey === 'ui-language') {
parsed.uiLanguage = sanitizedValue;
} else if (sanitizedKey === 'help') {
parsed.help = true;
} else if (sanitizedKey === 'setup-admin') {
parsed.setupAdmin = true;
} else if (sanitizedKey === 'disable-admin') {
parsed.disableAdmin = true;
} else if (sanitizedKey === 'admin-status') {
parsed.adminStatus = true;
} else if (sanitizedKey === 'no-prompt') {
parsed.noPrompt = true;
} else if (['en', 'de', 'es', 'fr', 'ru', 'ja', 'zh'].includes(sanitizedKey)) {
// Support shorthand language flags like --de, --fr, etc.
parsed.uiLanguage = sanitizedKey;
}
}
});
SecurityUtils.logSecurityEvent(this.t('validate.argsParsed'), 'info', 'Command line arguments parsed successfully');
return parsed;
} catch (error) {
SecurityUtils.logSecurityEvent(this.t('validate.argsParseError'), 'error', `Argument parsing error: ${error.message}`);
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
getAvailableLanguages() {
try {
SecurityUtils.logSecurityEvent(this.t('validate.languagesScan'), 'info', 'Scanning available languages');
if (!fs.existsSync(this.sourceDir)) {
throw new Error(this.t('validate.sourceLanguageDirectoryNotFound', { sourceDir: this.sourceDir }) || `Source directory not found: ${this.sourceDir}`);
}
const languages = fs.readdirSync(this.sourceDir)
.filter(item => {
const itemPath = path.join(this.sourceDir, item);
return fs.statSync(itemPath).isDirectory() && item !== this.config.sourceLanguage;
});
SecurityUtils.logSecurityEvent(this.t('validate.languagesFound'), 'info', `Found ${languages.length} languages`);
return languages;
} catch (error) {
SecurityUtils.logSecurityEvent(this.t('validate.languagesScanError'), 'error', `Language scanning error: ${error.message}`);
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 (!fs.existsSync(languageDir)) {
return [];
}
const files = fs.readdirSync(languageDir)
.filter(file => {
return file.endsWith('.json') &&
!this.config.excludeFiles.includes(file);
});
SecurityUtils.logSecurityEvent(this.t('validate.filesScan'), 'info', `Found ${files.length} files in ${sanitizedLanguage}`);
return files;
} catch (error) {
SecurityUtils.logSecurityEvent(this.t('validate.filesScanError'), 'error', `File scanning error: ${error.message}`);
throw error;
}
}
// Get all keys recursively from an object
getAllKeys(obj, prefix = '') {
const keys = new Set();
for (const [key, value] of Object.entries(obj)) {
const fullKey = prefix ? `${prefix}.${key}` : key;
keys.add(fullKey);
if (value && typeof value === 'object' && !Array.isArray(value)) {
const nestedKeys = this.getAllKeys(value, fullKey);
nestedKeys.forEach(k => keys.add(k));
}
}
return keys;
}
// Get value by key path
getValueByPath(obj, keyPath) {
const keys = keyPath.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 = fs.readFileSync(filePath, 'utf8');
const parsed = SecurityUtils.safeParseJSON(content);
SecurityUtils.logSecurityEvent(this.t('validate.jsonValidated'), 'info', `JSON syntax validated: ${filePath}`);
return { valid: true, data: parsed };
} catch (error) {
SecurityUtils.logSecurityEvent(this.t('validate.jsonValidationError'), 'error', `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
};
}
// 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++;
if (value === this.config.notTranslatedMarker) {
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 (value.includes(this.config.notTranslatedMarker)) {
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(this.t('validate.languageValidation'), 'info', `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,
translatedKeys: 0,
missingFiles: [],
syntaxErrors: [],
structuralIssues: [],
translationIssues: []
}
};
// Check for missing language directory
if (!fs.existsSync(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 (!fs.existsSync(sourceFilePath)) {
this.addWarning(
`Source file missing: ${this.config.sourceLanguage}/${fileName}`,
{ fileName, language: this.config.sourceLanguage }
);
continue;
}
// Check if target file exists
if (!fs.existsSync(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);
// Validate translations
const translations = this.validateTranslation(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;
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
validation.summary.percentage = validation.summary.totalKeys > 0
? Math.round((validation.summary.translatedKeys / validation.summary.totalKeys) * 100)
: 0;
return validation;
} catch (error) {
SecurityUtils.logSecurityEvent(this.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;
}
// Show help message
showHelp() {
console.log(t('validate.help_message'));
}
// Main validation process
async validate() {
try {
console.log(this.t('validate.title'));
console.log(this.t('validate.message'));
// Delete old validation report if it exists
const reportPath = path.join(process.cwd(), 'validation-report.txt');
SecurityUtils.validatePath(reportPath);
if (fs.existsSync(reportPath)) {
fs.unlinkSync(reportPath);
console.log(this.t('validate.deletedOldReport'));
SecurityUtils.logSecurityEvent(this.t('validate.fileDeleted'), 'info', {
path: reportPath,
timestamp: new Date().toISOString()
});
}
// Parse command line arguments
const args = this.parseArgs();
// Handle UI language change
if (args.uiLanguage) {
loadTranslations(args.uiLanguage);
this.t = t;
}
if (args.sourceDir) {
this.config.sourceDir = args.sourceDir;
this.sourceDir = path.resolve(this.config.sourceDir);
this.sourceLanguageDir = path.join(this.sourceDir, this.config.sourceLanguage);
}
if (args.strictMode) {
this.config.strictMode = true;
}
console.log(this.t('validate.sourceDirectory', { dir: this.sourceDir }));
console.log(this.t('validate.sourceLanguage', { sourceLanguage: this.config.sourceLanguage }));
console.log(this.t('validate.strictMode', { mode: this.config.strictMode ? 'ON' : 'OFF' }));
// Validate source language directory exists
SecurityUtils.validatePath(this.sourceLanguageDir);
if (!fs.existsSync(this.sourceLanguageDir)) {
this.addError(
`Source language directory not found: ${this.sourceLanguageDir}`,
{ sourceLanguage: this.config.sourceLanguage }
);
SecurityUtils.logSecurityEvent(this.t('validate.validationError'), 'error', {
error: 'Source language directory not found',
path: this.sourceLanguageDir,
timestamp: new Date().toISOString()
});
throw new Error(this.t('validate.sourceLanguageDirectoryNotFound', { sourceDir: this.sourceLanguageDir }) || 'Source language directory not found');
}
// Get available languages
const availableLanguages = this.getAvailableLanguages();
if (availableLanguages.length === 0) {
console.log(this.t('validate.noTargetLanguages'));
return { success: true, message: 'No languages to validate' };
}
// Filter languages if specified
const targetLanguages = args.language
? [args.language].filter(lang => availableLanguages.includes(lang))
: availableLanguages;
if (targetLanguages.length === 0) {
this.addError(
`Specified language '${args.language}' not found`,
{ requestedLanguage: args.language, availableLanguages }
);
throw new Error('Specified language not found');
}
console.log(this.t('validate.validatingLanguages', { langs: targetLanguages.join(', ') }));
console.log('');
const results = {};
// Validate each language
for (const language of targetLanguages) {
console.log(this.t('validate.validatingLanguage', { lang: language }));
const validation = await this.validateLanguage(language);
results[language] = validation;
// 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.totalKeys} keys)`);
}
console.log('');
console.log(this.t('validate.separator'));
// Overall summary
const hasErrors = this.errors.length > 0;
const hasWarnings = this.warnings.length > 0;
console.log(this.t('validate.validationSummary'));
console.log(this.t('validate.totalErrors', { count: this.errors.length }));
console.log(this.t('validate.totalWarnings', { count: this.warnings.length }));
// Show errors
if (hasErrors) {
console.log('');
console.log(this.t('validate.separator'));
console.log(this.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(this.t('validate.separator'));
console.log(this.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('');
});
}
// Recommendations
console.log('');
console.log(this.t('validate.separator'));
console.log(this.t('validate.recommendationsSection'));
if (hasErrors) {
console.log('');
console.log(this.t('validate.resolveMissingFilesAndSyntaxErrors'));
console.log(this.t('validate.fixStructuralInconsistencies'));
console.log(this.t('validate.completeMissingTranslations'));
console.log(this.t('validate.rerunValidation'));
} else if (hasWarnings) {
console.log('');
console.log(this.t('validate.addressWarnings'));
console.log(this.t('validate.reviewWarnings'));
console.log(this.t('validate.considerRunningWithStrict'));
} else {
console.log('');
console.log(this.t('validate.allValidationsPassed'));
console.log(this.t('validate.considerRunningUsageAnalysis'));
}
// Exit with appropriate code
const success = !hasErrors && (!hasWarnings || !this.config.strictMode);
return {
success,
errors: this.errors.length,
warnings: this.warnings.length,
results
};
} catch (error) {
console.error(this.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;
try {
await this.initialize();
// 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' + this.t('adminCli.authRequiredForOperation', { operation: 'validate translations' }));
const pin = await this.prompt(this.t('adminCli.enterPin'));
const isValid = await adminAuth.verifyPin(pin);
this.closeReadline();
if (!isValid) {
console.log(this.t('adminCli.invalidPin'));
if (!fromMenu) process.exit(1);
return { success: false, error: 'Authentication failed' };
}
console.log(this.t('adminCli.authenticationSuccess'));
}
}
console.log(this.t('validate.startingValidationProcess'));
SecurityUtils.logSecurityEvent(this.t('validate.runStarted'), 'info', 'Starting validation run');
const result = await this.validate();
console.log(this.t('validate.validationProcessCompletedSuccessfully'));
SecurityUtils.logSecurityEvent(this.t('validate.runCompleted'), 'info', 'Validation run completed successfully');
return result;
} catch (error) {
console.error(this.t('validate.validationError', { error: error.message }));
console.error(this.t('validate.stackTrace', { stack: error.stack }));
SecurityUtils.logSecurityEvent(this.t('validate.runError'), 'error', `Validation run failed: ${error.message}`);
throw error;
}
}
}
module.exports = I18nValidator;
if (require.main === module) {
(async () => {
try {
// Initialize translations for CLI usage
const settings = settingsManager.getSettings();
const uiLanguage = settings.language || 'en';
loadTranslations(uiLanguage);
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'));
process.exit(1);
}
}
const result = await validator.validate();
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 ? 0 : 1);
} 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(1);
}
})();
}