UNPKG

ai-i18n-translator

Version:

AI-powered i18n auto-translation tool with pluggable extractors, translators, and organizers

596 lines (524 loc) • 19.9 kB
#!/usr/bin/env node /** * Modular auto-translation script with pluggable extractors and translators * Usage: node auto-translate-refactored.js */ import fs from "fs"; import path from "path"; import T2Extractor from "./extractors/t2-extractor.js"; import ClaudeTranslator from "./translators/claude-translator.js"; import JSONOrganizer from "./organizers/json-organizer.js"; import NamespaceFileOrganizer from "./organizers/namespace-file-organizer.js"; import FolderNamespaceOrganizer from "./organizers/folder-namespace-organizer.js"; // Translation manager class class TranslationManager { constructor(config = {}, options = {}) { // Configuration will be loaded asynchronously this.providedConfig = config; this.options = options; } /** * Load configuration from file or use defaults */ async loadConfig(providedConfig) { let config = { ...providedConfig }; // Try multiple locations for config file - .mjs first to avoid warnings const configPaths = [ path.join(process.cwd(), "auto-translate.config.mjs"), path.join(process.cwd(), "auto-translate.config.cjs"), path.join(process.cwd(), "auto-translate.config.js"), path.join(process.cwd(), "i18n.config.mjs"), path.join(process.cwd(), "i18n.config.cjs"), path.join(process.cwd(), "i18n.config.js") ]; let configLoaded = false; for (const configPath of configPaths) { if (fs.existsSync(configPath)) { try { let fileConfig; // Handle .cjs files (CommonJS) if (configPath.endsWith('.cjs')) { // For .cjs files, we need to use createRequire const { createRequire } = await import('module'); const require = createRequire(import.meta.url); fileConfig = require(configPath); // Handle both module.exports and exports.default if (fileConfig.default) { fileConfig = fileConfig.default; } } else { // For .mjs and .js files, use dynamic import const fileUrl = new URL(`file://${configPath}`).href; const module = await import(fileUrl); fileConfig = module.default || module; } config = { ...fileConfig, ...config }; console.log(`šŸ“‹ Loaded configuration from ${path.basename(configPath)}`); configLoaded = true; break; } catch (error) { console.log(`āš ļø Error loading ${path.basename(configPath)}:`, error.message); } } } if (!configLoaded) { console.log("āš ļø No configuration file found, using defaults"); console.log(" Searched for: auto-translate.config.[mjs|cjs|js], i18n.config.[mjs|cjs|js]"); } // Apply defaults return { extensions: config.extensions || [".tsx"], excludedDirs: config.excludedDirs || [ "node_modules", ".git", ".next", "dist", "build" ], messagesDir: process.env.MESSAGES_DIR || process.argv[2] || config.messagesDir || "messages", supportedLocales: config.supportedLocales || ["en", "vi"], sourceLocale: config.sourceLocale || "en", batchSize: config.batchSize || 10, languageNames: config.languageNames || { en: "English", es: "Spanish", fr: "French", vi: "Vietnamese" }, ...config }; } /** * Load locale file - delegate to organizer */ loadLocaleFile(locale) { return this.organizer.loadLocale(locale); } /** * Save locale file - delegate to organizer */ saveLocaleFile(locale, data) { return this.organizer.saveLocale(locale, data); } /** * Get value from locale object - delegate to organizer */ getValue(obj, key, namespace = null) { return this.organizer.getValue(obj, key, namespace); } /** * Set value in locale object - delegate to organizer */ setValue(obj, key, value, namespace = null) { return this.organizer.setValue(obj, key, value, namespace); } /** * Process source locale */ async processSourceLocale(uniqueKeys) { console.log(`šŸ“ Processing ${this.sourceLocale} locale...`); const messages = this.loadLocaleFile(this.sourceLocale); let updated = false; const updatedPluralKeys = new Set(); // Check if translator is available for plural generation const hasTranslator = await this.translator.isAvailable(); for (const [compositeKey, keyData] of uniqueKeys.entries()) { const key = keyData.key; if (!this.getValue(messages, key, keyData.namespace)) { // Add base key this.setValue(messages, key, key, keyData.namespace); updated = true; const fullKey = keyData.namespace ? `${keyData.namespace}.${key}` : key; console.log(` + Added: "${fullKey}"`); } // Handle plural forms if (keyData.pluralOptions) { if (keyData.pluralOptions.auto) { // Check if we need to generate plurals const pluralFormsNeeded = [ "zero", "one", "two", "few", "many", "other" ]; const missingPlurals = pluralFormsNeeded.filter( (form) => !this.getValue(messages, `${key}_${form}`, keyData.namespace) ); if (missingPlurals.length > 0) { let pluralForms = null; if (hasTranslator) { try { pluralForms = await this.translator.generatePlurals( key, keyData.description ); console.log( ` ✨ Generated plural forms for "${key}" using ${this.translator.getName()}` ); } catch (error) { console.error( ` āš ļø Failed to generate plurals: ${error.message}` ); } } // Fallback to simple placeholders if (!pluralForms) { pluralForms = { zero: `${key} (0)`, one: `${key} (1)`, two: `${key} (2)`, few: `${key} (few)`, many: `${key} (many)`, other: `${key}` }; } Object.entries(pluralForms).forEach(([pluralForm, pluralText]) => { const pluralKey = `${key}_${pluralForm}`; if (!this.getValue(messages, pluralKey, keyData.namespace)) { this.setValue(messages, pluralKey, pluralText, keyData.namespace); updated = true; const fullKey = keyData.namespace ? `${keyData.namespace}.${pluralKey}` : pluralKey; console.log(` + Added auto-generated plural: "${fullKey}"`); updatedPluralKeys.add(keyData.namespace ? `${keyData.namespace}.${pluralKey}` : pluralKey); } }); } } else { // Use provided plural texts Object.entries(keyData.pluralOptions).forEach( ([pluralForm, pluralText]) => { if (pluralForm !== "key" && pluralForm !== "auto") { const pluralKey = `${key}_${pluralForm}`; const existingValue = this.getValue(messages, pluralKey, keyData.namespace); if (!existingValue) { this.setValue(messages, pluralKey, pluralText, keyData.namespace); updated = true; const fullKey = keyData.namespace ? `${keyData.namespace}.${pluralKey}` : pluralKey; console.log(` + Added plural: "${fullKey}"`); updatedPluralKeys.add(keyData.namespace ? `${keyData.namespace}.${pluralKey}` : pluralKey); } else if (existingValue !== pluralText) { this.setValue(messages, pluralKey, pluralText, keyData.namespace); updated = true; const fullKey = keyData.namespace ? `${keyData.namespace}.${pluralKey}` : pluralKey; console.log(` āœļø Updated plural: "${fullKey}"`); updatedPluralKeys.add(keyData.namespace ? `${keyData.namespace}.${pluralKey}` : pluralKey); } } } ); } } } if (updated) { this.saveLocaleFile(this.sourceLocale, messages); } return updatedPluralKeys; } /** * Process target locales */ async processTargetLocales(uniqueKeys, updatedPluralKeys) { const targetLocales = this.supportedLocales.filter( (l) => l !== this.sourceLocale ); const hasTranslator = await this.translator.isAvailable(); for (const locale of targetLocales) { console.log(`\nšŸŒ Processing ${locale} locale...`); const targetMessages = this.loadLocaleFile(locale); // Collect missing keys const missingKeysWithMetadata = []; uniqueKeys.forEach((keyData, compositeKey) => { const key = keyData.key; let needsUpdate = false; if (!this.getValue(targetMessages, key, keyData.namespace)) { needsUpdate = true; } // Check plural forms if (keyData.pluralOptions) { const forms = keyData.pluralOptions.auto ? ["zero", "one", "two", "few", "many", "other"] : Object.keys(keyData.pluralOptions).filter( (f) => f !== "key" && f !== "auto" ); forms.forEach((form) => { const pluralKey = `${key}_${form}`; const fullKey = keyData.namespace ? `${keyData.namespace}.${pluralKey}` : pluralKey; if ( !this.getValue(targetMessages, pluralKey, keyData.namespace) || updatedPluralKeys.has(fullKey) ) { needsUpdate = true; } }); } if (needsUpdate) { missingKeysWithMetadata.push({ key: key, description: keyData.description, pluralOptions: keyData.pluralOptions, namespace: keyData.namespace }); } }); if (missingKeysWithMetadata.length === 0) { console.log(` āœ… All texts already translated`); continue; } if (!hasTranslator) { // Fallback: copy source text this.copySourceTranslations( targetMessages, missingKeysWithMetadata, updatedPluralKeys ); } else { // Translate using the configured translator await this.translateWithProvider( targetMessages, missingKeysWithMetadata, locale, updatedPluralKeys ); } this.saveLocaleFile(locale, targetMessages); } } /** * Copy source translations as fallback */ copySourceTranslations( targetMessages, missingKeysWithMetadata, updatedPluralKeys ) { missingKeysWithMetadata.forEach((item) => { const fullKey = item.namespace ? `${item.namespace}.${item.key}` : item.key; if ( !this.getValue(targetMessages, item.key, item.namespace) || updatedPluralKeys.has(fullKey) ) { this.setValue(targetMessages, item.key, item.key, item.namespace); console.log(` + "${fullKey}": "${item.key}" (copied)`); } if (item.pluralOptions) { if (item.pluralOptions.auto) { const pluralForms = { zero: `${item.key} (0)`, one: `${item.key} (1)`, two: `${item.key} (2)`, few: `${item.key} (few)`, many: `${item.key} (many)`, other: `${item.key}` }; Object.entries(pluralForms).forEach(([pluralForm, pluralText]) => { const pluralKey = `${item.key}_${pluralForm}`; const fullKey = item.namespace ? `${item.namespace}.${pluralKey}` : pluralKey; if ( !this.getValue(targetMessages, pluralKey, item.namespace) || updatedPluralKeys.has(fullKey) ) { this.setValue(targetMessages, pluralKey, pluralText, item.namespace); console.log(` + "${fullKey}": "${pluralText}" (copied)`); } }); } else { Object.entries(item.pluralOptions).forEach( ([pluralForm, pluralText]) => { if (pluralForm !== "key" && pluralForm !== "auto") { const pluralKey = `${item.key}_${pluralForm}`; const fullKey = item.namespace ? `${item.namespace}.${pluralKey}` : pluralKey; if ( !this.getValue(targetMessages, pluralKey, item.namespace) || updatedPluralKeys.has(fullKey) ) { this.setValue(targetMessages, pluralKey, pluralText, item.namespace); console.log(` + "${fullKey}": "${pluralText}" (copied)`); } } } ); } } }); } /** * Translate with configured provider */ async translateWithProvider( targetMessages, missingKeysWithMetadata, locale, updatedPluralKeys ) { // Determine the English context file path if using folder organizer let contextFilePath = null; if (this.organizer.getName() === 'FolderNamespaceOrganizer' && missingKeysWithMetadata.length > 0) { // Get the namespace from the first item const firstItem = missingKeysWithMetadata[0]; if (firstItem.namespace) { const path = require('path'); contextFilePath = path.join( process.cwd(), this.messagesDir, this.sourceLocale, `${firstItem.namespace}.json` ); console.log(` šŸ“– Using context file: ${contextFilePath}`); } } const translations = await this.translator.translateAll( missingKeysWithMetadata, this.sourceLocale, locale, (progress) => { console.log( ` šŸ“ Translating batch ${progress.current}/${progress.total}...` ); }, contextFilePath ); missingKeysWithMetadata.forEach((item) => { const translation = translations[item.key] || item.key; const fullKey = item.namespace ? `${item.namespace}.${item.key}` : item.key; if ( !this.getValue(targetMessages, item.key, item.namespace) || updatedPluralKeys.has(fullKey) ) { this.setValue(targetMessages, item.key, translation, item.namespace); console.log(` + "${fullKey}": "${translation}"`); } // Handle plural forms if (item.pluralOptions) { const forms = item.pluralOptions.auto ? ["zero", "one", "two", "few", "many", "other"] : Object.keys(item.pluralOptions).filter( (f) => f !== "key" && f !== "auto" ); forms.forEach((pluralForm) => { const pluralKey = `${item.key}_${pluralForm}`; const fullKey = item.namespace ? `${item.namespace}.${pluralKey}` : pluralKey; const pluralTranslation = translations[pluralKey] || translation; if ( !this.getValue(targetMessages, pluralKey, item.namespace) || updatedPluralKeys.has(fullKey) ) { this.setValue(targetMessages, pluralKey, pluralTranslation, item.namespace); console.log(` + "${fullKey}": "${pluralTranslation}"`); } }); } }); } /** * Create organizer instance from config */ createOrganizer(config) { // Check if organizer is already an object instance (backwards compatibility) if (config.organizer && typeof config.organizer === 'object' && typeof config.organizer !== 'string') { return config.organizer; } // Get organizer type from string or use default const organizerType = config.organizer || config.organizerType || 'json'; // Merge config with organizer options const organizerOptions = { ...config, ...config.organizerOptions }; // Create organizer based on type switch (organizerType.toLowerCase()) { case 'json': return new JSONOrganizer(organizerOptions); case 'namespace': case 'namespace-file': return new NamespaceFileOrganizer(organizerOptions); case 'folder': case 'folder-namespace': return new FolderNamespaceOrganizer(organizerOptions); default: console.log(`āš ļø Unknown organizer type: ${organizerType}, using default JSON organizer`); return new JSONOrganizer(organizerOptions); } } /** * Create extractor instance from config */ createExtractor(config) { // Check if extractor is already an object instance (backwards compatibility) if (config.extractor && typeof config.extractor === 'object' && typeof config.extractor !== 'string') { return config.extractor; } // Get extractor type from string or use default const extractorType = config.extractor || config.extractorType || 't2'; switch (extractorType.toLowerCase()) { case 't2': default: return new T2Extractor(config); } } /** * Create translator instance from config */ createTranslator(config) { // Check if translator is already an object instance (backwards compatibility) if (config.translator && typeof config.translator === 'object' && typeof config.translator !== 'string') { return config.translator; } // Get translator type from string or use default const translatorType = config.translator || config.translatorType || 'claude'; switch (translatorType.toLowerCase()) { case 'claude': default: return new ClaudeTranslator(config); } } /** * Main execution */ async run() { // Load configuration first this.config = await this.loadConfig(this.providedConfig); // Initialize components using factory methods this.extractor = this.createExtractor(this.config); this.translator = this.createTranslator(this.config); this.organizer = this.createOrganizer(this.config); // Set up paths this.messagesDir = this.config.messagesDir || "messages"; this.supportedLocales = this.config.supportedLocales || ["en", "vi"]; this.sourceLocale = this.config.sourceLocale || "en"; console.log("šŸš€ Auto-sync i18n translations..."); console.log(`šŸ“ Output directory: ${this.messagesDir}`); console.log(`šŸ” Using extractor: ${this.extractor.getName()}`); console.log(`🌐 Using translator: ${this.translator.getName()}`); console.log(`šŸ“‚ Using organizer: ${this.organizer.getName()}\n`); // Step 1: Extract keys console.log("šŸ“‹ Extracting translation keys..."); const projectPath = process.cwd(); const uniqueKeys = this.extractor.extractAllKeys(projectPath, this.options.targetFile); if (uniqueKeys.size === 0) { console.log("āœ… No translation keys found. Nothing to sync."); return; } console.log(`Found ${uniqueKeys.size} unique texts to translate\n`); // Step 2: Process source locale const updatedPluralKeys = await this.processSourceLocale(uniqueKeys); // Step 3: Process target locales await this.processTargetLocales(uniqueKeys, updatedPluralKeys); console.log("\n✨ Sync complete!"); const hasTranslator = await this.translator.isAvailable(); if (!hasTranslator) { console.log("\nšŸ’” Tip: Configure a translator for automatic translation"); } } } // Run if executed directly if (import.meta.url === `file://${process.argv[1]}`) { const manager = new TranslationManager(); manager.run().catch(console.error); } export default TranslationManager;