i18n-ai-translate
Version:
Use LLMs to translate your i18n JSON to any language.
509 lines • 24.6 kB
JavaScript
"use strict";
var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
if (k2 === undefined) k2 = k;
var desc = Object.getOwnPropertyDescriptor(m, k);
if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
desc = { enumerable: true, get: function() { return m[k]; } };
}
Object.defineProperty(o, k2, desc);
}) : (function(o, m, k, k2) {
if (k2 === undefined) k2 = k;
o[k2] = m[k];
}));
var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) {
Object.defineProperty(o, "default", { enumerable: true, value: v });
}) : function(o, v) {
o["default"] = v;
});
var __importStar = (this && this.__importStar) || (function () {
var ownKeys = function(o) {
ownKeys = Object.getOwnPropertyNames || function (o) {
var ar = [];
for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k;
return ar;
};
return ownKeys(o);
};
return function (mod) {
if (mod && mod.__esModule) return mod;
var result = {};
if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]);
__setModuleDefault(result, mod);
return result;
};
})();
var __importDefault = (this && this.__importDefault) || function (mod) {
return (mod && mod.__esModule) ? mod : { "default": mod };
};
Object.defineProperty(exports, "__esModule", { value: true });
const fs_1 = __importDefault(require("fs"));
const os_1 = __importDefault(require("os"));
const path_1 = __importDefault(require("path"));
const utils = __importStar(require("../utils"));
const translate_1 = require("../translate");
const translate_directory_1 = require("../translate_directory");
const translate_file_1 = require("../translate_file");
const engine_1 = __importDefault(require("../enums/engine"));
const prompt_mode_1 = __importDefault(require("../enums/prompt_mode"));
const rate_limiter_1 = __importDefault(require("../rate_limiter"));
const fr = (v) => `${v}_fr`;
const es = (v) => `${v}_es`;
const mkCaseDir = () => fs_1.default.mkdtempSync(path_1.default.join(os_1.default.tmpdir(), "i18n-case-"));
describe.each(Object.values(prompt_mode_1.default))("translate (promptMode=%s)", (promptMode) => {
it("translates a flat JSON object", async () => {
const result = await (0, translate_1.translate)({
engine: engine_1.default.ChatGPT,
inputJSON: { hello: "Hello" },
inputLanguage: "en",
model: "gpt-4o",
outputLanguage: "fr",
promptMode,
rateLimitMs: 0,
});
expect(result).toEqual({ hello: fr("Hello") });
});
it("translates a nested JSON object", async () => {
const result = await (0, translate_1.translate)({
engine: engine_1.default.ChatGPT,
inputJSON: { greeting: { text: "Hello" } },
inputLanguage: "en",
model: "gpt-4o",
outputLanguage: "fr",
promptMode,
rateLimitMs: 0,
});
expect(result).toEqual({ greeting: { text: fr("Hello") } });
});
it("de-duplicates identical strings and includes them all in output", async () => {
const input = { a: "Hello", b: "Hello", c: { d: "Hello" } };
const result = await (0, translate_1.translate)({
engine: engine_1.default.ChatGPT,
inputJSON: input,
inputLanguage: "en",
model: "gpt-4o",
outputLanguage: "fr",
promptMode,
rateLimitMs: 0,
});
expect(result).toEqual({ a: fr("Hello"), b: fr("Hello"), c: { d: fr("Hello") } });
});
});
describe.each(Object.values(prompt_mode_1.default))("translateDiff (promptMode=%s)", (promptMode) => {
it("only touches added / changed keys", async () => {
const before = { greeting: "Hello", unchanged: "Stay" };
const after = { added: "New", greeting: "Hi" };
const out = await (0, translate_1.translateDiff)({
engine: engine_1.default.ChatGPT,
inputJSONAfter: after,
inputJSONBefore: before,
inputLanguage: "en",
model: "gpt-4o",
promptMode,
rateLimitMs: 0,
toUpdateJSONs: {
fr: { greeting: "Bonjour", unchanged: "Rester" },
},
});
const frOut = out.fr;
expect(frOut).toEqual({ added: fr("New"), greeting: fr("Hi") });
});
it("only touches added / changed keys with nested objects", async () => {
const before = { greeting: { text: "Hello" }, unchanged: "Stay" };
const after = { added: "New", greeting: { text: "Hi" } };
const out = await (0, translate_1.translateDiff)({
engine: engine_1.default.ChatGPT,
inputJSONAfter: after,
inputJSONBefore: before,
inputLanguage: "en",
model: "gpt-4o",
promptMode,
rateLimitMs: 0,
toUpdateJSONs: {
fr: { greeting: { text: "Bonjour" }, unchanged: "Rester" },
},
});
const frOut = out.fr;
expect(frOut).toEqual({
added: fr("New"),
greeting: { text: fr("Hi") },
});
});
it("prunes removed keys", async () => {
const before = { greeting: "Hello", unused: "Unused" };
const after = { greeting: "Hi" };
const out = await (0, translate_1.translateDiff)({
engine: engine_1.default.ChatGPT,
inputJSONAfter: after,
inputJSONBefore: before,
inputLanguage: "en",
model: "gpt-4o",
promptMode,
rateLimitMs: 0,
toUpdateJSONs: {
fr: { greeting: "Bonjour", unused: "Obsolete" },
},
});
const frOut = out.fr;
expect(frOut).toEqual({ greeting: fr("Hi") }); // 'unused' pruned
});
it("handles empty input gracefully", async () => {
const out = await (0, translate_1.translateDiff)({
engine: engine_1.default.ChatGPT,
inputJSONAfter: {},
inputJSONBefore: {},
inputLanguage: "en",
model: "gpt-4o",
promptMode,
rateLimitMs: 0,
toUpdateJSONs: { fr: {} },
});
expect(out).toEqual({ fr: {} });
});
it("handles multiple languages", async () => {
const before = { greeting: "Hello" };
const after = { added: "New", greeting: "Hi" };
const out = await (0, translate_1.translateDiff)({
engine: engine_1.default.ChatGPT,
inputJSONAfter: after,
inputJSONBefore: before,
inputLanguage: "en",
model: "gpt-4o",
promptMode,
rateLimitMs: 0,
toUpdateJSONs: {
es: { greeting: "Hola" },
fr: { greeting: "Bonjour" },
},
});
expect(out.fr).toEqual({ added: fr("New"), greeting: fr("Hi") });
expect(out.es).toEqual({ added: es("New"), greeting: es("Hi") });
});
});
describe.each(Object.values(prompt_mode_1.default))("translateFile (promptMode=%s)", (promptMode) => {
it("creates a sibling file with translated JSON", async () => {
const dir = mkCaseDir();
const inputPath = path_1.default.join(dir, "en.json");
const outputPath = path_1.default.join(dir, "fr.json");
fs_1.default.writeFileSync(inputPath, JSON.stringify({ cat: "Cat" }));
await (0, translate_file_1.translateFile)({
engine: engine_1.default.ChatGPT,
forceLanguageName: "fr",
inputFilePath: inputPath,
inputLanguage: "en",
model: "gpt-4o",
outputFilePath: outputPath,
promptMode,
rateLimitMs: 0,
});
const translated = JSON.parse(fs_1.default.readFileSync(outputPath, "utf-8"));
expect(translated).toEqual({ cat: fr("Cat") });
});
it("handles empty input file gracefully", async () => {
const dir = mkCaseDir();
const inputPath = path_1.default.join(dir, "en.json");
const outputPath = path_1.default.join(dir, "fr.json");
fs_1.default.writeFileSync(inputPath, JSON.stringify({}));
await (0, translate_file_1.translateFile)({
engine: engine_1.default.ChatGPT,
forceLanguageName: "fr",
inputFilePath: inputPath,
inputLanguage: "en",
model: "gpt-4o",
outputFilePath: outputPath,
promptMode,
rateLimitMs: 0,
});
const translated = JSON.parse(fs_1.default.readFileSync(outputPath, "utf-8"));
expect(translated).toEqual({});
});
});
describe.each(Object.values(prompt_mode_1.default))("translateFileDiff (promptMode=%s)", (promptMode) => {
it("updates only the changed keys in-place", async () => {
const dir = mkCaseDir();
const beforePath = path_1.default.join(dir, "before_en.json");
const afterPath = path_1.default.join(dir, "after_en.json");
const frPath = path_1.default.join(dir, "fr.json");
fs_1.default.writeFileSync(beforePath, JSON.stringify({ key: "Old" }));
fs_1.default.writeFileSync(afterPath, JSON.stringify({ added: "Yes", key: "New" }));
fs_1.default.writeFileSync(frPath, JSON.stringify({ key: "Ancien" }));
await (0, translate_file_1.translateFileDiff)({
engine: engine_1.default.ChatGPT,
inputAfterFileOrPath: afterPath,
inputBeforeFileOrPath: beforePath,
inputLanguageCode: "en",
model: "gpt-4o",
promptMode,
rateLimitMs: 0,
});
const out = JSON.parse(fs_1.default.readFileSync(frPath, "utf-8"));
expect(out).toEqual({ added: fr("Yes"), key: fr("New") });
});
it("prunes removed keys", async () => {
const dir = mkCaseDir();
const beforePath = path_1.default.join(dir, "before_en.json");
const afterPath = path_1.default.join(dir, "after_en.json");
const frPath = path_1.default.join(dir, "fr.json");
fs_1.default.writeFileSync(beforePath, JSON.stringify({ key: "Old", unused: "Unused" }));
fs_1.default.writeFileSync(afterPath, JSON.stringify({ key: "New" }));
fs_1.default.writeFileSync(frPath, JSON.stringify({ key: "Ancien", unused: "Obsolete" }));
await (0, translate_file_1.translateFileDiff)({
engine: engine_1.default.ChatGPT,
inputAfterFileOrPath: afterPath,
inputBeforeFileOrPath: beforePath,
inputLanguageCode: "en",
model: "gpt-4o",
promptMode,
rateLimitMs: 0,
});
const out = JSON.parse(fs_1.default.readFileSync(frPath, "utf-8"));
expect(out).toEqual({ key: fr("New") }); // 'unused' pruned
});
it("handles multiple languages", async () => {
const dir = mkCaseDir();
const beforePath = path_1.default.join(dir, "before_en.json");
const afterPath = path_1.default.join(dir, "after_en.json");
const frPath = path_1.default.join(dir, "fr.json");
const esPath = path_1.default.join(dir, "es.json");
fs_1.default.writeFileSync(beforePath, JSON.stringify({ key: "Old" }));
fs_1.default.writeFileSync(afterPath, JSON.stringify({ added: "Yes", key: "New" }));
fs_1.default.writeFileSync(frPath, JSON.stringify({ key: "Ancien" }));
fs_1.default.writeFileSync(esPath, JSON.stringify({ key: "Viejo" }));
await (0, translate_file_1.translateFileDiff)({
engine: engine_1.default.ChatGPT,
inputAfterFileOrPath: afterPath,
inputBeforeFileOrPath: beforePath,
inputLanguageCode: "en",
model: "gpt-4o",
promptMode,
rateLimitMs: 0,
});
const frOut = JSON.parse(fs_1.default.readFileSync(frPath, "utf-8"));
const esOut = JSON.parse(fs_1.default.readFileSync(esPath, "utf-8"));
expect(frOut).toEqual({ added: fr("Yes"), key: fr("New") });
expect(esOut).toEqual({ added: es("Yes"), key: es("New") });
});
});
describe.each(Object.values(prompt_mode_1.default))("translateDirectory (promptMode=%s)", (promptMode) => {
it("replicates the directory hierarchy for the target language", async () => {
const dir = mkCaseDir();
const enDir = path_1.default.join(dir, "en");
fs_1.default.mkdirSync(enDir, { recursive: true });
const enFile = path_1.default.join(enDir, "app.json");
fs_1.default.writeFileSync(enFile, JSON.stringify({ welcome: "Welcome" }));
await (0, translate_directory_1.translateDirectory)({
baseDirectory: dir,
engine: engine_1.default.ChatGPT,
inputLanguage: "en",
model: "gpt-4o",
outputLanguage: "fr",
promptMode,
rateLimitMs: 0,
});
const frFile = path_1.default.join(dir, "fr", "app.json");
const frJSON = JSON.parse(fs_1.default.readFileSync(frFile, "utf-8"));
expect(frJSON).toEqual({ welcome: fr("Welcome") });
});
it("handles nested directories", async () => {
const dir = mkCaseDir();
const enDir = path_1.default.join(dir, "en", "nested");
fs_1.default.mkdirSync(enDir, { recursive: true });
const enFile = path_1.default.join(enDir, "app.json");
fs_1.default.writeFileSync(enFile, JSON.stringify({ greeting: "Hello" }));
await (0, translate_directory_1.translateDirectory)({
baseDirectory: dir,
engine: engine_1.default.ChatGPT,
inputLanguage: "en",
model: "gpt-4o",
outputLanguage: "fr",
promptMode,
rateLimitMs: 0,
});
const frFile = path_1.default.join(dir, "fr", "nested", "app.json");
const frJSON = JSON.parse(fs_1.default.readFileSync(frFile, "utf-8"));
expect(frJSON).toEqual({ greeting: fr("Hello") });
});
it("handles multiple files with various amounts of nesting", async () => {
const dir = mkCaseDir();
const enDir = path_1.default.join(dir, "en");
fs_1.default.mkdirSync(enDir, { recursive: true });
// ── layout ────────────────────────────────────────────
// base/en/app.json { welcome: "Welcome" }
// base/en/nested/app.json { greeting: "Hello" }
const enFile1 = path_1.default.join(enDir, "app.json");
const enFile2 = path_1.default.join(enDir, "nested", "app.json");
fs_1.default.mkdirSync(path_1.default.dirname(enFile2), { recursive: true });
fs_1.default.writeFileSync(enFile1, JSON.stringify({ welcome: "Welcome" }));
fs_1.default.writeFileSync(enFile2, JSON.stringify({ greeting: "Hello" }));
await (0, translate_directory_1.translateDirectory)({
baseDirectory: dir,
engine: engine_1.default.ChatGPT,
inputLanguage: "en",
model: "gpt-4o",
outputLanguage: "fr",
promptMode,
rateLimitMs: 0,
});
const frFile1 = path_1.default.join(dir, "fr", "app.json");
const frFile2 = path_1.default.join(dir, "fr", "nested", "app.json");
const frJSON1 = JSON.parse(fs_1.default.readFileSync(frFile1, "utf-8"));
const frJSON2 = JSON.parse(fs_1.default.readFileSync(frFile2, "utf-8"));
expect(frJSON1).toEqual({ welcome: fr("Welcome") });
expect(frJSON2).toEqual({ greeting: fr("Hello") });
});
});
describe.each(Object.values(prompt_mode_1.default))("translateDirectoryDiff (promptMode=%s)", (promptMode) => {
it("writes translations for changed keys and prunes removed keys", async () => {
const dir = mkCaseDir();
// ── layout ────────────────────────────────────────────
// base/en_before/app.json { hello: "Hello", unused: "Unused" }
// base/en_after /app.json { hello: "Hi", bye: "Bye" }
// base/fr /app.json { hello: "Bonjour", unused: "Obso" }
const enBefore = path_1.default.join(dir, "en_before");
const enAfter = path_1.default.join(dir, "en_after");
const frDir = path_1.default.join(dir, "fr");
fs_1.default.mkdirSync(enBefore, { recursive: true });
fs_1.default.mkdirSync(enAfter, { recursive: true });
fs_1.default.mkdirSync(frDir, { recursive: true });
const beforeFile = path_1.default.join(enBefore, "app.json");
const afterFile = path_1.default.join(enAfter, "app.json");
const frFile = path_1.default.join(frDir, "app.json");
fs_1.default.writeFileSync(beforeFile, JSON.stringify({ hello: "Hello", unused: "Unused" }));
fs_1.default.writeFileSync(afterFile, JSON.stringify({ bye: "Bye", hello: "Hi" }));
fs_1.default.writeFileSync(frFile, JSON.stringify({ hello: "Bonjour", unused: "Obso" }));
await (0, translate_directory_1.translateDirectoryDiff)({
baseDirectory: dir,
engine: engine_1.default.ChatGPT,
inputFolderNameAfter: "en_after",
inputFolderNameBefore: "en_before",
inputLanguageCode: "en",
model: "gpt-4o",
promptMode,
rateLimitMs: 0,
verbose: true,
});
const updated = JSON.parse(fs_1.default.readFileSync(frFile, "utf-8"));
expect(updated).toEqual({ bye: fr("Bye"), hello: fr("Hi") }); // 'unused' pruned
});
it("handles nested directories and multiple files", async () => {
const dir = mkCaseDir();
// ── layout ────────────────────────────────────────────
// base/en_before/app.json { welcome: "Welcome" }
// base/en_after /app.json { greeting: "Hello" }
// base/en_after /nested/app.json { farewell: "Goodbye" }
// base/fr /app.json { welcome: "Bienvenue" }
const enBefore = path_1.default.join(dir, "en_before");
const enAfter = path_1.default.join(dir, "en_after");
const frDir = path_1.default.join(dir, "fr");
fs_1.default.mkdirSync(enBefore, { recursive: true });
fs_1.default.mkdirSync(enAfter, { recursive: true });
fs_1.default.mkdirSync(frDir, { recursive: true });
fs_1.default.mkdirSync(path_1.default.join(enAfter, "nested"), { recursive: true });
const beforeFile = path_1.default.join(enBefore, "app.json");
const afterFile = path_1.default.join(enAfter, "app.json");
const afterNestedFile = path_1.default.join(enAfter, "nested", "app.json");
const frFile = path_1.default.join(frDir, "app.json");
const frNestedFile = path_1.default.join(frDir, "nested", "app.json");
fs_1.default.writeFileSync(beforeFile, JSON.stringify({ welcome: "Welcome" }));
fs_1.default.writeFileSync(afterFile, JSON.stringify({ greeting: "Hello" }));
fs_1.default.writeFileSync(afterNestedFile, JSON.stringify({ farewell: "Goodbye" }));
fs_1.default.writeFileSync(frFile, JSON.stringify({ welcome: "Bienvenue" }));
await (0, translate_directory_1.translateDirectoryDiff)({
baseDirectory: dir,
engine: engine_1.default.ChatGPT,
inputFolderNameAfter: "en_after",
inputFolderNameBefore: "en_before",
inputLanguageCode: "en",
model: "gpt-4o",
promptMode,
rateLimitMs: 0,
verbose: true,
});
const updated = JSON.parse(fs_1.default.readFileSync(frFile, "utf-8"));
const updatedNested = JSON.parse(fs_1.default.readFileSync(frNestedFile, "utf-8"));
expect(updated).toEqual({ greeting: fr("Hello") });
expect(updatedNested).toEqual({ farewell: fr("Goodbye") });
});
it("handles multiple languages in the directory", async () => {
const dir = mkCaseDir();
// ── layout ────────────────────────────────────────────
// base/en_before/app.json { hello: "Hello" }
// base/en_after /app.json { hello: "Hi" }
// base/fr /app.json { hello: "Bonjour" }
// base/es /app.json { hello: "Hola" }
const enBefore = path_1.default.join(dir, "en_before");
const enAfter = path_1.default.join(dir, "en_after");
const frDir = path_1.default.join(dir, "fr");
const esDir = path_1.default.join(dir, "es");
fs_1.default.mkdirSync(enBefore, { recursive: true });
fs_1.default.mkdirSync(enAfter, { recursive: true });
fs_1.default.mkdirSync(frDir, { recursive: true });
fs_1.default.mkdirSync(esDir, { recursive: true });
const beforeFile = path_1.default.join(enBefore, "app.json");
const afterFile = path_1.default.join(enAfter, "app.json");
const frFile = path_1.default.join(frDir, "app.json");
const esFile = path_1.default.join(esDir, "app.json");
fs_1.default.writeFileSync(beforeFile, JSON.stringify({ hello: "Hello" }));
fs_1.default.writeFileSync(afterFile, JSON.stringify({ hello: "Hi" }));
fs_1.default.writeFileSync(frFile, JSON.stringify({ hello: "Bonjour" }));
fs_1.default.writeFileSync(esFile, JSON.stringify({ hello: "Hola" }));
await (0, translate_directory_1.translateDirectoryDiff)({
baseDirectory: dir,
engine: engine_1.default.ChatGPT,
inputFolderNameAfter: "en_after",
inputFolderNameBefore: "en_before",
inputLanguageCode: "en",
model: "gpt-4o",
promptMode,
rateLimitMs: 0,
verbose: true,
});
const updatedFr = JSON.parse(fs_1.default.readFileSync(frFile, "utf-8"));
const updatedEs = JSON.parse(fs_1.default.readFileSync(esFile, "utf-8"));
expect(updatedFr).toEqual({ hello: fr("Hi") });
expect(updatedEs).toEqual({ hello: es("Hi") });
});
});
describe("RateLimiter", () => {
const mockedDelay = utils.delay;
const delayBetweenCallsMs = 500;
afterEach(() => {
jest.restoreAllMocks();
jest.clearAllMocks();
});
it("returns immediately when no API call has been made", async () => {
const rl = new rate_limiter_1.default(delayBetweenCallsMs, true);
// Date.now() is irrelevant here, but stub anyway for determinism.
jest.spyOn(Date, "now").mockReturnValue(1_000);
await rl.wait();
expect(mockedDelay).not.toHaveBeenCalled();
});
it("returns immediately when enough time has already passed since the last call", async () => {
const now = 10_000;
jest.spyOn(Date, "now").mockReturnValue(now);
const rl = new rate_limiter_1.default(delayBetweenCallsMs, true);
rl.lastAPICall = now - delayBetweenCallsMs - 50; // already past the window
await rl.wait();
expect(mockedDelay).not.toHaveBeenCalled();
});
it("waits the correct time when called too soon and verboseLogging = true", async () => {
const now = 20_000;
const timeRemaining = 125; // ms still to wait
// Stub Date.now() for this test
jest.spyOn(Date, "now").mockReturnValue(now);
const rl = new rate_limiter_1.default(delayBetweenCallsMs, true);
rl.lastAPICall = now - (delayBetweenCallsMs - timeRemaining);
await rl.wait();
expect(mockedDelay).toHaveBeenCalledTimes(1);
expect(mockedDelay).toHaveBeenCalledWith(timeRemaining);
});
it("does not log when verboseLogging = false", async () => {
const now = 30_000;
const timeRemaining = 200;
jest.spyOn(Date, "now").mockReturnValue(now);
const rl = new rate_limiter_1.default(delayBetweenCallsMs, false);
rl.lastAPICall = now - (delayBetweenCallsMs - timeRemaining);
await rl.wait();
expect(mockedDelay).toHaveBeenCalledWith(timeRemaining);
});
});
//# sourceMappingURL=translate.spec.js.map