@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/README.md ADDED
@@ -0,0 +1,134 @@
1
+ # i18n-ai
2
+
3
+ AI-powered internationalization (i18n) translation and synchronization library. This tool automates the translation of your JSON message files using AI, keeping them synchronized with your base locale.
4
+
5
+ ## Features
6
+
7
+ - 🤖 **AI-Powered**: Uses Google Gemini (via OpenRouter) to generate context-aware translations.
8
+ - âš¡ **Parallel Processing**: Translations run concurrently for maximum speed (5x faster).
9
+ - 🔄 **Smart Synchronization**: Detects new, modified, and obsolete keys.
10
+ - â›” **Variable Protection**: Automatically preserves variables like `{name}` or `{count}`.
11
+ - 🔒 **Lock File System**: Prevents unnecessary re-translations of already translated content.
12
+ - 🧩 **Framework Agnostic**: Works with any i18n library that uses JSON files (next-intl, react-i18next, etc.).
13
+
14
+ ## Installation
15
+
16
+ ```bash
17
+ bun add @ccgp/i18n-ai
18
+ # or
19
+ npm install @ccgp/i18n-ai
20
+ ```
21
+
22
+ ## Usage
23
+
24
+ ### CLI
25
+
26
+ The easiest way to use `i18n-ai` is via the CLI.
27
+
28
+ 1. **Initialize configuration:**
29
+
30
+ ```bash
31
+ bun x i18n-ai init
32
+ ```
33
+
34
+ This will create an `i18n-ai.config.json` file:
35
+
36
+ ```json
37
+ {
38
+ "defaultLocale": "en",
39
+ "locales": ["en", "es", "fr"],
40
+ "messagesDir": "messages"
41
+ }
42
+ ```
43
+
44
+ 2. **Run synchronization:**
45
+
46
+ ```bash
47
+ bun x i18n-ai sync
48
+ ```
49
+
50
+ ### CLI Options
51
+
52
+ The `sync` command supports these options:
53
+
54
+ | Option | Description |
55
+ |--------|-------------|
56
+ | `-d, --dir <path>` | Messages directory (default: `messages`) |
57
+ | `-l, --locales <items>` | Comma-separated list of locales |
58
+ | `--default <locale>` | Default locale (default: `en`) |
59
+ | `--lock <path>` | Lock file path (default: `translation-lock.json`) |
60
+ | `--api-key <key>` | OpenRouter API key (or set `OPENROUTER_API_KEY`) |
61
+ | `--model <model>` | AI model to use (default: `google/gemini-2.5-flash`) |
62
+
63
+ Example with custom options:
64
+
65
+ ```bash
66
+ bun x i18n-ai sync --locales es,fr,de --model anthropic/claude-3-haiku
67
+ ```
68
+
69
+ ### Environment Variables
70
+
71
+ You need to provide an API key for the AI provider.
72
+
73
+ Create a `.env` file:
74
+
75
+ ```env
76
+ OPENROUTER_API_KEY=your_api_key
77
+ ```
78
+
79
+ ### Automate with GitHub Actions
80
+
81
+ You can automate translations whenever you push changes to your default locale.
82
+
83
+ Create `.github/workflows/i18n-translate.yml`:
84
+
85
+ ```yaml
86
+ name: AI Translation Sync
87
+
88
+ on:
89
+ push:
90
+ paths:
91
+ - 'public/messages/en.json' # Monitor ONLY source locale
92
+
93
+ jobs:
94
+ translate:
95
+ runs-on: ubuntu-latest
96
+ permissions:
97
+ contents: write
98
+ steps:
99
+ - uses: actions/checkout@v4
100
+ - uses: oven-sh/setup-bun@v1
101
+ - run: bun install
102
+ - run: bun x i18n-ai sync --api-key ${{ secrets.OPENROUTER_API_KEY }}
103
+ - run: |
104
+ git config --global user.name "github-actions[bot]"
105
+ git config --global user.email "github-actions[bot]@users.noreply.github.com"
106
+ if [[ -n $(git status -s) ]]; then
107
+ git add public/messages/*.json translation-lock.json
108
+ git commit -m "chore(i18n): update translations [skip ci]"
109
+ git push
110
+ fi
111
+ ```
112
+
113
+ ## Programmatic Usage
114
+
115
+ You can also use the library programmatically:
116
+
117
+ ```typescript
118
+ import { TranslationService } from '@ccgp/i18n-ai';
119
+ import { resolve } from 'path';
120
+
121
+ const service = new TranslationService({
122
+ locales: ['en', 'es', 'fr'],
123
+ defaultLocale: 'en',
124
+ messagesDir: resolve(process.cwd(), 'messages'),
125
+ lockFilePath: resolve(process.cwd(), 'translation-lock.json'),
126
+ apiKey: process.env.OPENROUTER_API_KEY
127
+ });
128
+
129
+ await service.sync();
130
+ ```
131
+
132
+ ## License
133
+
134
+ ISC
@@ -0,0 +1,286 @@
1
+ // src/core/lock.ts
2
+ import { createHash } from "crypto";
3
+ import { readFile, writeFile } from "fs/promises";
4
+ import { lock as acquireLock } from "proper-lockfile";
5
+ var hash = (text) => createHash("sha256").update(text).digest("hex").slice(0, 32);
6
+ var hashesMatch = (current, stored) => current === stored || stored.length === 16 && current.startsWith(stored);
7
+ async function loadLock(path) {
8
+ try {
9
+ return JSON.parse(await readFile(path, "utf-8"));
10
+ } catch (e) {
11
+ if (e.code === "ENOENT") return { locales: {} };
12
+ if (e instanceof SyntaxError)
13
+ throw new Error(`Lock file corrupted: ${path}`);
14
+ throw e;
15
+ }
16
+ }
17
+ async function saveLock(path, lock) {
18
+ await writeFile(path, `${JSON.stringify(lock, null, 2)}
19
+ `);
20
+ }
21
+ async function withLockFile(path, fn) {
22
+ let lock = await loadLock(path);
23
+ if (Object.keys(lock.locales).length === 0) await saveLock(path, lock);
24
+ const release = await acquireLock(path, {
25
+ retries: { retries: 5, factor: 2, minTimeout: 1e3, maxTimeout: 5e3 },
26
+ stale: 3e4
27
+ });
28
+ try {
29
+ lock = await loadLock(path);
30
+ const result = await fn(lock);
31
+ await saveLock(path, lock);
32
+ return result;
33
+ } finally {
34
+ await release();
35
+ }
36
+ }
37
+ function analyzeLocale(base, target, locale, lock) {
38
+ const result = {
39
+ locale,
40
+ toTranslate: [],
41
+ toPreserve: /* @__PURE__ */ new Map(),
42
+ toRemove: /* @__PURE__ */ new Set(),
43
+ manualEdits: /* @__PURE__ */ new Map()
44
+ };
45
+ const localeData = lock.locales[locale] ?? {};
46
+ for (const [key, text] of base) {
47
+ const entry = localeData[key];
48
+ const current = target.get(key);
49
+ const currentHash = hash(text);
50
+ if (!entry) {
51
+ current ? result.toPreserve.set(key, current) : result.toTranslate.push({ key, text, status: "new" });
52
+ } else if (!hashesMatch(currentHash, entry.sourceHash)) {
53
+ result.toTranslate.push({ key, text, status: "modified" });
54
+ } else if (current && current !== entry.translation) {
55
+ result.manualEdits.set(key, current);
56
+ } else {
57
+ result.toPreserve.set(key, current ?? entry.translation);
58
+ }
59
+ }
60
+ for (const key of target.keys()) {
61
+ if (!base.has(key)) result.toRemove.add(key);
62
+ }
63
+ return result;
64
+ }
65
+ function updateLock(lock, locale, base, final) {
66
+ lock.locales[locale] ??= {};
67
+ const localeData = lock.locales[locale];
68
+ for (const [key, translation] of final) {
69
+ const sourceText = base.get(key);
70
+ if (sourceText) {
71
+ localeData[key] = {
72
+ sourceHash: hash(sourceText),
73
+ sourceText,
74
+ translation
75
+ };
76
+ }
77
+ }
78
+ for (const key of Object.keys(localeData)) {
79
+ if (!base.has(key)) delete localeData[key];
80
+ }
81
+ }
82
+
83
+ // src/core/translator.ts
84
+ import { createOpenRouter } from "@openrouter/ai-sdk-provider";
85
+ import { generateText, Output } from "ai";
86
+ import * as z2 from "zod";
87
+
88
+ // src/env.ts
89
+ import { createEnv } from "@t3-oss/env-core";
90
+ import * as z from "zod";
91
+ var env = createEnv({
92
+ server: {
93
+ OPENROUTER_API_KEY: z.string()
94
+ },
95
+ runtimeEnv: process.env
96
+ });
97
+
98
+ // src/core/translator.ts
99
+ var DEFAULT_MODEL = "google/gemini-2.5-flash";
100
+ var translationSchema = z2.object({
101
+ translations: z2.array(
102
+ z2.object({
103
+ key: z2.string(),
104
+ value: z2.string()
105
+ })
106
+ )
107
+ });
108
+ var openrouter = null;
109
+ var getOpenRouter = () => openrouter ?? (openrouter = createOpenRouter({ apiKey: env.OPENROUTER_API_KEY }));
110
+ var SYSTEM_PROMPT = `You are a professional translator.
111
+
112
+ RULES:
113
+ 1. Translate the provided key-value pairs from the source language to the target language
114
+ 2. Return an array of translations with the EXACT same keys and their translated values
115
+ 3. Do NOT translate variables in curly braces: {name}, {count}, etc. Keep them exactly as-is
116
+ 4. Maintain original tone and context
117
+ 5. For single words or short phrases, translate directly`;
118
+ async function withRetry(fn, maxAttempts = 3) {
119
+ for (let attempt = 1; attempt <= maxAttempts; attempt++) {
120
+ try {
121
+ return await fn();
122
+ } catch (error) {
123
+ const msg = error.message?.toLowerCase() ?? "";
124
+ const retryable = msg.includes("network") || msg.includes("timeout") || msg.includes("econnreset") || msg.includes("429") || msg.includes("503") || msg.includes("502");
125
+ if (attempt === maxAttempts || !retryable) throw error;
126
+ await new Promise((r) => setTimeout(r, 2 ** (attempt - 1) * 1e3));
127
+ }
128
+ }
129
+ throw new Error("Unreachable");
130
+ }
131
+ async function translateBatch(batch) {
132
+ if (batch.entries.length === 0) return /* @__PURE__ */ new Map();
133
+ const inputArray = batch.entries.map((e) => ({ key: e.key, text: e.text }));
134
+ return withRetry(async () => {
135
+ const { output } = await generateText({
136
+ model: getOpenRouter().languageModel(DEFAULT_MODEL),
137
+ output: Output.object({
138
+ schema: translationSchema
139
+ }),
140
+ system: SYSTEM_PROMPT,
141
+ prompt: `Translate from "${batch.sourceLang}" to "${batch.targetLang}":
142
+
143
+ ${JSON.stringify(inputArray, null, 2)}
144
+
145
+ Return the translations array with the same keys and translated values.`
146
+ });
147
+ if (!output.translations || output.translations.length === 0) {
148
+ throw new Error("Translation returned empty result");
149
+ }
150
+ const resultMap = new Map(output.translations.map((t) => [t.key, t.value]));
151
+ const missingKeys = batch.entries.filter((e) => !resultMap.has(e.key));
152
+ if (missingKeys.length > 0) {
153
+ throw new Error(
154
+ `Missing translations for keys: ${missingKeys.map((e) => e.key).join(", ")}`
155
+ );
156
+ }
157
+ return resultMap;
158
+ });
159
+ }
160
+
161
+ // src/core/sync.ts
162
+ import { readFile as readFile2, writeFile as writeFile2, rename } from "fs/promises";
163
+ import { join } from "path";
164
+ import PQueue from "p-queue";
165
+ var chunk = (arr, n) => Array.from(
166
+ { length: Math.ceil(arr.length / n) },
167
+ (_, i) => arr.slice(i * n, (i + 1) * n)
168
+ );
169
+ var flatten = (obj, prefix = "") => {
170
+ const result = /* @__PURE__ */ new Map();
171
+ for (const [k, v] of Object.entries(obj)) {
172
+ const path = prefix ? `${prefix}.${k}` : k;
173
+ typeof v === "string" ? result.set(path, v) : flatten(v, path).forEach((val, p) => result.set(p, val));
174
+ }
175
+ return result;
176
+ };
177
+ var unflatten = (flat) => {
178
+ const result = {};
179
+ for (const [path, value] of flat) {
180
+ const keys = path.split(".");
181
+ let cur = result;
182
+ for (let i = 0; i < keys.length - 1; i++) {
183
+ cur = cur[keys[i]] ??= {};
184
+ }
185
+ cur[keys.at(-1)] = value;
186
+ }
187
+ return result;
188
+ };
189
+ var readJson = async (path) => {
190
+ try {
191
+ return JSON.parse(await readFile2(path, "utf-8"));
192
+ } catch (e) {
193
+ if (e.code === "ENOENT") return null;
194
+ throw e;
195
+ }
196
+ };
197
+ var writeJsonAtomic = async (path, data) => {
198
+ const tmp = `${path}.tmp`;
199
+ await writeFile2(tmp, `${JSON.stringify(data, null, 2)}
200
+ `);
201
+ await rename(tmp, path);
202
+ };
203
+ var TranslationService = class {
204
+ constructor(config) {
205
+ this.config = config;
206
+ this.queue = new PQueue({ concurrency: config.concurrency ?? 10 });
207
+ this.batchSize = config.batchSize ?? 20;
208
+ }
209
+ queue;
210
+ batchSize;
211
+ async sync() {
212
+ const basePath = join(
213
+ this.config.messagesDir,
214
+ `${this.config.defaultLocale}.json`
215
+ );
216
+ const baseObj = await readJson(basePath);
217
+ if (!baseObj) throw new Error(`Base locale file not found: ${basePath}`);
218
+ const base = flatten(baseObj);
219
+ console.log(
220
+ `[ok] Loaded ${this.config.defaultLocale}.json (${base.size} keys)`
221
+ );
222
+ await withLockFile(this.config.lockFilePath, async (lock) => {
223
+ const locales = this.config.locales.filter(
224
+ (l) => l !== this.config.defaultLocale
225
+ );
226
+ await Promise.all(
227
+ locales.map((locale) => this.syncLocale(locale, base, lock))
228
+ );
229
+ });
230
+ console.log("\n[done] Synchronization complete!");
231
+ }
232
+ async syncLocale(locale, base, lock) {
233
+ const targetPath = join(this.config.messagesDir, `${locale}.json`);
234
+ const targetObj = await readJson(targetPath) ?? {};
235
+ const target = flatten(targetObj);
236
+ const changes = analyzeLocale(base, target, locale, lock);
237
+ const hasWork = changes.toTranslate.length > 0 || changes.toRemove.size > 0 || changes.manualEdits.size > 0;
238
+ console.log(
239
+ `
240
+ [${locale}] ${changes.toTranslate.length} to translate, ${changes.toRemove.size} obsolete`
241
+ );
242
+ if (!hasWork) {
243
+ console.log(" [ok] Already synchronized");
244
+ return;
245
+ }
246
+ const translations = await this.translateAll(changes);
247
+ const final = new Map([
248
+ ...changes.toPreserve,
249
+ ...changes.manualEdits,
250
+ ...translations
251
+ ]);
252
+ changes.toRemove.forEach((k) => final.delete(k));
253
+ updateLock(lock, locale, base, final);
254
+ await writeJsonAtomic(targetPath, unflatten(final));
255
+ console.log(` [ok] Updated ${targetPath}`);
256
+ }
257
+ async translateAll(changes) {
258
+ if (changes.toTranslate.length === 0) return /* @__PURE__ */ new Map();
259
+ const batches = chunk(changes.toTranslate, this.batchSize);
260
+ const results = await Promise.all(
261
+ batches.map(
262
+ (batch) => this.queue.add(
263
+ () => translateBatch({
264
+ entries: batch,
265
+ sourceLang: this.config.defaultLocale,
266
+ targetLang: changes.locale,
267
+ apiKey: this.config.apiKey,
268
+ model: this.config.model
269
+ })
270
+ )
271
+ )
272
+ );
273
+ return results.reduce(
274
+ (acc, r) => r ? new Map([...acc, ...r]) : acc,
275
+ /* @__PURE__ */ new Map()
276
+ );
277
+ }
278
+ };
279
+
280
+ export {
281
+ withLockFile,
282
+ analyzeLocale,
283
+ updateLock,
284
+ translateBatch,
285
+ TranslationService
286
+ };
package/dist/cli.d.mts ADDED
@@ -0,0 +1 @@
1
+ #!/usr/bin/env node
package/dist/cli.d.ts ADDED
@@ -0,0 +1 @@
1
+ #!/usr/bin/env node