UNPKG

i18next-mcp-server

Version:

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

380 lines 16.9 kB
import { exec } from 'node:child_process'; import { promisify } from 'node:util'; import fs from 'node:fs/promises'; import path from 'node:path'; export class I18nextScannerIntegration { config; projectRoot; execAsync; constructor(config) { this.config = config; this.projectRoot = config.projectRoot || process.cwd(); this.execAsync = promisify(exec); } /** * Run i18next-scanner to extract keys from codebase */ async scanCodebase(options = {}) { const startTime = Date.now(); const result = { success: false, extractedKeys: [], totalKeys: 0, affectedFiles: [], errors: [], scanTime: 0 }; try { let command; if (options.extract) { command = 'npm run i18n:extract'; result.outputPath = './temp-i18n-extract/'; } else if (options.clean) { command = 'npm run i18n:clean'; } else if (options.namespace) { command = `I18N_NAMESPACE=${options.namespace} npx gulp i18n:scan-namespace`; } else { command = 'npm run i18n:scan'; } console.log(`Running: ${command}`); const { stdout, stderr } = await this.execAsync(command, { cwd: this.projectRoot, maxBuffer: 1024 * 1024 * 10 // 10MB buffer }); if (stderr && !stderr.includes('Warning')) { result.errors.push(stderr); } // Parse the scanner output to extract information const outputLines = stdout.split('\n'); result.extractedKeys = this.parseExtractedKeys(outputLines); result.totalKeys = result.extractedKeys.length; result.affectedFiles = this.parseAffectedFiles(outputLines); result.success = true; console.log(`Scanner completed successfully. Found ${result.totalKeys} keys.`); } catch (error) { const errorMessage = error instanceof Error ? error.message : String(error); result.errors.push(errorMessage); console.error('Scanner failed:', errorMessage); } result.scanTime = Date.now() - startTime; return result; } /** * Analyze missing keys across all languages and namespaces */ async analyzeMissingKeys() { const analysis = []; try { // First, extract current keys from codebase const extractResult = await this.scanCodebase({ extract: true }); if (!extractResult.success) { throw new Error('Failed to extract keys from codebase'); } // Get all extracted translation files const extractPath = './temp-i18n-extract/'; const languages = this.config.languages || ['en', 'es', 'ca']; const namespaces = this.config.namespaces || ['translation']; for (const lang of languages) { for (const ns of namespaces) { const extractedFilePath = path.join(extractPath, this.config.localesPath, lang, `${ns}.json`); const currentFilePath = path.join(this.config.localesPath, lang, `${ns}.json`); try { // Read extracted keys (what should exist) const extractedData = await this.readJSONFile(extractedFilePath); const extractedKeys = extractedData ? this.flattenKeys(extractedData) : []; // Read current translation file (what exists) const currentData = await this.readJSONFile(currentFilePath); const currentKeys = currentData ? this.flattenKeys(currentData) : []; // Find missing keys const missingKeys = extractedKeys.filter(key => !currentKeys.includes(key)); if (missingKeys.length > 0 || extractedKeys.length > 0) { analysis.push({ language: lang, namespace: ns, missingKeys, totalMissing: missingKeys.length }); } } catch (error) { console.warn(`Could not analyze ${lang}/${ns}:`, error instanceof Error ? error.message : String(error)); } } } // Clean up temporary extraction await this.cleanupTempFiles('./temp-i18n-extract/'); } catch (error) { console.error('Missing keys analysis failed:', error); } return analysis; } /** * Analyze key usage patterns and find orphaned keys */ async analyzeKeyUsage() { const analysis = { totalUsedKeys: 0, unusedKeys: [], orphanedKeys: [], keysByNamespace: {}, keysByFile: {} }; try { // Extract keys currently used in code const extractResult = await this.scanCodebase({ extract: true }); if (!extractResult.success) { throw new Error('Failed to extract used keys'); } // Read extracted translation files to get used keys const extractPath = './temp-i18n-extract/'; const languages = this.config.languages || ['en', 'es', 'ca']; const namespaces = this.config.namespaces || ['translation']; const usedKeys = new Set(); const keysByNamespace = {}; // Collect all used keys from extracted files for (const ns of namespaces) { keysByNamespace[ns] = new Set(); for (const lang of languages) { const extractedFilePath = path.join(extractPath, this.config.localesPath, lang, `${ns}.json`); try { const extractedData = await this.readJSONFile(extractedFilePath); if (extractedData) { const keys = this.flattenKeys(extractedData); keys.forEach(key => { usedKeys.add(key); keysByNamespace[ns].add(key); }); } } catch (error) { console.warn(`Could not read extracted file ${extractedFilePath}:`, error instanceof Error ? error.message : String(error)); } } } // Now compare with actual translation files to find orphaned keys for (const lang of languages) { for (const ns of namespaces) { const currentFilePath = path.join(this.config.localesPath, lang, `${ns}.json`); try { const currentData = await this.readJSONFile(currentFilePath); if (currentData) { const currentKeys = this.flattenKeys(currentData); const orphaned = currentKeys.filter(key => !usedKeys.has(key)); analysis.orphanedKeys.push(...orphaned); analysis.keysByFile[currentFilePath] = currentKeys; } } catch (error) { console.warn(`Could not read current file ${currentFilePath}:`, error instanceof Error ? error.message : String(error)); } } } analysis.totalUsedKeys = usedKeys.size; analysis.unusedKeys = Array.from(analysis.orphanedKeys); // Convert sets to arrays for final result for (const ns in keysByNamespace) { analysis.keysByNamespace[ns] = Array.from(keysByNamespace[ns]); } // Clean up temporary extraction await this.cleanupTempFiles('./temp-i18n-extract/'); } catch (error) { console.error('Usage analysis failed:', error); } return analysis; } /** * Sync missing keys between languages */ async syncMissingKeys(options = {}) { const result = { success: false, changes: {}, errors: [] }; try { const sourceLanguage = options.sourceLanguage || this.config.defaultLanguage || 'en'; const targetLanguages = options.targetLanguages || this.config.languages?.filter(lang => lang !== sourceLanguage) || []; const namespaces = options.namespaces || this.config.namespaces || ['translation']; const placeholder = options.placeholder || '{{TRANSLATE_ME}}'; // First scan to get current state const scanResult = await this.scanCodebase(); if (!scanResult.success) { throw new Error('Failed to scan codebase for sync operation'); } for (const ns of namespaces) { const sourceFilePath = path.join(this.config.localesPath, sourceLanguage, `${ns}.json`); try { const sourceData = await this.readJSONFile(sourceFilePath); if (!sourceData) { result.errors.push(`Source file not found: ${sourceFilePath}`); continue; } const sourceKeys = this.flattenKeys(sourceData); for (const targetLang of targetLanguages) { const targetFilePath = path.join(this.config.localesPath, targetLang, `${ns}.json`); try { let targetData = await this.readJSONFile(targetFilePath) || {}; const targetKeys = this.flattenKeys(targetData); const missingKeys = sourceKeys.filter(key => !targetKeys.includes(key)); if (missingKeys.length > 0) { // Add missing keys with placeholder values for (const key of missingKeys) { const sourceValue = this.getNestedValue(sourceData, key); if (sourceValue !== undefined) { this.setNestedValue(targetData, key, placeholder); } } if (!options.dryRun) { // Ensure directory exists await fs.mkdir(path.dirname(targetFilePath), { recursive: true }); await this.writeJSONFile(targetFilePath, targetData); } result.changes[`${targetLang}/${ns}`] = missingKeys.length; } } catch (error) { result.errors.push(`Failed to process ${targetLang}/${ns}: ${error instanceof Error ? error.message : String(error)}`); } } } catch (error) { result.errors.push(`Failed to read source ${sourceLanguage}/${ns}: ${error instanceof Error ? error.message : String(error)}`); } } result.success = result.errors.length === 0; } catch (error) { result.errors.push(error instanceof Error ? error.message : String(error)); } return result; } parseExtractedKeys(outputLines) { const keys = []; for (const line of outputLines) { const trimmedLine = line.trim(); // Look for common patterns in scanner output // Pattern 1: "Found key: keyName" if (trimmedLine.startsWith('Found key:')) { const key = trimmedLine.replace('Found key:', '').trim(); if (key) keys.push(key); } // Pattern 2: "- keyName" (list format) if (trimmedLine.startsWith('- ') && !trimmedLine.includes('.tsx') && !trimmedLine.includes('.ts') && !trimmedLine.includes('.js')) { const key = trimmedLine.replace('- ', '').trim(); if (key && !key.includes('/')) keys.push(key); } // Pattern 3: JSON-like format "keyName": "value" const jsonMatch = trimmedLine.match(/^"([^"]+)":\s*"[^"]*"$/); if (jsonMatch) { keys.push(jsonMatch[1]); } // Pattern 4: Simple key extraction from t('key') patterns const tFunctionMatch = trimmedLine.match(/t\(['"`]([^'"`]+)['"`]\)/g); if (tFunctionMatch) { for (const match of tFunctionMatch) { const keyMatch = match.match(/t\(['"`]([^'"`]+)['"`]\)/); if (keyMatch) { keys.push(keyMatch[1]); } } } } // Remove duplicates and empty keys return [...new Set(keys.filter(key => key && key.length > 0))]; } parseAffectedFiles(outputLines) { const files = []; for (const line of outputLines) { const trimmedLine = line.trim(); // Look for file paths in scanner output if (trimmedLine.includes('.tsx') || trimmedLine.includes('.ts') || trimmedLine.includes('.js') || trimmedLine.includes('.jsx')) { // Pattern 1: "Processing file: path/to/file.tsx" if (trimmedLine.startsWith('Processing file:')) { const file = trimmedLine.replace('Processing file:', '').trim(); if (file) files.push(file); } // Pattern 2: "- path/to/file.tsx" if (trimmedLine.startsWith('- ') && (trimmedLine.includes('/') || trimmedLine.includes('\\'))) { const file = trimmedLine.replace('- ', '').trim(); if (file) files.push(file); } // Pattern 3: Just a file path on its own line if (trimmedLine.match(/^[^\s]+\.(tsx?|jsx?|vue)$/)) { files.push(trimmedLine); } } } // Remove duplicates return [...new Set(files)]; } async readJSONFile(filePath) { try { const content = await fs.readFile(filePath, 'utf-8'); return JSON.parse(content); } catch (error) { return null; } } async writeJSONFile(filePath, data) { await fs.writeFile(filePath, JSON.stringify(data, null, 2), 'utf-8'); } flattenKeys(obj, prefix = '') { const keys = []; for (const key in obj) { const fullKey = prefix ? `${prefix}${this.config.keySeparator || '.'}${key}` : key; if (typeof obj[key] === 'object' && obj[key] !== null && !Array.isArray(obj[key])) { keys.push(...this.flattenKeys(obj[key], fullKey)); } else { keys.push(fullKey); } } return keys; } getNestedValue(obj, path) { const keys = path.split(this.config.keySeparator || '.'); let current = obj; for (const key of keys) { if (current && typeof current === 'object' && key in current) { current = current[key]; } else { return undefined; } } return current; } setNestedValue(obj, path, value) { const keys = path.split(this.config.keySeparator || '.'); let current = obj; for (let i = 0; i < keys.length - 1; i++) { const key = keys[i]; if (!(key in current) || typeof current[key] !== 'object') { current[key] = {}; } current = current[key]; } current[keys[keys.length - 1]] = value; } async cleanupTempFiles(dirPath) { try { await fs.rm(dirPath, { recursive: true, force: true }); } catch (error) { // Ignore cleanup errors console.warn('Failed to cleanup temp files:', error instanceof Error ? error.message : String(error)); } } } //# sourceMappingURL=scanner-integration.js.map