UNPKG

@scoutello/i18n-magic

Version:

Intelligent CLI toolkit that automates internationalization workflows for JavaScript/TypeScript projects and auto-translates new string keys to other languages

318 lines • 15.3 kB
import fs from "node:fs"; import chalk from "chalk"; import cliProgress from "cli-progress"; import { languages } from "../lib/languges.js"; import { findExistingTranslations, loadLocalesFile, TranslationError, translateKey, writeLocalesFile, } from "../lib/utils.js"; const getLanguageLabel = (locale) => { const lang = languages.find((l) => l.value === locale); return lang?.label || locale; }; export const syncLocales = async (config) => { const { loadPath, savePath, defaultLocale, namespaces, locales, context, openai, } = config; const localesToProcess = locales.filter((l) => l !== defaultLocale); const BATCH_SIZE = 5; console.log(chalk.cyan("\nšŸ”„ Syncing translations\n")); console.log(chalk.dim(` Default locale: ${chalk.white(getLanguageLabel(defaultLocale))}`)); console.log(chalk.dim(` Namespaces: ${chalk.white(namespaces.join(", "))}`)); console.log(chalk.dim(` Target languages: ${chalk.white(localesToProcess.length)}`)); console.log(chalk.dim(` Concurrency: ${chalk.white(`${BATCH_SIZE} languages at a time`)}\n`)); // Track results for each locale const localeResults = {}; for (const locale of localesToProcess) { localeResults[locale] = { status: "pending", translated: 0, translationCompleted: 0, translationTotal: 0, reused: 0, }; } const progressBars = new cliProgress.MultiBar({ format: ` {label} {bar} {percentage}% | {value}/{total} | {duration_formatted} | {status}`, barCompleteChar: chalk.green("ā–ˆ"), barIncompleteChar: chalk.gray("ā–‘"), hideCursor: true, clearOnComplete: false, emptyOnZero: true, etaAsynchronousUpdate: true, barsize: 30, }, cliProgress.Presets.shades_classic); const localeProgressBar = progressBars.create(localesToProcess.length, 0, { label: "Languages ", status: "", }); const translationProgressBar = progressBars.create(0, 0, { label: "Translations", status: "finding keys to translate...", }); // Suppress console.log during progress bar display const originalConsoleLog = console.log; const suppressedLog = () => { }; try { // Helper to ensure a locale/namespace file exists before we add keys const ensureLocaleNamespaceFile = async (locale, namespace) => { if (typeof savePath === "string") { const filePath = savePath .replace("{{lng}}", locale) .replace("{{ns}}", namespace); if (!fs.existsSync(filePath)) { await writeLocalesFile(savePath, locale, namespace, {}); } } }; // Ensure all default-locale namespace files exist first await Promise.all(namespaces.map((namespace) => ensureLocaleNamespaceFile(defaultLocale, namespace))); // Process locales in parallel batches let completedCount = 0; const currentlyProcessing = new Set(); // Helper to update progress bars with currently processing languages const updateProgressDisplay = () => { const processingArray = Array.from(currentlyProcessing); const localeStatus = processingArray.length > 0 ? `processing ${processingArray.join(", ")}` : ""; localeProgressBar.update(completedCount, { label: "Languages ", status: localeStatus, }); const translationTotals = localesToProcess.reduce((totals, locale) => { const result = localeResults[locale]; totals.completed += result.translationCompleted; totals.total += result.translationTotal; return totals; }, { completed: 0, total: 0 }); const translationTotal = translationTotals.total; const translationCurrent = translationTotal > 0 ? Math.min(translationTotals.completed, translationTotal) : 0; const activeTranslations = localesToProcess.filter((locale) => { const result = localeResults[locale]; return (currentlyProcessing.has(locale) && result.translationTotal > 0 && result.translationCompleted < result.translationTotal); }); const translationStatus = activeTranslations.length > 0 ? `translating ${activeTranslations .map((locale) => { const result = localeResults[locale]; return `${locale} ${result.translationCompleted}/${result.translationTotal}`; }) .join(", ")}` : processingArray.length > 0 ? "waiting for translation work..." : translationTotal > 0 ? "translations complete" : "finding keys to translate..."; translationProgressBar.setTotal(translationTotal); translationProgressBar.update(translationCurrent, { label: "Translations", status: translationStatus, }); }; // Process a single locale const processLocale = async (locale) => { localeResults[locale].status = "processing"; currentlyProcessing.add(locale); updateProgressDisplay(); try { // Ensure all namespace files for this locale exist await Promise.all(namespaces.map((namespace) => ensureLocaleNamespaceFile(locale, namespace))); // Collect all missing keys for this locale across all namespaces const allMissingKeys = {}; const namespaceKeys = {}; // Load existing keys for all namespaces in parallel const namespaceResults = await Promise.all(namespaces.map(async (namespace) => { const defaultLocaleKeys = await loadLocalesFile(loadPath, defaultLocale, namespace, { silent: true }); const localeKeys = await loadLocalesFile(loadPath, locale, namespace, { silent: true }); return { namespace, defaultLocaleKeys, localeKeys, }; })); // Process results and collect missing keys for (const result of namespaceResults) { const { namespace, defaultLocaleKeys, localeKeys } = result; namespaceKeys[namespace] = localeKeys; for (const [key, value] of Object.entries(defaultLocaleKeys)) { if (!localeKeys[key]) { if (allMissingKeys[key]) { allMissingKeys[key].namespaces.push(namespace); } else { allMissingKeys[key] = { value, namespaces: [namespace], }; } } } } const missingKeysList = Object.keys(allMissingKeys); if (missingKeysList.length === 0) { localeResults[locale].status = "done"; currentlyProcessing.delete(locale); completedCount++; updateProgressDisplay(); return; } // Check for existing translations const keysToTranslate = {}; const existingTranslations = {}; const existingTranslationResults = await findExistingTranslations(missingKeysList, namespaces, locale, loadPath, { silent: true }); for (const key of missingKeysList) { const existingValue = existingTranslationResults[key]; if (existingValue !== null) { existingTranslations[key] = existingValue; localeResults[locale].reused++; } else { keysToTranslate[key] = allMissingKeys[key].value; } } let translatedValues = {}; // Translate if needed if (Object.keys(keysToTranslate).length > 0) { try { localeResults[locale].status = "translating"; localeResults[locale].translationCompleted = 0; localeResults[locale].translationTotal = Object.keys(keysToTranslate).length; updateProgressDisplay(); translatedValues = await translateKey({ inputLanguage: defaultLocale, outputLanguage: locale, context, object: keysToTranslate, openai, model: config.model, onProgress: (completed, total) => { localeResults[locale].translationCompleted = completed; localeResults[locale].translationTotal = total; updateProgressDisplay(); }, }); localeResults[locale].translationCompleted = Object.keys(keysToTranslate).length; localeResults[locale].translated = Object.keys(translatedValues).length; } catch (error) { localeResults[locale].status = "error"; localeResults[locale].error = error instanceof Error ? error.message : "Translation failed"; currentlyProcessing.delete(locale); completedCount++; updateProgressDisplay(); return; } } // Combine and save const allTranslations = { ...existingTranslations, ...translatedValues }; await Promise.all(namespaces.map(async (namespace) => { let hasChanges = false; const updatedKeys = { ...namespaceKeys[namespace] }; for (const key of missingKeysList) { if (allMissingKeys[key].namespaces.includes(namespace)) { const translation = allTranslations[key] || ""; updatedKeys[key] = translation; hasChanges = true; } } if (hasChanges) { await writeLocalesFile(savePath, locale, namespace, updatedKeys); } })); localeResults[locale].status = "done"; currentlyProcessing.delete(locale); completedCount++; updateProgressDisplay(); } catch (error) { localeResults[locale].status = "error"; localeResults[locale].error = error instanceof Error ? error.message : "Unknown error"; currentlyProcessing.delete(locale); completedCount++; updateProgressDisplay(); } }; // Suppress logs during processing console.log = suppressedLog; // Process locales in batches for (let i = 0; i < localesToProcess.length; i += BATCH_SIZE) { const batch = localesToProcess.slice(i, i + BATCH_SIZE); await Promise.all(batch.map((locale) => processLocale(locale))); } // Restore console.log console.log = originalConsoleLog; progressBars.stop(); // Print summary console.log(""); console.log(chalk.green("✨ Sync complete!\n")); // Count totals let totalTranslated = 0; let totalReused = 0; let totalErrors = 0; for (const locale of localesToProcess) { const result = localeResults[locale]; totalTranslated += result.translated; totalReused += result.reused; if (result.status === "error") totalErrors++; } // Show summary table console.log(chalk.dim(" Results by language:\n")); // Group results into columns for compact display const doneLocales = localesToProcess.filter((l) => localeResults[l].status === "done"); const errorLocales = localesToProcess.filter((l) => localeResults[l].status === "error"); // Show successful languages in a compact grid if (doneLocales.length > 0) { const columns = 3; const rows = Math.ceil(doneLocales.length / columns); for (let row = 0; row < rows; row++) { let line = " "; for (let col = 0; col < columns; col++) { const idx = row + col * rows; if (idx < doneLocales.length) { const locale = doneLocales[idx]; const result = localeResults[locale]; const label = getLanguageLabel(locale).substring(0, 14).padEnd(14); const count = result.translated > 0 ? chalk.cyan(`+${result.translated}`.padStart(5)) : chalk.dim(`${result.reused}`.padStart(5)); line += `${chalk.green("āœ“")} ${label}${count} `; } } console.log(line); } } // Show errors if any if (errorLocales.length > 0) { console.log(""); console.log(chalk.red(" Errors:")); for (const locale of errorLocales) { const result = localeResults[locale]; console.log(chalk.red(` āœ— ${getLanguageLabel(locale)}: ${result.error}`)); } } console.log(""); console.log(chalk.dim(" Summary:")); console.log(chalk.dim(` • ${chalk.white(totalTranslated)} keys translated`)); console.log(chalk.dim(` • ${chalk.white(totalReused)} keys reused`)); if (totalErrors > 0) { console.log(chalk.dim(` • ${chalk.red(totalErrors)} languages failed`)); } console.log(""); } catch (error) { // Restore console.log console.log = originalConsoleLog; progressBars.stop(); if (error instanceof TranslationError) { throw error; } throw new TranslationError("An unexpected error occurred during translation", undefined, undefined, error instanceof Error ? error : undefined); } }; //# sourceMappingURL=sync-locales.js.map