UNPKG

i18n-ai-translate

Version:

AI-powered localization CLI, Node library, and GitHub Action. Translate i18next JSON, Gettext PO, Java .properties, and iOS .strings with ChatGPT, Claude, Gemini, or local Ollama models.

307 lines 12.3 kB
"use strict"; var __importDefault = (this && this.__importDefault) || function (mod) { return (mod && mod.__esModule) ? mod : { "default": mod }; }; Object.defineProperty(exports, "__esModule", { value: true }); exports.DIRECTORY_KEY_DELIMITER = void 0; exports.delay = delay; exports.printError = printError; exports.printWarn = printWarn; exports.printInfo = printInfo; exports.retryJob = retryJob; exports.getLanguageCodeFromFilename = getLanguageCodeFromFilename; exports.getAllLanguageCodes = getAllLanguageCodes; exports.isValidLanguageCode = isValidLanguageCode; exports.getLanguageName = getLanguageName; exports.resolveLanguageCode = resolveLanguageCode; exports.getAllFilesInPath = getAllFilesInPath; exports.getTranslationDirectoryPath = getTranslationDirectoryPath; exports.getTranslationDirectoryKey = getTranslationDirectoryKey; exports.isNAK = isNAK; exports.isACK = isACK; exports.getMissingVariables = getMissingVariables; exports.getTemplatedStringRegex = getTemplatedStringRegex; exports.printExecutionTime = printExecutionTime; exports.printProgress = printProgress; exports.getOutputPathFromInputPath = getOutputPathFromInputPath; exports.resolveInputPath = resolveInputPath; exports.resolveOutputPath = resolveOutputPath; const iso_639_1_1 = __importDefault(require("iso-639-1")); const ansi_colors_1 = __importDefault(require("ansi-colors")); const fs_1 = __importDefault(require("fs")); const path_1 = __importDefault(require("path")); /** * @param delayDuration - time (in ms) to delay * @returns a promise that resolves after delayDuration */ function delay(delayDuration) { // eslint-disable-next-line no-promise-executor-return return new Promise((resolve) => setTimeout(resolve, delayDuration)); } /** * @param error - the error message */ function printError(error) { console.error(ansi_colors_1.default.redBright(error)); } /** * @param warn - the warning message */ function printWarn(warn) { console.warn(ansi_colors_1.default.yellowBright(warn)); } /** * @param info - the message */ function printInfo(info) { console.log(ansi_colors_1.default.cyanBright(info)); } /** * @param job - the function to retry * @param jobArgs - arguments to pass to job * @param maxRetries - retries of job before throwing * @param firstTry - whether this is the first try * @param delayDuration - time (in ms) before attempting job retry * @param sendError - whether to send a warning or error * @returns the result of job */ async function retryJob(job, jobArgs, maxRetries, firstTry, delayDuration, sendError = true) { if (!firstTry && delayDuration) { await delay(delayDuration); } return job(...jobArgs).catch((err) => { if (sendError) { printError(`err = ${err}`); } else { printWarn(`err = ${err}`); } if (maxRetries <= 0) { throw err; } return retryJob(job, jobArgs, maxRetries - 1, false, delayDuration); }); } /** * Extract the language code from a filename like `fr.json` or * `es-ES.json`. If the full prefix (e.g. `es-ES`) is not a valid * ISO-639-1 code, fall back to the portion before the first hyphen — * BCP-47 locale tags like `es-ES` / `pt-BR` / `zh-CN` are common in * i18next projects and should be accepted. If neither form is valid, * the raw prefix is returned so the caller can surface a clear error. * @param filename - the filename to get the language from * @returns the language code from the filename */ function getLanguageCodeFromFilename(filename) { const base = path_1.default.basename(filename); const [prefix] = base.split("."); if (iso_639_1_1.default.validate(prefix)) return prefix; const [baseTag] = prefix.split("-"); if (iso_639_1_1.default.validate(baseTag)) return baseTag; return prefix; } /** * @returns all language codes */ function getAllLanguageCodes() { return iso_639_1_1.default.getAllCodes(); } /** * @param languageCode - the language code to validate * @returns whether the language code is valid */ function isValidLanguageCode(languageCode) { return iso_639_1_1.default.validate(languageCode); } /** * Expand an ISO-639-1 code to its English display name (e.g. "en" → * "English"). Used in prompts because language names steer the LLM * much better than the two-letter code does. Falls back to the raw * code if the lookup fails so prompts never break. * @param languageCode - the ISO-639-1 code * @returns the English display name, or the raw code if unknown */ function getLanguageName(languageCode) { const name = iso_639_1_1.default.getName(languageCode); return name || languageCode; } /** * Accept both ISO-639-1 codes ("en") and English language names * ("English", "english", "ENGLISH") and normalise to the code. Returns * the input unchanged when no match is found so the caller's existing * validation can surface a clear error. * * This covers a common footgun flagged in BUG_REPORT.md and issue #5 — * users passed `-l English` based on older docs and got a cryptic * 'Invalid input language code: English' instead of a hint. * @param raw - the user-supplied language identifier * @returns the resolved ISO-639-1 code, or the raw input if unresolved */ function resolveLanguageCode(raw) { if (!raw) return raw; if (iso_639_1_1.default.validate(raw)) return raw; const normalized = raw.trim().toLowerCase(); for (const code of iso_639_1_1.default.getAllCodes()) { if (iso_639_1_1.default.getName(code).toLowerCase() === normalized) { return code; } } return raw; } /** * @param directory - the directory to list all files for * @returns all files with their absolute path that exist within the directory, recursively */ function getAllFilesInPath(directory) { const files = []; for (const fileOrDir of fs_1.default.readdirSync(directory)) { const fullPath = path_1.default.join(directory, fileOrDir); if (fs_1.default.lstatSync(fullPath).isDirectory()) { files.push(...getAllFilesInPath(fullPath)); } else { files.push(fullPath); } } return files; } /** * ASCII Unit Separator (0x1F). Used to join a file path with an i18n * key into a single compound key string. Chosen because no legal file * path on any platform can contain it — unlike `:`, which is a drive * letter separator on Windows and broke directory mode on that OS. */ exports.DIRECTORY_KEY_DELIMITER = "\x1f"; /** * Swap the input-language segment of a source file path for the output * language, normalising separators. This is the path half of * {@link getTranslationDirectoryKey}; the directory wrappers key * per-file adapter state (sidecar, chosen adapter) by it so the write * loop can recover that state after translation. Normalizing to forward * slashes keeps the language-segment match working on Windows (where * path.resolve returns backslash paths) and POSIX alike. * @param sourceFilePath - the source file's path * @param inputLanguageCode - the source language code * @param outputLanguageCode - the target language code (defaults to input) * @returns the output file path with forward slashes */ function getTranslationDirectoryPath(sourceFilePath, inputLanguageCode, outputLanguageCode) { const normalized = sourceFilePath.replace(/\\/g, "/"); return normalized.replace(`/${inputLanguageCode}/`, `/${outputLanguageCode ?? inputLanguageCode}/`); } /** * @param sourceFilePath - the source file's path * @param key - the key associated with the translation * @param inputLanguageCode - the language code of the source language * @param outputLanguageCode - the language code of the output language * @returns a key to use when translating a key from a directory; * swaps the input language code with the output language code */ function getTranslationDirectoryKey(sourceFilePath, key, inputLanguageCode, outputLanguageCode) { const outputPath = getTranslationDirectoryPath(sourceFilePath, inputLanguageCode, outputLanguageCode); return `${outputPath}${exports.DIRECTORY_KEY_DELIMITER}${key}`; } /** * @param response - the message from the LLM * @returns whether the response includes NAK */ function isNAK(response) { return response.includes("NAK") && !response.includes("ACK"); } /** * @param response - the message from the LLM * @returns whether the response only contains ACK and not NAK */ function isACK(response) { return response.includes("ACK") && !response.includes("NAK"); } /** * @param originalTemplateStrings - the template strings in the original text * @param translatedTemplateStrings - the template strings in the translated text * @returns the missing template string from the original */ function getMissingVariables(originalTemplateStrings, translatedTemplateStrings) { if (originalTemplateStrings.length === 0) return []; const translatedTemplateStringsSet = new Set(translatedTemplateStrings); const missingTemplateStrings = originalTemplateStrings.filter((originalTemplateString) => !translatedTemplateStringsSet.has(originalTemplateString)); return missingTemplateStrings; } /** * @param templatedStringPrefix - templated String Prefix * @param templatedStringSuffix - templated String Suffix * @returns the regex needed to get the templated Strings */ function getTemplatedStringRegex(templatedStringPrefix, templatedStringSuffix) { return new RegExp(`${templatedStringPrefix}[^{}]+${templatedStringSuffix}`, "g"); } /** * @param startTime - the startTime * @param prefix - the prefix of the Execution Time */ function printExecutionTime(startTime, prefix) { const endTime = Date.now(); const roundedSeconds = Math.round((endTime - startTime) / 1000); printInfo(`${prefix}${roundedSeconds} seconds\n`); } /** * @param title - the title * @param startTime - the startTime * @param totalItems - the totalItems * @param processedItems - the processedItems */ function printProgress(title, startTime, totalItems, processedItems) { const roundedEstimatedTimeLeftSeconds = Math.round((((Date.now() - startTime) / (processedItems + 1)) * (totalItems - processedItems)) / 1000); const percentage = ((processedItems / totalItems) * 100).toFixed(0); process.stdout.write(`\r${ansi_colors_1.default.blueBright(title)} | ${ansi_colors_1.default.greenBright(`Completed ${percentage}%`)} | ${ansi_colors_1.default.yellowBright(`ETA: ${roundedEstimatedTimeLeftSeconds}s`)}`); } /** * @param inputPath - the input path * @param outputLanguageCode - the output language code * @returns the output path based on the input path and output language code */ function getOutputPathFromInputPath(inputPath, outputLanguageCode) { const dir = path_1.default.dirname(inputPath); const filename = `${outputLanguageCode}${path_1.default.extname(inputPath)}`; return path_1.default.join(dir, filename); } /** * Legacy path-resolution convention: an absolute path resolves as-is, * a relative path is tried first under `./jsons/` and then under cwd. * @param input - the user-supplied path * @returns the resolved absolute path */ function resolveInputPath(input) { if (path_1.default.isAbsolute(input)) { return path_1.default.resolve(input); } const jsonFolder = path_1.default.resolve(process.cwd(), "jsons"); const underJsons = path_1.default.resolve(jsonFolder, input); if (fs_1.default.existsSync(underJsons)) { return underJsons; } return path_1.default.resolve(process.cwd(), input); } /** * For output paths — unlike input paths, the file doesn't exist yet, so * we decide based on whether the `./jsons/` directory is present. * @param output - the user-supplied output path * @returns the resolved absolute path */ function resolveOutputPath(output) { if (path_1.default.isAbsolute(output)) { return path_1.default.resolve(output); } const jsonFolder = path_1.default.resolve(process.cwd(), "jsons"); if (fs_1.default.existsSync(jsonFolder)) { return path_1.default.resolve(jsonFolder, output); } return path_1.default.resolve(process.cwd(), output); } //# sourceMappingURL=utils.js.map