@cgaravitoq/i18n-ai 0.2.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/index.js ADDED
@@ -0,0 +1,326 @@
1
+ "use strict";
2
+ var __create = Object.create;
3
+ var __defProp = Object.defineProperty;
4
+ var __getOwnPropDesc = Object.getOwnPropertyDescriptor;
5
+ var __getOwnPropNames = Object.getOwnPropertyNames;
6
+ var __getProtoOf = Object.getPrototypeOf;
7
+ var __hasOwnProp = Object.prototype.hasOwnProperty;
8
+ var __export = (target, all) => {
9
+ for (var name in all)
10
+ __defProp(target, name, { get: all[name], enumerable: true });
11
+ };
12
+ var __copyProps = (to, from, except, desc) => {
13
+ if (from && typeof from === "object" || typeof from === "function") {
14
+ for (let key of __getOwnPropNames(from))
15
+ if (!__hasOwnProp.call(to, key) && key !== except)
16
+ __defProp(to, key, { get: () => from[key], enumerable: !(desc = __getOwnPropDesc(from, key)) || desc.enumerable });
17
+ }
18
+ return to;
19
+ };
20
+ var __toESM = (mod, isNodeMode, target) => (target = mod != null ? __create(__getProtoOf(mod)) : {}, __copyProps(
21
+ // If the importer is in node compatibility mode or this is not an ESM
22
+ // file that has been converted to a CommonJS file using a Babel-
23
+ // compatible transform (i.e. "__esModule" has not been set), then set
24
+ // "default" to the CommonJS "module.exports" for node compatibility.
25
+ isNodeMode || !mod || !mod.__esModule ? __defProp(target, "default", { value: mod, enumerable: true }) : target,
26
+ mod
27
+ ));
28
+ var __toCommonJS = (mod) => __copyProps(__defProp({}, "__esModule", { value: true }), mod);
29
+
30
+ // src/index.ts
31
+ var index_exports = {};
32
+ __export(index_exports, {
33
+ TranslationService: () => TranslationService,
34
+ analyzeLocale: () => analyzeLocale,
35
+ translateBatch: () => translateBatch,
36
+ updateLock: () => updateLock,
37
+ withLockFile: () => withLockFile
38
+ });
39
+ module.exports = __toCommonJS(index_exports);
40
+
41
+ // src/core/lock.ts
42
+ var import_node_crypto = require("crypto");
43
+ var import_promises = require("fs/promises");
44
+ var import_proper_lockfile = require("proper-lockfile");
45
+ var hash = (text) => (0, import_node_crypto.createHash)("sha256").update(text).digest("hex").slice(0, 32);
46
+ var hashesMatch = (current, stored) => current === stored || stored.length === 16 && current.startsWith(stored);
47
+ async function loadLock(path) {
48
+ try {
49
+ return JSON.parse(await (0, import_promises.readFile)(path, "utf-8"));
50
+ } catch (e) {
51
+ if (e.code === "ENOENT") return { locales: {} };
52
+ if (e instanceof SyntaxError)
53
+ throw new Error(`Lock file corrupted: ${path}`);
54
+ throw e;
55
+ }
56
+ }
57
+ async function saveLock(path, lock) {
58
+ await (0, import_promises.writeFile)(path, `${JSON.stringify(lock, null, 2)}
59
+ `);
60
+ }
61
+ async function withLockFile(path, fn) {
62
+ let lock = await loadLock(path);
63
+ if (Object.keys(lock.locales).length === 0) await saveLock(path, lock);
64
+ const release = await (0, import_proper_lockfile.lock)(path, {
65
+ retries: { retries: 5, factor: 2, minTimeout: 1e3, maxTimeout: 5e3 },
66
+ stale: 3e4
67
+ });
68
+ try {
69
+ lock = await loadLock(path);
70
+ const result = await fn(lock);
71
+ await saveLock(path, lock);
72
+ return result;
73
+ } finally {
74
+ await release();
75
+ }
76
+ }
77
+ function analyzeLocale(base, target, locale, lock) {
78
+ const result = {
79
+ locale,
80
+ toTranslate: [],
81
+ toPreserve: /* @__PURE__ */ new Map(),
82
+ toRemove: /* @__PURE__ */ new Set(),
83
+ manualEdits: /* @__PURE__ */ new Map()
84
+ };
85
+ const localeData = lock.locales[locale] ?? {};
86
+ for (const [key, text] of base) {
87
+ const entry = localeData[key];
88
+ const current = target.get(key);
89
+ const currentHash = hash(text);
90
+ if (!entry) {
91
+ current ? result.toPreserve.set(key, current) : result.toTranslate.push({ key, text, status: "new" });
92
+ } else if (!hashesMatch(currentHash, entry.sourceHash)) {
93
+ result.toTranslate.push({ key, text, status: "modified" });
94
+ } else if (current && current !== entry.translation) {
95
+ result.manualEdits.set(key, current);
96
+ } else {
97
+ result.toPreserve.set(key, current ?? entry.translation);
98
+ }
99
+ }
100
+ for (const key of target.keys()) {
101
+ if (!base.has(key)) result.toRemove.add(key);
102
+ }
103
+ return result;
104
+ }
105
+ function updateLock(lock, locale, base, final) {
106
+ lock.locales[locale] ??= {};
107
+ const localeData = lock.locales[locale];
108
+ for (const [key, translation] of final) {
109
+ const sourceText = base.get(key);
110
+ if (sourceText) {
111
+ localeData[key] = {
112
+ sourceHash: hash(sourceText),
113
+ sourceText,
114
+ translation
115
+ };
116
+ }
117
+ }
118
+ for (const key of Object.keys(localeData)) {
119
+ if (!base.has(key)) delete localeData[key];
120
+ }
121
+ }
122
+
123
+ // src/core/translator.ts
124
+ var import_ai_sdk_provider = require("@openrouter/ai-sdk-provider");
125
+ var import_ai = require("ai");
126
+ var z2 = __toESM(require("zod"));
127
+
128
+ // src/env.ts
129
+ var import_env_core = require("@t3-oss/env-core");
130
+ var z = __toESM(require("zod"));
131
+ var env = (0, import_env_core.createEnv)({
132
+ server: {
133
+ OPENROUTER_API_KEY: z.string()
134
+ },
135
+ runtimeEnv: process.env
136
+ });
137
+
138
+ // src/core/translator.ts
139
+ var DEFAULT_MODEL = "google/gemini-2.5-flash";
140
+ var translationSchema = z2.object({
141
+ translations: z2.array(
142
+ z2.object({
143
+ key: z2.string(),
144
+ value: z2.string()
145
+ })
146
+ )
147
+ });
148
+ var openrouter = null;
149
+ var getOpenRouter = () => openrouter ?? (openrouter = (0, import_ai_sdk_provider.createOpenRouter)({ apiKey: env.OPENROUTER_API_KEY }));
150
+ var SYSTEM_PROMPT = `You are a professional translator.
151
+
152
+ RULES:
153
+ 1. Translate the provided key-value pairs from the source language to the target language
154
+ 2. Return an array of translations with the EXACT same keys and their translated values
155
+ 3. Do NOT translate variables in curly braces: {name}, {count}, etc. Keep them exactly as-is
156
+ 4. Maintain original tone and context
157
+ 5. For single words or short phrases, translate directly`;
158
+ async function withRetry(fn, maxAttempts = 3) {
159
+ for (let attempt = 1; attempt <= maxAttempts; attempt++) {
160
+ try {
161
+ return await fn();
162
+ } catch (error) {
163
+ const msg = error.message?.toLowerCase() ?? "";
164
+ const retryable = msg.includes("network") || msg.includes("timeout") || msg.includes("econnreset") || msg.includes("429") || msg.includes("503") || msg.includes("502");
165
+ if (attempt === maxAttempts || !retryable) throw error;
166
+ await new Promise((r) => setTimeout(r, 2 ** (attempt - 1) * 1e3));
167
+ }
168
+ }
169
+ throw new Error("Unreachable");
170
+ }
171
+ async function translateBatch(batch) {
172
+ if (batch.entries.length === 0) return /* @__PURE__ */ new Map();
173
+ const inputArray = batch.entries.map((e) => ({ key: e.key, text: e.text }));
174
+ return withRetry(async () => {
175
+ const { output } = await (0, import_ai.generateText)({
176
+ model: getOpenRouter().languageModel(DEFAULT_MODEL),
177
+ output: import_ai.Output.object({
178
+ schema: translationSchema
179
+ }),
180
+ system: SYSTEM_PROMPT,
181
+ prompt: `Translate from "${batch.sourceLang}" to "${batch.targetLang}":
182
+
183
+ ${JSON.stringify(inputArray, null, 2)}
184
+
185
+ Return the translations array with the same keys and translated values.`
186
+ });
187
+ if (!output.translations || output.translations.length === 0) {
188
+ throw new Error("Translation returned empty result");
189
+ }
190
+ const resultMap = new Map(output.translations.map((t) => [t.key, t.value]));
191
+ const missingKeys = batch.entries.filter((e) => !resultMap.has(e.key));
192
+ if (missingKeys.length > 0) {
193
+ throw new Error(
194
+ `Missing translations for keys: ${missingKeys.map((e) => e.key).join(", ")}`
195
+ );
196
+ }
197
+ return resultMap;
198
+ });
199
+ }
200
+
201
+ // src/core/sync.ts
202
+ var import_promises2 = require("fs/promises");
203
+ var import_node_path = require("path");
204
+ var import_p_queue = __toESM(require("p-queue"));
205
+ var chunk = (arr, n) => Array.from(
206
+ { length: Math.ceil(arr.length / n) },
207
+ (_, i) => arr.slice(i * n, (i + 1) * n)
208
+ );
209
+ var flatten = (obj, prefix = "") => {
210
+ const result = /* @__PURE__ */ new Map();
211
+ for (const [k, v] of Object.entries(obj)) {
212
+ const path = prefix ? `${prefix}.${k}` : k;
213
+ typeof v === "string" ? result.set(path, v) : flatten(v, path).forEach((val, p) => result.set(p, val));
214
+ }
215
+ return result;
216
+ };
217
+ var unflatten = (flat) => {
218
+ const result = {};
219
+ for (const [path, value] of flat) {
220
+ const keys = path.split(".");
221
+ let cur = result;
222
+ for (let i = 0; i < keys.length - 1; i++) {
223
+ cur = cur[keys[i]] ??= {};
224
+ }
225
+ cur[keys.at(-1)] = value;
226
+ }
227
+ return result;
228
+ };
229
+ var readJson = async (path) => {
230
+ try {
231
+ return JSON.parse(await (0, import_promises2.readFile)(path, "utf-8"));
232
+ } catch (e) {
233
+ if (e.code === "ENOENT") return null;
234
+ throw e;
235
+ }
236
+ };
237
+ var writeJsonAtomic = async (path, data) => {
238
+ const tmp = `${path}.tmp`;
239
+ await (0, import_promises2.writeFile)(tmp, `${JSON.stringify(data, null, 2)}
240
+ `);
241
+ await (0, import_promises2.rename)(tmp, path);
242
+ };
243
+ var TranslationService = class {
244
+ constructor(config) {
245
+ this.config = config;
246
+ this.queue = new import_p_queue.default({ concurrency: config.concurrency ?? 10 });
247
+ this.batchSize = config.batchSize ?? 20;
248
+ }
249
+ queue;
250
+ batchSize;
251
+ async sync() {
252
+ const basePath = (0, import_node_path.join)(
253
+ this.config.messagesDir,
254
+ `${this.config.defaultLocale}.json`
255
+ );
256
+ const baseObj = await readJson(basePath);
257
+ if (!baseObj) throw new Error(`Base locale file not found: ${basePath}`);
258
+ const base = flatten(baseObj);
259
+ console.log(
260
+ `[ok] Loaded ${this.config.defaultLocale}.json (${base.size} keys)`
261
+ );
262
+ await withLockFile(this.config.lockFilePath, async (lock) => {
263
+ const locales = this.config.locales.filter(
264
+ (l) => l !== this.config.defaultLocale
265
+ );
266
+ await Promise.all(
267
+ locales.map((locale) => this.syncLocale(locale, base, lock))
268
+ );
269
+ });
270
+ console.log("\n[done] Synchronization complete!");
271
+ }
272
+ async syncLocale(locale, base, lock) {
273
+ const targetPath = (0, import_node_path.join)(this.config.messagesDir, `${locale}.json`);
274
+ const targetObj = await readJson(targetPath) ?? {};
275
+ const target = flatten(targetObj);
276
+ const changes = analyzeLocale(base, target, locale, lock);
277
+ const hasWork = changes.toTranslate.length > 0 || changes.toRemove.size > 0 || changes.manualEdits.size > 0;
278
+ console.log(
279
+ `
280
+ [${locale}] ${changes.toTranslate.length} to translate, ${changes.toRemove.size} obsolete`
281
+ );
282
+ if (!hasWork) {
283
+ console.log(" [ok] Already synchronized");
284
+ return;
285
+ }
286
+ const translations = await this.translateAll(changes);
287
+ const final = new Map([
288
+ ...changes.toPreserve,
289
+ ...changes.manualEdits,
290
+ ...translations
291
+ ]);
292
+ changes.toRemove.forEach((k) => final.delete(k));
293
+ updateLock(lock, locale, base, final);
294
+ await writeJsonAtomic(targetPath, unflatten(final));
295
+ console.log(` [ok] Updated ${targetPath}`);
296
+ }
297
+ async translateAll(changes) {
298
+ if (changes.toTranslate.length === 0) return /* @__PURE__ */ new Map();
299
+ const batches = chunk(changes.toTranslate, this.batchSize);
300
+ const results = await Promise.all(
301
+ batches.map(
302
+ (batch) => this.queue.add(
303
+ () => translateBatch({
304
+ entries: batch,
305
+ sourceLang: this.config.defaultLocale,
306
+ targetLang: changes.locale,
307
+ apiKey: this.config.apiKey,
308
+ model: this.config.model
309
+ })
310
+ )
311
+ )
312
+ );
313
+ return results.reduce(
314
+ (acc, r) => r ? new Map([...acc, ...r]) : acc,
315
+ /* @__PURE__ */ new Map()
316
+ );
317
+ }
318
+ };
319
+ // Annotate the CommonJS export names for ESM import in node:
320
+ 0 && (module.exports = {
321
+ TranslationService,
322
+ analyzeLocale,
323
+ translateBatch,
324
+ updateLock,
325
+ withLockFile
326
+ });
package/dist/index.mjs ADDED
@@ -0,0 +1,14 @@
1
+ import {
2
+ TranslationService,
3
+ analyzeLocale,
4
+ translateBatch,
5
+ updateLock,
6
+ withLockFile
7
+ } from "./chunk-IOH4K3A3.mjs";
8
+ export {
9
+ TranslationService,
10
+ analyzeLocale,
11
+ translateBatch,
12
+ updateLock,
13
+ withLockFile
14
+ };
package/package.json ADDED
@@ -0,0 +1,56 @@
1
+ {
2
+ "name": "@cgaravitoq/i18n-ai",
3
+ "version": "0.2.3",
4
+ "description": "AI-powered i18n translation and synchronization library",
5
+ "main": "dist/index.js",
6
+ "module": "dist/index.mjs",
7
+ "types": "dist/index.d.ts",
8
+ "bin": {
9
+ "i18n-ai": "dist/cli.js"
10
+ },
11
+ "files": [
12
+ "dist",
13
+ "README.md"
14
+ ],
15
+ "publishConfig": {
16
+ "access": "public"
17
+ },
18
+ "scripts": {
19
+ "build": "tsup",
20
+ "dev": "tsup --watch",
21
+ "start": "node dist/cli.js",
22
+ "lint": "tsc --noEmit"
23
+ },
24
+ "repository": {
25
+ "type": "git",
26
+ "url": "git+https://github.com/CarlosPProjects/i18n-ai.git"
27
+ },
28
+ "keywords": [
29
+ "i18n",
30
+ "ai",
31
+ "translation",
32
+ "automation"
33
+ ],
34
+ "author": "Carlos Garavito",
35
+ "license": "ISC",
36
+ "dependencies": {
37
+ "@openrouter/ai-sdk-provider": "^1.5.4",
38
+ "@t3-oss/env-core": "^0.13.10",
39
+ "ai": "^6.0.37",
40
+ "chalk": "^5.6.2",
41
+ "commander": "^14.0.2",
42
+ "dotenv": "^17.2.3",
43
+ "p-queue": "^9.1.0",
44
+ "prompts": "^2.4.2",
45
+ "proper-lockfile": "^4.1.2",
46
+ "zod": "^4.3.5"
47
+ },
48
+ "devDependencies": {
49
+ "@types/node": "^25.0.9",
50
+ "@types/prompts": "^2.4.9",
51
+ "@types/proper-lockfile": "^4.1.4",
52
+ "tsup": "^8.5.1",
53
+ "tsx": "^4.21.0",
54
+ "typescript": "^5.9.3"
55
+ }
56
+ }