UNPKG

locale-wizard

Version:
581 lines (559 loc) 16.8 kB
#!/usr/bin/env node var __defProp = Object.defineProperty; var __defNormalProp = (obj, key, value) => key in obj ? __defProp(obj, key, { enumerable: true, configurable: true, writable: true, value }) : obj[key] = value; var __publicField = (obj, key, value) => __defNormalProp(obj, typeof key !== "symbol" ? key + "" : key, value); // src/cli.ts import fs2 from "node:fs"; import path3 from "node:path"; import * as process3 from "node:process"; import { program } from "commander"; // src/index.ts import path2 from "node:path"; import * as process2 from "node:process"; import OpenAI from "openai"; // src/logger/index.ts var reset = "\x1B[0m"; var logger = { log: (text) => console.log("\x1B[37m" /* FgWhite */ + text + reset), green: (text) => console.log("\x1B[32m" /* FgGreen */ + text + reset), red: (text) => console.log("\x1B[31m" /* FgRed */ + text + reset), blue: (text) => console.log("\x1B[34m" /* FgBlue */ + text + reset), yellow: (text) => console.log("\x1B[33m" /* FgYellow */ + text + reset) }; // src/translation/translate.ts import * as process from "node:process"; // src/types/public/index.ts var localesNames = { en: "English", ru: "Russian", es: "Spanish", zh: "Chinese", it: "Italian", ar: "Arabic", de: "German", fr: "French", pt: "Portuguese", hi: "Hindi", ja: "Japanese", ko: "Korean", tr: "Turkish", nl: "Dutch", sv: "Swedish", da: "Danish", no: "Norwegian", fi: "Finnish", pl: "Polish", cs: "Czech", sr: "Serbian", bg: "Bulgarian", hr: "Croatian", el: "Greek", he: "Hebrew", hu: "Hungarian", id: "Indonesian", ms: "Malay", ro: "Romanian", sk: "Slovak", sl: "Slovenian", th: "Thai", vi: "Vietnamese", fa: "Persian", ur: "Urdu", bn: "Bengali", ta: "Tamil", te: "Telugu", ml: "Malayalam", kn: "Kannada", mr: "Marathi", gu: "Gujarati", ka: "Georgian", az: "Azerbaijani", be: "Belarusian", hy: "Armenian", et: "Estonian", lv: "Latvian", lt: "Lithuanian", af: "Afrikaans", sq: "Albanian", am: "Amharic", eu: "Basque", my: "Burmese", ca: "Catalan", km: "Khmer", ky: "Kyrgyz", lo: "Lao", mk: "Macedonian", mn: "Mongolian", ne: "Nepali", pa: "Punjabi", si: "Sinhala", tg: "Tajik", tk: "Turkmen", uz: "Uzbek", cy: "Welsh", yi: "Yiddish", zu: "Zulu", sw: "Swahili", so: "Somali", ha: "Hausa", ig: "Igbo", yo: "Yoruba", gl: "Galician", is: "Icelandic", lb: "Luxembourgish", mt: "Maltese", ps: "Pashto" }; // src/translation/get-prompt.ts var getPrompt = (targetLocale) => { const localeString = localesNames[targetLocale]; return `You will be given a JSON file in the format {[key]: [value]} Translate all values to ${localeString} language and return the completed JSON with translated values Try to understand the context for more accurate translation. Make all the efforts to understand correct plural forms of words(keep in mind that some languages have multiple plural forms (like in Russian: "one window", "two windows", "five windows" becomes "\u043E\u0434\u043D\u043E \u043E\u043A\u041D\u041E", "\u0434\u0432\u0430 \u043E\u043A\u041D\u0410", "\u043F\u044F\u0442\u044C \u043E\u043A\u041E\u041D")) Most important: Return ONLY the JSON in your response. No extra words or explanations `; }; // src/utils/compare-keys.ts var areKeysTheSame = (object1, object2) => { const keys1 = Object.keys(object1).sort(); const keys2 = Object.keys(object2).sort(); return JSON.stringify(keys1) === JSON.stringify(keys2); }; // src/utils/sleep.ts var sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms)); // src/utils/split-record.ts function splitRecord(record, maxKeys = 50) { const entries = Object.entries(record); if (entries.length <= maxKeys) { return [record]; } const numberObjects = Math.ceil(entries.length / maxKeys); const result = []; for (let index = 0; index < numberObjects; index++) { const start = index * maxKeys; const chunk = entries.slice(start, start + maxKeys); result.push(Object.fromEntries(chunk)); } return result; } // src/translation/translate.ts async function translateLocaleKeys(keyValuePairs, targetLocale) { if (!this.openai) { logger.red(`openai is not initialized`); return process.exit(); } const chunks = splitRecord(keyValuePairs); let result = {}; let tokenCost = 0; if (Object.keys(keyValuePairs).length === 0) { return result; } logger.blue(`Translating "${targetLocale}" locale`); for (const chunk of chunks) { const translatedChunk = await translateChunk.call( this, chunk, targetLocale ); result = { ...result, ...translatedChunk.translated }; tokenCost += translatedChunk.cost || 0; } logger.green( `Locale "${targetLocale}" successfully translated!!! Cost: ${tokenCost} tokens ` ); return result; } async function translateChunk(chunk, targetLocale, isRetry) { if (!this.openai) { logger.red(`openai is not initialized`); process.exit(); } const prompt = this.customPrompt ? this.customPrompt(targetLocale) : getPrompt(targetLocale); try { const completion = await this.openai.chat.completions.create({ messages: [ { role: "system", content: prompt }, { role: "user", content: JSON.stringify(chunk) } ], model: this.chatGptModel, response_format: { type: "json_object" } }); const responseText = completion.choices[0].message.content; if (typeof responseText !== "string") { throw new TypeError(`Invalid ChatGPT response`); } const cost = completion.usage?.total_tokens; const result = JSON.parse(responseText); const isTheSame = areKeysTheSame(chunk, result); if (isTheSame) { await sleep(500); return { translated: result, cost }; } else { if (isRetry) { logger.red(`Failed to retry translation "${targetLocale}" locale.`); process.exit(); throw new Error(`Failed to translate "${targetLocale}" locale`); } else { logger.yellow( `Failed to translate "${targetLocale}" locale. Retrying...` ); const retry = await translateChunk.call( this, chunk, targetLocale, true ); logger.green( `Successfully retried translation of "${targetLocale}" locale!` ); return retry; } } } catch (error) { throw error; } } // src/utils/delete-nested-key.ts function deleteNestedKey(object, path4) { const keys = path4.split("."); function deleteRecursive(current, index) { if (index >= keys.length) return; const key = keys[index]; if (index === keys.length - 1) { delete current[key]; } else { if (current[key]) { deleteRecursive(current[key], index + 1); } } if (Object.keys(current[key] || {}).length === 0) { delete current[key]; } } deleteRecursive(object, 0); return object; } // src/utils/files.ts import fs from "node:fs"; import path from "node:path"; var getLocaleNamespaces = (directory, locale, ignoredNamespaces) => { try { return fs.readdirSync(`${directory}/${locale}`).filter((file) => !ignoredNamespaces.includes(file.split(".json")[0])); } catch { return []; } }; var getJsonFromFile = (filePath) => { try { const fileContent = fs.readFileSync(filePath, "utf8"); return JSON.parse(fileContent); } catch { return {}; } }; var writeJsonToFile = (filePath, data) => { try { const absolutePath = path.resolve(filePath); const directory = path.dirname(absolutePath); if (!fs.existsSync(directory)) { fs.mkdirSync(directory, { recursive: true }); } const jsonContent = JSON.stringify(data, null, 2); fs.writeFileSync(absolutePath, jsonContent, "utf8"); return true; } catch (error) { logger.red(`Error writing file ${filePath}: ${error.message}`); logger.red(`Stack: ${error.stack}`); return false; } }; // src/utils/flaten-to-nested.ts var flattenToNested = (object) => { const result = {}; console.log("flaten", object); for (const key in object) { const parts = key.split("."); if (parts.length === 1) { result[key] = object[key]; } else { const parentKey = parts[0]; const childKey = parts[1]; const isArrayIndex = /^\d+$/.test(childKey); if (isArrayIndex) { if (!result[parentKey]) { result[parentKey] = []; } const index = Number.parseInt(childKey, 10); result[parentKey][index] = object[key]; } else { if (!result[parentKey]) { result[parentKey] = {}; } if (!(childKey in result[parentKey])) { result[parentKey][childKey] = object[key]; } } } } console.log("nested", result); return result; }; // src/utils/get-all-json-keys.ts function getAllJsonKeys(object, currentPath = "") { let paths = []; for (const key in object) { const newPath = currentPath ? `${currentPath}.${key}` : key; if (typeof object[key] === "object" && object[key] !== null && !Array.isArray(object[key])) { paths = [...paths, ...getAllJsonKeys(object[key], newPath)]; } else { paths.push(newPath); } } return paths.map((s) => s.replace(":.", ":")); } // src/utils/get-value-by-path.ts var getValueByPath = (object, path4) => { const keys = path4.split("."); let result = object; for (const key of keys) { if (result === void 0 || result === null) { return void 0; } result = result[key]; } return result; }; // src/utils/get-all-locale-key-values.ts function getAllLocaleKeyValues(path4, locale, ignoredNamespaces) { const files = getLocaleNamespaces(path4, locale, ignoredNamespaces); let allKeys = {}; for (const fileName of files) { const filePath = `${path4}/${locale}/${fileName}`; const nameSpace = fileName.split(".json")[0]; const fileObject = getJsonFromFile(filePath); const keys = getAllJsonKeys(fileObject); for (const key of keys) { const value = getValueByPath(fileObject, key); allKeys[nameSpace + ":" + key] = value || ""; } } return allKeys; } // src/utils/get-name-spaces.ts var getNameSpaces = (keyValuePairs) => { const namespaces = {}; for (const [fullKey, value] of Object.entries(keyValuePairs)) { const [namespace, key] = fullKey.split(":"); namespaces[namespace] = { ...namespaces[namespace], [key]: value }; } return namespaces; }; // src/utils/process-locale.ts function processLocale(locale) { const missingKeys = []; const extraKeys = []; const keyValuePairs = getAllLocaleKeyValues( this.localesPath, locale, this.ignoreNamespaces ); for (const key of Object.keys(keyValuePairs)) { const existsInMainLocale = typeof this.allMainLocaleKeysValuePairs[key] === "string" || Array.isArray(this.allMainLocaleKeysValuePairs[key]); if (!existsInMainLocale) { extraKeys.push(key); } } for (const key of Object.keys(this.allMainLocaleKeysValuePairs)) { const existsInTargetLocale = typeof keyValuePairs[key] === "string" || Array.isArray(keyValuePairs[key]); if (!existsInTargetLocale) { missingKeys.push(key); } } logger.blue(`Locale "${locale}":`); if (missingKeys.length > 0) { for (const key of missingKeys) { logger.red(`Missing key: "${key}"`); } } else { logger.green(`No missing keys \u2705 `); } if (extraKeys.length > 0) { for (const key of extraKeys) { logger.red(`Extra key: "${key}"`); } } else { logger.green(`No extra keys \u2705 `); } this.targetLocalesMeta[locale] = { missingKeys, extraKeys }; } // src/index.ts var LocaleWizard = class { constructor(config) { this.config = config; __publicField(this, "openai", null); __publicField(this, "chatGptModel", "gpt-4o"); __publicField(this, "targetLocales", []); __publicField(this, "localesPath", ""); __publicField(this, "ignoreNamespaces", []); __publicField(this, "customPrompt", null); __publicField(this, "allMainLocaleKeysValuePairs", {}); __publicField(this, "targetLocalesMeta", {}); __publicField(this, "mainLocaleFiles", []); this.targetLocales = config.targetLocales; this.localesPath = path2.resolve(process2.cwd(), config.localesPath); this.ignoreNamespaces = config.ignoreNamespaces || []; const mainLocaleFiles = getLocaleNamespaces( config.localesPath, config.sourceLocale, this.ignoreNamespaces ); if (mainLocaleFiles.length === 0) { logger.red( `No locale files for source locale "${config.sourceLocale}" found at "${this.localesPath}/${config.sourceLocale}"` ); process2.exit(); } this.mainLocaleFiles = mainLocaleFiles; this.allMainLocaleKeysValuePairs = getAllLocaleKeyValues( this.localesPath, config.sourceLocale, this.ignoreNamespaces ); for (const locale of config.targetLocales) { processLocale.call(this, locale); } if (config.openAiKey) { this.openai = new OpenAI({ apiKey: config.openAiKey }); } if (config.chatGptModel) { this.chatGptModel = config.chatGptModel; } if (config.customPrompt) { this.customPrompt = config.customPrompt; } } async translate() { for (const [_locale, { missingKeys }] of Object.entries( this.targetLocalesMeta )) { const locale = _locale; const keyPairsToTranslate = missingKeys.reduce((accumulator, key) => { return { ...accumulator, [key]: this.allMainLocaleKeysValuePairs[key] }; }, {}); const translatedPairs = await translateLocaleKeys.call( this, keyPairsToTranslate, locale ); const namespaces = getNameSpaces(translatedPairs); for (const [namespace, pairs] of Object.entries(namespaces)) { if (this.ignoreNamespaces.includes(namespace)) { continue; } const namespacePath = `${this.localesPath}/${locale}/${namespace}.json`; const currentContent = getJsonFromFile(namespacePath); let newContent = flattenToNested({ ...currentContent, ...pairs }); writeJsonToFile(namespacePath, newContent); } } } async removeExtraKeys() { for (const [locale, { extraKeys }] of Object.entries( this.targetLocalesMeta )) { const files = getLocaleNamespaces( this.localesPath, locale, this.ignoreNamespaces ); for (const file of files) { const filePath = `${this.localesPath}/${locale}/${file}`; const content = getJsonFromFile(filePath); let newContent = {}; if (extraKeys.length > 0) { for (const key of extraKeys) { const [ns, ns_key] = key.split(":"); if (`${ns}.json` === file) { newContent = deleteNestedKey(content, ns_key); logger.log(`"${locale}/${file}" extra key "${ns_key}" removed`); } } writeJsonToFile(filePath, newContent); } } } } }; // src/cli.ts program.option("--translate", "Translate missing locale keys").option("--clean", "Remove extra keys").parse(); var options = program.opts(); var bootstrap = async () => { let config; let configPath; let rawConfig; try { configPath = path3.join(process3.cwd(), ".locale-wizard.json"); } catch { throw 1 /* NoConfigPath */; } try { rawConfig = fs2.readFileSync(configPath, "utf8"); } catch { throw 0 /* NoRawConfig */; } try { config = JSON.parse(rawConfig); } catch { throw 2 /* InvalidJsonConfig */; } let wizard; try { wizard = new LocaleWizard(config); } catch { throw 3 /* FailedToInit */; } if (options.translate) { try { await wizard.translate(); } catch { throw 4 /* FailedToTranslate */; } } if (options.clean) { try { await wizard.removeExtraKeys(); } catch { throw 5 /* FailedToClean */; } } }; bootstrap().catch((error) => { let message = "Unknown error"; if (error === 0 /* NoRawConfig */) { message = `File ".locale-wizard.json" not found`; } if (error === 2 /* InvalidJsonConfig */) { message = `File ".locale-wizard.json" should contain a valid JSON`; } if (error === 3 /* FailedToInit */) { message = `Unknown error occurred while initiating LocaleWizard instance`; } if (error === 4 /* FailedToTranslate */) { message = `An error occurred while translating`; } if (error === 5 /* FailedToClean */) { message = `An error occurred while removing extra keys`; } console.log("\x1B[31m " + message + "\x1B[0m"); process3.exit(); });