UNPKG

i18next-mcp-server

Version:

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

314 lines 13.6 kB
import { exec } from 'node:child_process'; import { promisify } from 'node:util'; import fs from 'node:fs/promises'; import path from 'node:path'; const execAsync = promisify(exec); export class I18nextScannerIntegration { config; projectRoot; constructor(config) { this.config = config; this.projectRoot = '/home/genar/src/galleries'; // Set to the actual project root } /** * 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 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, 'apps/client/public/locales', lang, `${ns}.json`); const currentFilePath = path.join('apps/client/public/locales', 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, 'apps/client/public/locales', lang, `${ns}.json`); try { const extractedData = await this.readJSONFile(extractedFilePath); if (extractedData) { const keys = this.flattenKeys(extractedData); for (const key of keys) { usedKeys.add(key); keysByNamespace[ns].add(key); } } } catch { // File might not exist, that's okay } } } // Now check current translation files for orphaned keys const allExistingKeys = new Set(); for (const lang of languages) { for (const ns of namespaces) { const currentFilePath = path.join('apps/client/public/locales', lang, `${ns}.json`); try { const currentData = await this.readJSONFile(currentFilePath); if (currentData) { const keys = this.flattenKeys(currentData); for (const key of keys) { allExistingKeys.add(key); } } } catch { // File might not exist } } } // Find orphaned keys (exist in translation files but not used in code) analysis.orphanedKeys = Array.from(allExistingKeys).filter(key => !usedKeys.has(key)); analysis.totalUsedKeys = usedKeys.size; // Convert Sets to arrays for the result analysis.keysByNamespace = Object.fromEntries(Object.entries(keysByNamespace).map(([ns, keys]) => [ns, Array.from(keys)])); // Clean up temporary files await this.cleanupTempFiles('./temp-i18n-extract/'); } catch (error) { console.error('Usage analysis failed:', error); } return analysis; } /** * Synchronize missing keys across all languages */ async syncMissingKeys(options = {}) { const { sourceLanguage = 'en', targetLanguages = this.config.languages?.filter(lang => lang !== sourceLanguage) || ['es', 'ca'], namespaces = this.config.namespaces || ['translation'], placeholder = '', dryRun = false } = options; const result = { success: false, changes: {}, errors: [] }; try { // First scan to get latest keys const scanResult = await this.scanCodebase(); if (!scanResult.success) { throw new Error('Failed to scan codebase for latest keys'); } // Analyze missing keys const missingAnalysis = await this.analyzeMissingKeys(); // Group missing keys by target language and namespace for (const analysis of missingAnalysis) { if (!targetLanguages.includes(analysis.language)) continue; if (!namespaces.includes(analysis.namespace)) continue; if (analysis.missingKeys.length === 0) continue; const changeKey = `${analysis.language}/${analysis.namespace}`; result.changes[changeKey] = analysis.missingKeys.length; if (!dryRun) { // Read source language file to get values const sourceFilePath = path.join('apps/client/public/locales', sourceLanguage, `${analysis.namespace}.json`); const targetFilePath = path.join('apps/client/public/locales', analysis.language, `${analysis.namespace}.json`); try { const sourceData = await this.readJSONFile(sourceFilePath) || {}; const targetData = await this.readJSONFile(targetFilePath) || {}; // Add missing keys for (const missingKey of analysis.missingKeys) { const sourceValue = this.getNestedValue(sourceData, missingKey); const valueToSet = sourceValue || placeholder || missingKey; this.setNestedValue(targetData, missingKey, valueToSet); } // Write updated file await this.writeJSONFile(targetFilePath, targetData); console.log(`Updated ${changeKey}: added ${analysis.missingKeys.length} keys`); } catch (error) { const errorMsg = `Failed to sync ${changeKey}: ${error instanceof Error ? error.message : String(error)}`; result.errors.push(errorMsg); } } } result.success = result.errors.length === 0; } catch (error) { const errorMsg = error instanceof Error ? error.message : String(error); result.errors.push(errorMsg); } return result; } // Helper methods parseExtractedKeys(_outputLines) { // This is a simplified parser - in practice, you'd analyze the generated files const keys = []; // The actual implementation would read the generated JSON files return keys; } parseAffectedFiles(outputLines) { // Parse scanner output to find which files were processed return outputLines .filter(line => line.includes('.tsx') || line.includes('.ts')) .map(line => line.trim()) .filter(Boolean); } async readJSONFile(filePath) { try { const content = await fs.readFile(filePath, 'utf-8'); return JSON.parse(content); } catch { return null; } } async writeJSONFile(filePath, data) { await fs.mkdir(path.dirname(filePath), { recursive: true }); await fs.writeFile(filePath, `${JSON.stringify(data, null, 2)}\n`, 'utf-8'); } flattenKeys(obj, prefix = '') { const keys = []; for (const [key, value] of Object.entries(obj)) { const fullKey = prefix ? `${prefix}.${key}` : key; if (typeof value === 'object' && value !== null && !Array.isArray(value)) { keys.push(...this.flattenKeys(value, fullKey)); } else { keys.push(fullKey); } } return keys; } getNestedValue(obj, path) { return path.split('.').reduce((current, key) => current?.[key], obj); } setNestedValue(obj, path, value) { const keys = path.split('.'); const lastKey = keys.pop(); if (!lastKey) return; const target = keys.reduce((current, key) => { if (!(key in current)) { current[key] = {}; } return current[key]; }, obj); target[lastKey] = value; } async cleanupTempFiles(dirPath) { try { await fs.rm(dirPath, { recursive: true, force: true }); } catch { // Ignore cleanup errors } } } //# sourceMappingURL=scanner-integration.js.map