@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 +134 -0
- package/dist/chunk-IOH4K3A3.mjs +286 -0
- package/dist/cli.d.mts +1 -0
- package/dist/cli.d.ts +1 -0
- package/dist/cli.js +414 -0
- package/dist/cli.mjs +111 -0
- package/dist/index.d.mts +56 -0
- package/dist/index.d.ts +56 -0
- package/dist/index.js +326 -0
- package/dist/index.mjs +14 -0
- package/package.json +56 -0
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
|