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.
170 lines • 8.98 kB
JavaScript
;
var __importDefault = (this && this.__importDefault) || function (mod) {
return (mod && mod.__esModule) ? mod : { "default": mod };
};
Object.defineProperty(exports, "__esModule", { value: true });
exports.default = buildCheckCommand;
const constants_1 = require("./constants");
const commander_1 = require("commander");
const check_1 = require("./check");
const registry_1 = require("./formats/registry");
const utils_1 = require("./utils");
const cli_helpers_1 = require("./cli_helpers");
const chat_pool_1 = __importDefault(require("./chat_pool"));
const rate_limiter_1 = __importDefault(require("./rate_limiter"));
const fs_1 = __importDefault(require("fs"));
const path_1 = __importDefault(require("path"));
/**
* Build the `check` subcommand: runs the verification pipeline against
* existing translations without writing anything and prints a report.
* Exits non-zero when any issue is reported so CI can gate on it.
* @returns the commander Command
*/
function buildCheckCommand() {
return new commander_1.Command("check")
.requiredOption("-i, --input <input>", "Source i18n file, in the jsons/ directory if a relative path is given")
.option("-o, --target-languages [language codes...]", "Language codes to check; if omitted, every sibling JSON file in the source's directory is checked")
.requiredOption("-e, --engine <engine>", constants_1.CLI_HELP.Engine)
.option("-m, --model <model>", constants_1.CLI_HELP.Model)
.option("-r, --rate-limit-ms <rateLimitMs>", constants_1.CLI_HELP.RateLimit)
.option("-k, --api-key <API key>", "API key")
.option("-h, --host <hostIP:port>", constants_1.CLI_HELP.OllamaHost)
.option("-p, --templated-string-prefix <prefix>", "Prefix for templated strings", constants_1.DEFAULT_TEMPLATED_STRING_PREFIX)
.option("-s, --templated-string-suffix <suffix>", "Suffix for templated strings", constants_1.DEFAULT_TEMPLATED_STRING_SUFFIX)
.option("-n, --batch-size <batchSize>", constants_1.CLI_HELP.BatchSize)
.option("--override-prompt <path to JSON file>", constants_1.CLI_HELP.OverridePromptFile)
.option("--verbose", constants_1.CLI_HELP.Verbose, false)
.option("--prompt-mode <prompt-mode>", constants_1.CLI_HELP.PromptMode)
.option("--batch-max-tokens <batch-max-tokens>", constants_1.CLI_HELP.MaxTokens)
.option("--concurrency <concurrency>", constants_1.CLI_HELP.Concurrency)
.option("--context <context>", constants_1.CLI_HELP.Context)
.option("--tokens-per-minute <tpm>", constants_1.CLI_HELP.TokensPerMinute)
.option("--format <format>", "Output format: 'table' (default, human-readable) or 'json' (for CI consumption)", "table")
.option("--file-format <format>", constants_1.CLI_HELP.FileFormat)
.action(async (options) => {
const modelArgs = (0, cli_helpers_1.processModelArgs)(options);
// Share one pool + limiter across every target file we
// check, so the RPM/TPM budgets are honoured across the
// batch instead of being reset per target.
const sharedRateLimiter = new rate_limiter_1.default(modelArgs.rateLimitMs, Boolean(options.verbose), modelArgs.tokensPerMinute);
const sharedPool = chat_pool_1.default.create({
apiKey: modelArgs.apiKey,
chatParams: modelArgs.chatParams,
concurrency: Math.max(1, modelArgs.concurrency),
engine: options.engine,
host: modelArgs.host,
model: modelArgs.model,
rateLimiter: sharedRateLimiter,
});
let overridePrompt;
if (options.overridePrompt) {
overridePrompt = (0, cli_helpers_1.processOverridePromptFile)(options.overridePrompt);
}
const inputPath = (0, utils_1.resolveInputPath)(options.input);
if (!fs_1.default.existsSync(inputPath) || !fs_1.default.statSync(inputPath).isFile()) {
(0, utils_1.printError)(`Source file not found: ${inputPath}`);
process.exit(2);
}
// Resolve the format adapter from --file-format, else infer
// it from the source extension (JSON by default).
const adapter = options.fileFormat
? (0, registry_1.getAdapterByName)(options.fileFormat)
: (0, registry_1.getAdapterForFile)(inputPath);
if (!adapter) {
(0, utils_1.printError)(`Unknown format: ${options.fileFormat}`);
process.exit(2);
}
const inputLanguageCode = (0, utils_1.getLanguageCodeFromFilename)(inputPath);
const sourceFlat = adapter.read(fs_1.default.readFileSync(inputPath, "utf-8")).flat;
// Determine which target files to check. Siblings are matched
// by the adapter's extension(s), not a hardcoded .json.
const sourceDir = path_1.default.dirname(inputPath);
const inputBase = path_1.default.basename(inputPath);
const targetExtension = adapter.extensions[0];
const matchesFormat = (file) => adapter.extensions.some((ext) => file.toLowerCase().endsWith(ext.toLowerCase()));
let targetFiles;
if (options.targetLanguages && options.targetLanguages.length > 0) {
targetFiles = options.targetLanguages.map((code) => path_1.default.join(sourceDir, `${code}${targetExtension}`));
}
else {
targetFiles = fs_1.default
.readdirSync(sourceDir)
.filter((f) => matchesFormat(f) && f !== inputBase)
.map((f) => path_1.default.join(sourceDir, f));
}
if (targetFiles.length === 0) {
(0, utils_1.printWarn)("No target files to check.");
return;
}
const allReports = [];
let hasIssues = false;
for (const targetFile of targetFiles) {
if (!fs_1.default.existsSync(targetFile)) {
(0, utils_1.printWarn)(`Skipping missing target file: ${targetFile}`);
continue;
}
const outputLanguageCode = (0, utils_1.getLanguageCodeFromFilename)(path_1.default.basename(targetFile));
let targetFlat;
try {
const raw = fs_1.default.readFileSync(targetFile, "utf-8");
// Formats that separate source from target (PO:
// msgstr vs msgid) expose the translated values via
// readTranslated, keyed identically to the source.
targetFlat = adapter.readTranslated
? adapter.readTranslated(raw).flat
: adapter.read(raw).flat;
}
catch (e) {
(0, utils_1.printError)(`Skipping unreadable target ${targetFile}: ${e}`);
continue;
}
if (options.verbose) {
(0, utils_1.printInfo)(`\nChecking ${outputLanguageCode} (${path_1.default.basename(targetFile)})...`);
}
// eslint-disable-next-line no-await-in-loop
const report = await (0, check_1.check)({
...modelArgs,
context: options.context,
engine: options.engine,
inputJSON: sourceFlat,
inputLanguageCode,
outputLanguageCode,
overridePrompt,
pool: sharedPool,
rateLimiter: sharedRateLimiter,
targetJSON: targetFlat,
templatedStringPrefix: options.templatedStringPrefix,
templatedStringSuffix: options.templatedStringSuffix,
verbose: options.verbose,
});
allReports.push(report);
if (report.issues.length > 0)
hasIssues = true;
}
if (options.format === "json") {
// eslint-disable-next-line no-console
console.log(JSON.stringify(allReports, null, 2));
}
else {
for (const report of allReports) {
if (report.issues.length === 0) {
(0, utils_1.printInfo)(`\n${report.languageCode}: no issues found (${report.totalKeys} keys checked)`);
continue;
}
(0, utils_1.printError)(`\n${report.languageCode}: ${report.issues.length} issue(s) found`);
for (const issue of report.issues) {
(0, utils_1.printError)(` ${issue.key}:`);
(0, utils_1.printError)(` original: ${issue.original}`);
(0, utils_1.printError)(` translated: ${issue.translated}`);
(0, utils_1.printError)(` issue: ${issue.issue}`);
if (issue.suggestion) {
(0, utils_1.printError)(` suggestion: ${issue.suggestion}`);
}
}
}
}
if (hasIssues)
process.exit(1);
});
}
//# sourceMappingURL=cli_check.js.map