ai-i18n-translator
Version: 
AI-powered i18n auto-translation tool with pluggable extractors, translators, and organizers
596 lines (524 loc) ⢠19.9 kB
JavaScript
/**
 * 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;