UNPKG

i18next-mcp-server

Version:

A comprehensive Model Context Protocol (MCP) server for i18next translation management, health checking, and automated translation workflows

698 lines 31 kB
import { ValidationError } from '../types/index.js'; export class HealthChecker { config; fileManager; constructor(config, fileManager) { this.config = config; this.fileManager = fileManager; } async performHealthCheck(languages, namespaces, detailed = false) { const targetLanguages = languages || this.config.languages; const targetNamespaces = namespaces || this.config.namespaces; const result = { summary: { totalIssues: 0, errors: 0, warnings: 0, info: 0, score: 0 }, files: {}, issues: [], recommendations: [], timestamp: new Date() }; // Load all translation files const translations = {}; for (const language of targetLanguages) { translations[language] = {}; for (const namespace of targetNamespaces) { try { const file = await this.fileManager.loadTranslationFile(language, namespace); translations[language][namespace] = file.content; } catch (error) { result.issues.push({ type: 'file_missing', severity: 'error', message: `Missing translation file: ${language}/${namespace}.json`, file: `${language}/${namespace}`, suggestion: 'Create the missing translation file', location: { language, namespace } }); translations[language][namespace] = {}; } } } // Perform health checks for each file for (const language of targetLanguages) { for (const namespace of targetNamespaces) { const fileKey = `${language}/${namespace}`; const keys = this.getAllKeysWithDetails(translations[language][namespace]); const fileIssues = []; // Interpolation validation for (const [key, detail] of Object.entries(keys)) { const interpolationIssues = this.validateInterpolation(detail.value, key, fileKey); fileIssues.push(...interpolationIssues); } // Pluralization validation const pluralizationIssues = this.validatePluralization(keys, namespace, language, fileKey); fileIssues.push(...pluralizationIssues); // Translation quality validation const qualityIssues = this.validateTranslationQuality(keys, language, namespace, fileKey); fileIssues.push(...qualityIssues); // Consistency validation (compare with source language) if (language !== this.config.defaultLanguage) { const sourceKeys = this.getAllKeysWithDetails(translations[this.config.defaultLanguage][namespace]); const consistencyIssues = this.validateConsistency(sourceKeys, keys, this.config.defaultLanguage, language, namespace, fileKey); fileIssues.push(...consistencyIssues); } // Structure validation const structureIssues = this.validateStructure(keys, fileKey); fileIssues.push(...structureIssues); // Performance validation const performanceIssues = this.validatePerformance(keys, fileKey); fileIssues.push(...performanceIssues); result.issues.push(...fileIssues); result.files[fileKey] = { keyCount: Object.keys(keys).length, issueCount: fileIssues.length, issues: detailed ? fileIssues : fileIssues.filter(i => i.severity === 'error'), qualityScore: this.calculateQualityScore(keys, fileIssues) }; } } // Cross-language analysis const crossLangIssues = this.performCrossLanguageAnalysis(translations, targetLanguages, targetNamespaces); result.issues.push(...crossLangIssues); // Calculate summary for (const issue of result.issues) { result.summary.totalIssues++; if (issue.severity === 'error') result.summary.errors++; else if (issue.severity === 'warning') result.summary.warnings++; else if (issue.severity === 'info') result.summary.info++; } // Calculate overall score result.summary.score = this.calculateOverallScore(result); // Generate recommendations result.recommendations = this.generateRecommendations(result); return result; } async performHealthCheckSummary(languages, namespaces) { // Perform lightweight health check const fullResult = await this.performHealthCheck(languages, namespaces, false); // Calculate overall status const getStatus = (score) => { if (score >= 90) return 'excellent'; if (score >= 80) return 'good'; if (score >= 60) return 'needs_attention'; return 'poor'; }; const getGrade = (score) => { if (score >= 90) return 'A'; if (score >= 80) return 'B'; if (score >= 70) return 'C'; if (score >= 60) return 'D'; return 'F'; }; // Group issues by type for summary const issueTypeCounts = fullResult.issues.reduce((acc, issue) => { acc[issue.type] = (acc[issue.type] || 0) + 1; return acc; }, {}); const topIssueTypes = Object.entries(issueTypeCounts) .sort(([, a], [, b]) => b - a) .slice(0, 5) .map(([type, count]) => ({ type, severity: fullResult.issues.find(i => i.type === type)?.severity || 'info', count, description: this.getIssueTypeDescription(type) })); return { overall: { score: fullResult.summary.score, grade: getGrade(fullResult.summary.score), status: getStatus(fullResult.summary.score), totalIssues: fullResult.summary.totalIssues, criticalIssues: fullResult.summary.errors }, files: Object.fromEntries(Object.entries(fullResult.files).map(([fileKey, fileData]) => [ fileKey, { score: fileData.qualityScore.score, grade: fileData.qualityScore.grade, keyCount: fileData.keyCount, issueCount: fileData.issueCount, status: fileData.issues.some(i => i.severity === 'error') ? 'error' : fileData.issues.some(i => i.severity === 'warning') ? 'warning' : 'healthy' } ])), topIssues: topIssueTypes, recommendations: fullResult.recommendations.slice(0, 3) }; } async validateFiles(fix = false) { const files = await this.fileManager.loadAllTranslationFiles(); const results = { valid: true, issues: [], totalFiles: files.length, validFiles: 0, fixedIssues: fix ? [] : undefined }; for (const file of files) { try { // Basic JSON validation JSON.stringify(file.content); // Will throw if not serializable // Structure validation this.validateJsonStructure(file.content, `${file.language}/${file.namespace}`); results.validFiles++; } catch (error) { results.valid = false; results.issues.push({ type: 'json_syntax_error', severity: 'error', message: `JSON syntax error in ${file.path}`, file: `${file.language}/${file.namespace}`, suggestion: 'Fix JSON syntax errors', location: { language: file.language, namespace: file.namespace }, details: { error: error instanceof Error ? error.message : String(error) } }); if (fix && results.fixedIssues) { try { // Attempt basic JSON fixes const fixed = this.attemptJsonFix(file.content); if (fixed) { await this.fileManager.saveTranslationFile(file.language, file.namespace, fixed, true); results.fixedIssues.push(`Fixed JSON in ${file.path}`); } } catch (fixError) { // Fix failed, continue } } } } return results; } getAllKeysWithDetails(obj, prefix = '', path = []) { const result = {}; for (const [key, value] of Object.entries(obj)) { const fullKey = prefix ? `${prefix}.${key}` : key; const fullPath = [...path, key]; if (typeof value === 'object' && value !== null && !Array.isArray(value)) { Object.assign(result, this.getAllKeysWithDetails(value, fullKey, fullPath)); } else { result[fullKey] = { value, path: fullPath, type: typeof value }; } } return result; } validateInterpolation(value, keyPath, fileKey) { const issues = []; if (typeof value !== 'string') return issues; // Check for unbalanced interpolation brackets const openBrackets = (value.match(/\{\{/g) || []).length; const closeBrackets = (value.match(/\}\}/g) || []).length; if (openBrackets !== closeBrackets) { issues.push({ type: 'interpolation_unbalanced', severity: 'error', message: `Unbalanced interpolation brackets in "${keyPath}"`, file: fileKey, suggestion: 'Ensure all {{variable}} brackets are properly closed', location: { key: keyPath }, details: { value, openCount: openBrackets, closeCount: closeBrackets } }); } // Check for empty interpolation const emptyInterpolation = value.match(/\{\{\s*\}\}/g); if (emptyInterpolation) { issues.push({ type: 'interpolation_empty', severity: 'warning', message: `Empty interpolation in "${keyPath}"`, file: fileKey, suggestion: 'Remove empty {{}} or add variable name', location: { key: keyPath }, details: { value, emptyCount: emptyInterpolation.length } }); } // Check for malformed interpolation const malformedInterpolation = value.match(/\{[^{}]*\}[^}]|[^{]\{[^{}]*\}/g); if (malformedInterpolation) { issues.push({ type: 'interpolation_malformed', severity: 'warning', message: `Potentially malformed interpolation in "${keyPath}"`, file: fileKey, suggestion: 'Use {{variable}} format for interpolation', location: { key: keyPath }, details: { value, matches: malformedInterpolation } }); } return issues; } validatePluralization(keys, namespace, lang, fileKey) { const issues = []; const pluralKeys = {}; // Group potential plural keys for (const key of Object.keys(keys)) { const pluralMatch = key.match(/^(.+)_(one|other|zero|two|few|many)$/); if (pluralMatch) { const baseKey = pluralMatch[1]; const pluralForm = pluralMatch[2]; if (!pluralKeys[baseKey]) { pluralKeys[baseKey] = []; } pluralKeys[baseKey].push(pluralForm); } } // Validate plural forms based on language for (const [baseKey, forms] of Object.entries(pluralKeys)) { const expectedForms = this.getExpectedPluralForms(lang); const missingForms = expectedForms.filter(form => !forms.includes(form)); if (missingForms.length > 0) { issues.push({ type: 'pluralization_missing_forms', severity: 'warning', message: `${this.getLanguageName(lang)} pluralization incomplete for "${baseKey}" in ${namespace}`, file: fileKey, suggestion: `${this.getLanguageName(lang)} typically requires: ${expectedForms.join(', ')}`, location: { key: baseKey, namespace }, details: { found: forms, expected: expectedForms, missing: missingForms } }); } } return issues; } validateConsistency(sourceKeys, targetKeys, sourceLang, targetLang, namespace, fileKey) { const issues = []; for (const [key, sourceDetail] of Object.entries(sourceKeys)) { const targetDetail = targetKeys[key]; if (!targetDetail) { issues.push({ type: 'consistency_missing_key', severity: 'warning', message: `Key "${key}" exists in ${sourceLang} but missing in ${targetLang}`, file: fileKey, suggestion: `Add missing key to ${targetLang}/${namespace}.json`, location: { key, namespace }, details: { sourceValue: sourceDetail.value } }); continue; } const sourceValue = sourceDetail.value; const targetValue = targetDetail.value; if (typeof sourceValue !== typeof targetValue) { issues.push({ type: 'consistency_type_mismatch', severity: 'error', message: `Type mismatch for "${key}" between ${sourceLang} and ${targetLang}`, file: fileKey, suggestion: 'Ensure the same type (string, object, etc.) in all languages', location: { key, namespace }, details: { sourceType: typeof sourceValue, targetType: typeof targetValue } }); } // Check interpolation consistency if (typeof sourceValue === 'string' && typeof targetValue === 'string') { const sourceVars = this.extractInterpolationVars(sourceValue); const targetVars = this.extractInterpolationVars(targetValue); const missingVars = sourceVars.filter(v => !targetVars.includes(v)); const extraVars = targetVars.filter(v => !sourceVars.includes(v)); if (missingVars.length > 0 || extraVars.length > 0) { issues.push({ type: 'consistency_interpolation_mismatch', severity: 'error', message: `Interpolation variables mismatch for "${key}" between ${sourceLang} and ${targetLang}`, file: fileKey, suggestion: 'Ensure all interpolation variables match between languages', location: { key, namespace }, details: { sourceVars, targetVars, missing: missingVars, extra: extraVars, sourceValue, targetValue } }); } } } // Check for orphaned keys (exist in target but not in source) for (const key of Object.keys(targetKeys)) { if (!sourceKeys[key]) { issues.push({ type: 'consistency_orphaned_key', severity: 'info', message: `Key "${key}" exists in ${targetLang} but not in ${sourceLang}`, file: fileKey, suggestion: `Consider removing orphaned key or adding it to ${sourceLang}`, location: { key, namespace }, details: { targetValue: targetKeys[key].value } }); } } return issues; } validateTranslationQuality(keys, lang, namespace, fileKey) { const issues = []; for (const [key, detail] of Object.entries(keys)) { if (typeof detail.value !== 'string') continue; const value = detail.value; // Check for untranslated content (still in source language) if (lang !== this.config.defaultLanguage && this.isUntranslated(value, lang)) { issues.push({ type: 'quality_untranslated', severity: 'warning', message: `Potentially untranslated content in "${key}"`, file: fileKey, suggestion: `Translate to ${this.getLanguageName(lang)}`, location: { key, namespace }, details: { value } }); } // Check for suspicious patterns if (this.hasSuspiciousPattern(value)) { issues.push({ type: 'quality_suspicious_content', severity: 'info', message: `Suspicious content pattern in "${key}"`, file: fileKey, suggestion: 'Review content for placeholder text or development artifacts', location: { key, namespace }, details: { value } }); } // Check for HTML content if (this.containsHtml(value)) { issues.push({ type: 'quality_html_content', severity: 'info', message: `HTML content detected in "${key}"`, file: fileKey, suggestion: 'Ensure HTML is properly sanitized and consider using rich text components', location: { key, namespace }, details: { value } }); } // Check for length inconsistencies (extremely long or short translations) const lengthIssue = this.validateTranslationLength(value, key); if (lengthIssue) { issues.push({ ...lengthIssue, file: fileKey, location: { key, namespace } }); } } return issues; } validateStructure(keys, fileKey) { const issues = []; // Check for deeply nested structures for (const [key, detail] of Object.entries(keys)) { if (detail.path.length > 5) { issues.push({ type: 'structure_deeply_nested', severity: 'info', message: `Deeply nested key "${key}" (${detail.path.length} levels)`, file: fileKey, suggestion: 'Consider flattening deeply nested structures for better maintainability', location: { key }, details: { depth: detail.path.length, path: detail.path } }); } } // Check for inconsistent naming patterns const namingIssues = this.validateNamingPatterns(Object.keys(keys), fileKey); issues.push(...namingIssues); return issues; } validatePerformance(keys, fileKey) { const issues = []; const keyCount = Object.keys(keys).length; // Check for large namespace files if (keyCount > 500) { issues.push({ type: 'performance_large_namespace', severity: 'warning', message: `Large namespace with ${keyCount} keys`, file: fileKey, suggestion: 'Consider splitting large namespaces for better performance', details: { keyCount } }); } // Check for very long translation values for (const [key, detail] of Object.entries(keys)) { if (typeof detail.value === 'string' && detail.value.length > 1000) { issues.push({ type: 'performance_long_translation', severity: 'info', message: `Very long translation in "${key}" (${detail.value.length} characters)`, file: fileKey, suggestion: 'Consider breaking long translations into smaller parts', location: { key }, details: { length: detail.value.length } }); } } return issues; } performCrossLanguageAnalysis(translations, languages, namespaces) { const issues = []; // Check for missing language files for (const namespace of namespaces) { const availableLanguages = languages.filter(lang => translations[lang] && translations[lang][namespace]); if (availableLanguages.length < languages.length) { const missingLanguages = languages.filter(lang => !availableLanguages.includes(lang)); issues.push({ type: 'cross_language_missing_files', severity: 'error', message: `Namespace "${namespace}" missing in languages: ${missingLanguages.join(', ')}`, suggestion: 'Create missing translation files', details: { namespace, missingLanguages, availableLanguages } }); } } return issues; } calculateQualityScore(keys, issues) { const totalKeys = Object.keys(keys).length; const errors = issues.filter(i => i.severity === 'error').length; const warnings = issues.filter(i => i.severity === 'warning').length; // Calculate score out of 100 let score = 100; score -= errors * 10; // Each error -10 points score -= warnings * 2; // Each warning -2 points score = Math.max(0, Math.min(100, score)); // Clamp between 0-100 let grade; if (score >= 90) grade = 'A'; else if (score >= 80) grade = 'B'; else if (score >= 70) grade = 'C'; else if (score >= 60) grade = 'D'; else grade = 'F'; return { score, grade, totalKeys, errorCount: errors, warningCount: warnings }; } calculateOverallScore(result) { const files = Object.values(result.files); if (files.length === 0) return 0; const totalScore = files.reduce((sum, file) => sum + file.qualityScore.score, 0); return Math.round(totalScore / files.length); } generateRecommendations(result) { const recommendations = []; if (result.summary.errors > 0) { recommendations.push('🚨 Critical: Fix errors immediately to prevent runtime issues'); } if (result.summary.warnings > 10) { recommendations.push('⚠️ Consider addressing warnings to improve translation quality'); } if (result.summary.totalIssues === 0) { recommendations.push('✅ Excellent! Your translations are in great health'); } else if (result.summary.totalIssues < 10) { recommendations.push('👍 Good translation health, minor issues to address'); } else if (result.summary.totalIssues < 50) { recommendations.push('📝 Moderate number of issues, consider a cleanup session'); } else { recommendations.push('🔧 Many issues found, recommend systematic cleanup'); } // Specific recommendations based on issue types const issueTypes = [...new Set(result.issues.map(i => i.type))]; if (issueTypes.includes('interpolation_unbalanced')) { recommendations.push('🔧 Fix unbalanced interpolation brackets to prevent runtime errors'); } if (issueTypes.includes('consistency_missing_key')) { recommendations.push('📝 Run translation sync to add missing keys across languages'); } if (issueTypes.includes('quality_untranslated')) { recommendations.push('🌐 Review and translate content marked as potentially untranslated'); } return recommendations; } // Helper methods getExpectedPluralForms(lang) { switch (lang) { case 'en': case 'es': case 'ca': return ['one', 'other']; default: return ['one', 'other']; } } getLanguageName(code) { const names = { 'en': 'English', 'es': 'Spanish', 'ca': 'Catalan' }; return names[code] || code; } extractInterpolationVars(value) { const matches = value.match(/\{\{([^}]*)\}\}/g) || []; return matches.map(match => match.slice(2, -2).trim()); } isUntranslated(value, lang) { // Simple heuristics for detecting untranslated content const englishWords = ['the', 'and', 'or', 'but', 'in', 'on', 'at', 'to', 'for', 'of', 'with', 'by']; const words = value.toLowerCase().split(/\s+/); const englishWordCount = words.filter(word => englishWords.includes(word)).length; return lang !== 'en' && englishWordCount > words.length * 0.3; } hasSuspiciousPattern(value) { const suspiciousPatterns = [ /^(test|todo|fixme|placeholder)/i, /lorem ipsum/i, /\[.*\]/, /xxx+/i ]; return suspiciousPatterns.some(pattern => pattern.test(value)); } containsHtml(value) { return /<[^>]+>/.test(value); } validateTranslationLength(value, key) { if (value.length > 2000) { return { type: 'quality_extremely_long', severity: 'warning', message: `Extremely long translation in "${key}" (${value.length} characters)`, suggestion: 'Consider breaking into smaller, manageable pieces' }; } if (value.length < 2 && !key.includes('_short') && !key.includes('_abbr')) { return { type: 'quality_extremely_short', severity: 'info', message: `Very short translation in "${key}" (${value.length} characters)`, suggestion: 'Verify this is intentionally brief' }; } return null; } validateNamingPatterns(keys, fileKey) { const issues = []; // Check for inconsistent casing const camelCaseKeys = keys.filter(key => /^[a-z][a-zA-Z0-9]*$/.test(key)); const snakeCaseKeys = keys.filter(key => /^[a-z][a-z0-9_]*$/.test(key)); if (camelCaseKeys.length > 0 && snakeCaseKeys.length > 0) { issues.push({ type: 'structure_inconsistent_naming', severity: 'info', message: 'Mixed naming conventions detected (camelCase and snake_case)', file: fileKey, suggestion: 'Use consistent naming convention throughout the file', details: { camelCaseCount: camelCaseKeys.length, snakeCaseCount: snakeCaseKeys.length } }); } return issues; } validateJsonStructure(content, fileKey) { // Check for circular references try { JSON.stringify(content); } catch (error) { throw new ValidationError(`Circular reference or invalid JSON structure in ${fileKey}`); } } attemptJsonFix(content) { try { // Remove circular references and fix common issues return JSON.parse(JSON.stringify(content)); } catch { return null; } } getIssueTypeDescription(type) { const descriptions = { 'file_missing': 'Translation files are missing for some languages', 'interpolation_unbalanced': 'Interpolation brackets are not properly balanced', 'interpolation_empty': 'Empty interpolation placeholders found', 'interpolation_malformed': 'Malformed interpolation syntax detected', 'consistency_missing_key': 'Keys missing in some languages', 'consistency_type_mismatch': 'Value types differ between languages', 'consistency_interpolation_mismatch': 'Interpolation variables differ between languages', 'consistency_orphaned_key': 'Keys exist in target but not source language', 'quality_untranslated': 'Content appears to be untranslated', 'quality_suspicious_content': 'Suspicious placeholder or test content', 'quality_html_content': 'HTML content detected in translations', 'quality_extremely_long': 'Extremely long translation values', 'quality_extremely_short': 'Suspiciously short translation values', 'structure_deeply_nested': 'Deeply nested translation structure', 'structure_inconsistent_naming': 'Inconsistent naming conventions', 'performance_large_namespace': 'Large namespace files affecting performance', 'performance_long_translation': 'Very long individual translations', 'pluralization_missing_forms': 'Missing plural forms for language', 'cross_language_missing_files': 'Missing translation files across languages', 'json_syntax_error': 'JSON syntax errors in translation files' }; return descriptions[type] || 'Unknown issue type'; } } //# sourceMappingURL=health-checker.js.map