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