UNPKG

woaru

Version:

Universal Project Setup Autopilot - Analyze and automatically configure development tools for ANY programming language

449 lines 18.2 kB
/** * Enhanced Translation Validator * * This utility ensures translation keys are consistent across all language files * and provides comprehensive validation for internationalization requirements. */ import fs from 'fs-extra'; import * as path from 'path'; import { glob } from 'glob'; import { fileURLToPath } from 'url'; import { dirname } from 'path'; // ES module compatibility const __filename = fileURLToPath(import.meta.url); const __dirname = dirname(__filename); export class TranslationValidator { localesPath; options; constructor(localesPath = path.join(__dirname, '../../locales'), options = {}) { this.localesPath = localesPath; this.options = { checkEmptyValues: true, checkPlaceholders: true, baseLanguage: 'en', requiredLanguages: ['en', 'de'], ignorePaths: [], ...options, }; } /** * Validates all translation files for consistency and completeness */ async validateTranslations() { const issues = []; try { // Get all language files const langFiles = await glob('*/translation.json', { cwd: this.localesPath, }); if (langFiles.length < 2) { console.warn('Less than 2 language files found. Skipping validation.'); return issues; } // Load all translations const translations = {}; for (const file of langFiles) { const lang = path.dirname(file); const content = await fs.readJson(path.join(this.localesPath, file)); translations[lang] = content; } // Get all unique keys from all languages const allKeys = new Set(); Object.values(translations).forEach(trans => { this.extractKeys(trans, '', allKeys); }); // Check each language has all keys for (const [lang, trans] of Object.entries(translations)) { const langKeys = new Set(); this.extractKeys(trans, '', langKeys); // Check for missing keys for (const key of allKeys) { if (this.shouldIgnoreKey(key)) continue; if (!langKeys.has(key)) { issues.push({ key, issue: 'missing', language: lang, details: `Key "${key}" is missing in ${lang} translation`, severity: 'error', }); } } // Check for extra keys for (const key of langKeys) { if (this.shouldIgnoreKey(key)) continue; if (!allKeys.has(key)) { issues.push({ key, issue: 'extra', language: lang, details: `Key "${key}" exists only in ${lang} translation`, severity: 'warning', }); } } // Check for empty values if enabled if (this.options.checkEmptyValues) { this.checkEmptyValues(trans, lang, '', issues); } } // Check for type mismatches (e.g., string vs object) for (const key of allKeys) { const types = new Map(); for (const [lang, trans] of Object.entries(translations)) { const value = this.getValueByKey(trans, key); if (value !== undefined) { types.set(lang, typeof value); } } const uniqueTypes = new Set(types.values()); if (uniqueTypes.size > 1) { issues.push({ key, issue: 'type_mismatch', language: 'multiple', details: `Type mismatch for key "${key}": ${Array.from(types.entries()) .map(([l, t]) => `${l}=${t}`) .join(', ')}`, severity: 'error', }); } } // Check placeholder consistency if enabled if (this.options.checkPlaceholders && this.options.baseLanguage) { const baseTranslation = translations[this.options.baseLanguage]; if (baseTranslation) { this.checkPlaceholderConsistency(translations, issues); } } // Check required languages if (this.options.requiredLanguages) { for (const requiredLang of this.options.requiredLanguages) { if (!translations[requiredLang]) { issues.push({ key: '', issue: 'missing', language: requiredLang, details: `Required language "${requiredLang}" is missing`, severity: 'error', }); } } } } catch (error) { console.error('Error validating translations:', error); } return issues; } /** * Recursively extract all keys from a translation object */ extractKeys(obj, prefix, keys) { for (const [key, value] of Object.entries(obj)) { const fullKey = prefix ? `${prefix}.${key}` : key; if (typeof value === 'object' && value !== null && !Array.isArray(value)) { this.extractKeys(value, fullKey, keys); } else { keys.add(fullKey); } } } /** * Get value by dot-notation key */ getValueByKey(obj, key) { const parts = key.split('.'); let current = obj; for (const part of parts) { if (current && typeof current === 'object' && part in current) { const nextValue = current[part]; if (typeof nextValue === 'object' && nextValue !== null) { current = nextValue; } else { return nextValue; } } else { return undefined; } } return current; } /** * Check for empty or whitespace-only values */ checkEmptyValues(obj, language, prefix, issues) { for (const [key, value] of Object.entries(obj)) { const fullKey = prefix ? `${prefix}.${key}` : key; if (typeof value === 'object' && value !== null && !Array.isArray(value)) { this.checkEmptyValues(value, language, fullKey, issues); } else if (typeof value === 'string') { if (value.trim() === '') { issues.push({ key: fullKey, issue: 'empty_value', language, details: `Empty or whitespace-only value for key "${fullKey}" in ${language}`, severity: 'warning', }); } } } } /** * Check placeholder consistency between languages */ checkPlaceholderConsistency(translations, issues) { const baseLanguage = this.options.baseLanguage; if (!baseLanguage) { throw new Error('Base language is required for placeholder consistency check'); } const baseTranslation = translations[baseLanguage]; const basePlaceholders = this.extractPlaceholders(baseTranslation); for (const [lang, translation] of Object.entries(translations)) { if (lang === this.options.baseLanguage) continue; const langPlaceholders = this.extractPlaceholders(translation); for (const [key, basePlaceholder] of basePlaceholders) { const langPlaceholder = langPlaceholders.get(key); if (!langPlaceholder) { if (basePlaceholder.length > 0) { issues.push({ key, issue: 'placeholder_mismatch', language: lang, details: `Missing placeholders in ${lang}: ${basePlaceholder.join(', ')}`, severity: 'error', }); } continue; } const missingPlaceholders = basePlaceholder.filter(p => !langPlaceholder.includes(p)); const extraPlaceholders = langPlaceholder.filter(p => !basePlaceholder.includes(p)); if (missingPlaceholders.length > 0) { issues.push({ key, issue: 'placeholder_mismatch', language: lang, details: `Missing placeholders in ${lang}: ${missingPlaceholders.join(', ')}`, severity: 'error', }); } if (extraPlaceholders.length > 0) { issues.push({ key, issue: 'placeholder_mismatch', language: lang, details: `Extra placeholders in ${lang}: ${extraPlaceholders.join(', ')}`, severity: 'warning', }); } } } } /** * Extract placeholders from translation object */ extractPlaceholders(obj, prefix = '') { const placeholders = new Map(); for (const [key, value] of Object.entries(obj)) { const fullKey = prefix ? `${prefix}.${key}` : key; if (typeof value === 'object' && value !== null && !Array.isArray(value)) { const nested = this.extractPlaceholders(value, fullKey); nested.forEach((placeholderList, nestedKey) => { placeholders.set(nestedKey, placeholderList); }); } else if (typeof value === 'string') { const matches = value.match(/\{\{[^}]+\}\}/g) || []; if (matches.length > 0) { placeholders.set(fullKey, matches); } } } return placeholders; } /** * Check if a key should be ignored based on ignore patterns */ shouldIgnoreKey(key) { if (!this.options.ignorePaths) return false; return this.options.ignorePaths.some(pattern => { if (pattern.includes('*')) { const regex = new RegExp(pattern.replace(/\*/g, '.*')); return regex.test(key); } return key.startsWith(pattern); }); } /** * Generate a comprehensive validation summary */ generateSummary(issues) { const summary = { totalKeys: 0, totalIssues: issues.length, errorCount: issues.filter(i => i.severity === 'error').length, warningCount: issues.filter(i => i.severity === 'warning').length, infoCount: issues.filter(i => i.severity === 'info').length, languages: [], completeness: {}, }; // Calculate completeness for each language const langStats = new Map(); issues.forEach(issue => { if (issue.language !== 'multiple') { if (!langStats.has(issue.language)) { langStats.set(issue.language, { total: 0, missing: 0 }); } const stats = langStats.get(issue.language); if (!stats) { langStats.set(issue.language, { total: 1, missing: issue.issue === 'missing' ? 1 : 0, }); } else { if (issue.issue === 'missing') { stats.missing++; } stats.total++; } } }); summary.languages = Array.from(langStats.keys()); langStats.forEach((stats, lang) => { const completeness = Math.max(0, 100 - (stats.missing / Math.max(stats.total, 1)) * 100); summary.completeness[lang] = Math.round(completeness * 100) / 100; }); return summary; } /** * Compare specific languages and return detailed comparison */ async compareLanguages(lang1, lang2) { const translation1Path = path.join(this.localesPath, lang1, 'translation.json'); const translation2Path = path.join(this.localesPath, lang2, 'translation.json'); const [trans1, trans2] = await Promise.all([ fs.readJson(translation1Path), fs.readJson(translation2Path), ]); const keys1 = new Set(); const keys2 = new Set(); this.extractKeys(trans1, '', keys1); this.extractKeys(trans2, '', keys2); const onlyInLang1 = Array.from(keys1).filter(k => !keys2.has(k)); const onlyInLang2 = Array.from(keys2).filter(k => !keys1.has(k)); const common = Array.from(keys1).filter(k => keys2.has(k)); const differences = []; for (const key of common) { const val1 = this.getValueByKey(trans1, key); const val2 = this.getValueByKey(trans2, key); if (JSON.stringify(val1) !== JSON.stringify(val2)) { differences.push({ key, lang1Value: val1, lang2Value: val2 }); } } return { onlyInLang1, onlyInLang2, common, differences }; } /** * Generate a report of translation issues */ generateReport(issues) { if (issues.length === 0) { return '✅ All translations are consistent!'; } const summary = this.generateSummary(issues); const report = ['# Translation Validation Report', '']; // Add summary section report.push('## Summary'); report.push(''); report.push(`- **Total Issues**: ${summary.totalIssues}`); report.push(`- **Errors**: ${summary.errorCount}`); report.push(`- **Warnings**: ${summary.warningCount}`); report.push(`- **Languages**: ${summary.languages.join(', ')}`); report.push(''); // Add completeness section if (Object.keys(summary.completeness).length > 0) { report.push('## Translation Completeness'); report.push(''); Object.entries(summary.completeness).forEach(([lang, percentage]) => { const status = percentage >= 95 ? '✅' : percentage >= 80 ? '⚠️' : '❌'; report.push(`- **${lang}**: ${percentage}% ${status}`); }); report.push(''); } // Group by severity first, then by issue type const bySeverity = issues.reduce((acc, issue) => { if (!acc[issue.severity]) acc[issue.severity] = []; acc[issue.severity].push(issue); return acc; }, {}); // Process each severity level ['error', 'warning', 'info'].forEach(severity => { const severityIssues = bySeverity[severity]; if (!severityIssues || severityIssues.length === 0) return; const severityIcon = severity === 'error' ? '🔴' : severity === 'warning' ? '🟡' : '🔵'; report.push(`## ${severityIcon} ${severity.toUpperCase()} (${severityIssues.length})`); report.push(''); // Group by issue type within severity const byType = severityIssues.reduce((acc, issue) => { if (!acc[issue.issue]) acc[issue.issue] = []; acc[issue.issue].push(issue); return acc; }, {}); this.addIssueTypeSection(report, byType, 'missing', 'Missing Keys'); this.addIssueTypeSection(report, byType, 'extra', 'Extra Keys'); this.addIssueTypeSection(report, byType, 'type_mismatch', 'Type Mismatches'); this.addIssueTypeSection(report, byType, 'empty_value', 'Empty Values'); this.addIssueTypeSection(report, byType, 'placeholder_mismatch', 'Placeholder Mismatches'); }); return report.join('\n'); } /** * Helper method to add issue type sections to report */ addIssueTypeSection(report, byType, issueType, title) { if (byType[issueType]) { report.push(`### ${title} (${byType[issueType].length})`); report.push(''); byType[issueType].forEach(issue => { if (issue.language === 'multiple') { report.push(`- \`${issue.key}\`: ${issue.details}`); } else { report.push(`- **${issue.language}**: \`${issue.key}\`${issue.details ? ` - ${issue.details}` : ''}`); } }); report.push(''); } } } // Export for CLI usage export async function validateI18n() { const validator = new TranslationValidator(); const issues = await validator.validateTranslations(); if (issues.length > 0) { console.log(validator.generateReport(issues)); process.exit(1); } else { console.log('✅ All translations are consistent!'); } } //# sourceMappingURL=translationValidator.js.map