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
JavaScript
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