@ccgp/i18n-ai 0.0.1
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 +83 -0
- package/dist/chunk-QBRFUS6N.mjs +292 -0
- package/dist/cli.d.mts +1 -0
- package/dist/cli.d.ts +1 -0
- package/dist/cli.js +356 -0
- package/dist/cli.mjs +53 -0
- package/dist/index.d.mts +94 -0
- package/dist/index.d.ts +94 -0
- package/dist/index.js +338 -0
- package/dist/index.mjs +26 -0
- package/package.json +51 -0
package/README.md
ADDED
|
@@ -0,0 +1,83 @@
|
|
|
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
|
+
- 🔄 **Smart Synchronization**: Detects new, modified, and obsolete keys.
|
|
9
|
+
- 🔒 **Lock File System**: Prevents unnecessary re-translations of already translated content.
|
|
10
|
+
- 🧩 **Framework Agnostic**: Works with any i18n library that uses JSON files (next-intl, react-i18next, etc.).
|
|
11
|
+
|
|
12
|
+
## Installation
|
|
13
|
+
|
|
14
|
+
```bash
|
|
15
|
+
bun add @ccgp/i18n-ai
|
|
16
|
+
# or
|
|
17
|
+
npm install @ccgp/i18n-ai
|
|
18
|
+
```
|
|
19
|
+
|
|
20
|
+
## Usage
|
|
21
|
+
|
|
22
|
+
### CLI
|
|
23
|
+
|
|
24
|
+
The easiest way to use `i18n-ai` is via the CLI.
|
|
25
|
+
|
|
26
|
+
Add a script to your `package.json`:
|
|
27
|
+
|
|
28
|
+
```json
|
|
29
|
+
{
|
|
30
|
+
"scripts": {
|
|
31
|
+
"i18n:sync": "i18n-ai sync"
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
```
|
|
35
|
+
|
|
36
|
+
Or run it directly:
|
|
37
|
+
|
|
38
|
+
```bash
|
|
39
|
+
bun x i18n-ai sync
|
|
40
|
+
```
|
|
41
|
+
|
|
42
|
+
#### Configuration
|
|
43
|
+
|
|
44
|
+
The CLI attempts to auto-detect your configuration from `i18n/routing.ts` if you are using `next-intl`. Otherwise, you can specify options:
|
|
45
|
+
|
|
46
|
+
```bash
|
|
47
|
+
i18n-ai sync --locales en,es,fr --default en --dir messages
|
|
48
|
+
```
|
|
49
|
+
|
|
50
|
+
### Environment Variables
|
|
51
|
+
|
|
52
|
+
You need to provide an API key for the AI provider.
|
|
53
|
+
|
|
54
|
+
Create a `.env` file:
|
|
55
|
+
|
|
56
|
+
```env
|
|
57
|
+
OPENROUTER_API_KEY=your_api_key
|
|
58
|
+
# or
|
|
59
|
+
AI_API_KEY=your_api_key
|
|
60
|
+
```
|
|
61
|
+
|
|
62
|
+
## Programmatic Usage
|
|
63
|
+
|
|
64
|
+
You can also use the library programmatically:
|
|
65
|
+
|
|
66
|
+
```typescript
|
|
67
|
+
import { TranslationService } from 'i18n-ai';
|
|
68
|
+
import { resolve } from 'path';
|
|
69
|
+
|
|
70
|
+
const service = new TranslationService({
|
|
71
|
+
locales: ['en', 'es', 'fr'],
|
|
72
|
+
defaultLocale: 'en',
|
|
73
|
+
messagesDir: resolve(process.cwd(), 'messages'),
|
|
74
|
+
lockFilePath: resolve(process.cwd(), 'translation-lock.json'),
|
|
75
|
+
apiKey: process.env.AI_API_KEY
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
await service.sync();
|
|
79
|
+
```
|
|
80
|
+
|
|
81
|
+
## License
|
|
82
|
+
|
|
83
|
+
ISC
|
|
@@ -0,0 +1,292 @@
|
|
|
1
|
+
// src/core/translator.ts
|
|
2
|
+
import { createOpenRouter } from "@openrouter/ai-sdk-provider";
|
|
3
|
+
import { generateText, Output } from "ai";
|
|
4
|
+
import z from "zod";
|
|
5
|
+
var translate = async ({ text, lang, targetLang, apiKey, model }) => {
|
|
6
|
+
const token = apiKey || process.env.OPENROUTER_API_KEY || process.env.AI_API_KEY;
|
|
7
|
+
if (!token) {
|
|
8
|
+
throw new Error("Missing API Key. Please provide apiKey or set OPENROUTER_API_KEY / AI_API_KEY environment variable.");
|
|
9
|
+
}
|
|
10
|
+
const openrouter = createOpenRouter({
|
|
11
|
+
apiKey: token
|
|
12
|
+
});
|
|
13
|
+
const { output } = await generateText({
|
|
14
|
+
model: openrouter.languageModel(model || "google/gemini-2.5-flash"),
|
|
15
|
+
prompt: `Translate the following text from ${lang} to ${targetLang}. Return ONLY the translated text, no explanations or additional text.
|
|
16
|
+
|
|
17
|
+
Text to translate: ${text}`,
|
|
18
|
+
output: Output.object({
|
|
19
|
+
schema: z.object({
|
|
20
|
+
translatedText: z.string()
|
|
21
|
+
})
|
|
22
|
+
})
|
|
23
|
+
});
|
|
24
|
+
return output.translatedText;
|
|
25
|
+
};
|
|
26
|
+
|
|
27
|
+
// src/core/lock-manager.ts
|
|
28
|
+
import { createHash } from "crypto";
|
|
29
|
+
import { readFile, writeFile } from "fs/promises";
|
|
30
|
+
function generateHash(text) {
|
|
31
|
+
return createHash("sha256").update(text).digest("hex").substring(0, 16);
|
|
32
|
+
}
|
|
33
|
+
async function loadLock(lockPath) {
|
|
34
|
+
try {
|
|
35
|
+
const content = await readFile(lockPath, "utf-8");
|
|
36
|
+
return JSON.parse(content);
|
|
37
|
+
} catch (error) {
|
|
38
|
+
return {
|
|
39
|
+
version: "1.0.0",
|
|
40
|
+
lastSync: (/* @__PURE__ */ new Date()).toISOString(),
|
|
41
|
+
locales: {}
|
|
42
|
+
};
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
async function saveLock(lockPath, lock) {
|
|
46
|
+
lock.lastSync = (/* @__PURE__ */ new Date()).toISOString();
|
|
47
|
+
const content = JSON.stringify(lock, null, 2);
|
|
48
|
+
await writeFile(lockPath, content + "\n", "utf-8");
|
|
49
|
+
}
|
|
50
|
+
function detectChange(key, locale, sourceText, currentTranslation, lock) {
|
|
51
|
+
const localeData = lock.locales[locale];
|
|
52
|
+
const lockEntry = localeData?.[key];
|
|
53
|
+
if (!lockEntry) {
|
|
54
|
+
return {
|
|
55
|
+
key,
|
|
56
|
+
status: "new",
|
|
57
|
+
sourceText,
|
|
58
|
+
currentTranslation
|
|
59
|
+
};
|
|
60
|
+
}
|
|
61
|
+
const currentHash = generateHash(sourceText);
|
|
62
|
+
if (currentHash !== lockEntry.sourceHash) {
|
|
63
|
+
return {
|
|
64
|
+
key,
|
|
65
|
+
status: "modified",
|
|
66
|
+
sourceText,
|
|
67
|
+
currentTranslation,
|
|
68
|
+
previousSourceText: lockEntry.sourceText
|
|
69
|
+
};
|
|
70
|
+
}
|
|
71
|
+
return {
|
|
72
|
+
key,
|
|
73
|
+
status: "unchanged",
|
|
74
|
+
sourceText,
|
|
75
|
+
currentTranslation: lockEntry.translation
|
|
76
|
+
};
|
|
77
|
+
}
|
|
78
|
+
function updateLockEntry(lock, locale, key, sourceText, translation) {
|
|
79
|
+
if (!lock.locales[locale]) {
|
|
80
|
+
lock.locales[locale] = {};
|
|
81
|
+
}
|
|
82
|
+
lock.locales[locale][key] = {
|
|
83
|
+
sourceHash: generateHash(sourceText),
|
|
84
|
+
sourceText,
|
|
85
|
+
translation,
|
|
86
|
+
translatedAt: (/* @__PURE__ */ new Date()).toISOString()
|
|
87
|
+
};
|
|
88
|
+
}
|
|
89
|
+
function analyzeChanges(baseFlat, targetFlat, locale, lock) {
|
|
90
|
+
const result = {
|
|
91
|
+
new: [],
|
|
92
|
+
modified: [],
|
|
93
|
+
unchanged: []
|
|
94
|
+
};
|
|
95
|
+
for (const [key, sourceText] of baseFlat.entries()) {
|
|
96
|
+
const currentTranslation = targetFlat.get(key);
|
|
97
|
+
const change = detectChange(key, locale, sourceText, currentTranslation, lock);
|
|
98
|
+
result[change.status].push(change);
|
|
99
|
+
}
|
|
100
|
+
return result;
|
|
101
|
+
}
|
|
102
|
+
function cleanupLock(lock, locale, validKeys) {
|
|
103
|
+
if (!lock.locales[locale]) {
|
|
104
|
+
return 0;
|
|
105
|
+
}
|
|
106
|
+
const currentKeys = Object.keys(lock.locales[locale]);
|
|
107
|
+
let removedCount = 0;
|
|
108
|
+
for (const key of currentKeys) {
|
|
109
|
+
if (!validKeys.has(key)) {
|
|
110
|
+
delete lock.locales[locale][key];
|
|
111
|
+
removedCount++;
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
return removedCount;
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
// src/core/sync.ts
|
|
118
|
+
import { readFile as readFile2, writeFile as writeFile2 } from "fs/promises";
|
|
119
|
+
import { join } from "path";
|
|
120
|
+
import chalk from "chalk";
|
|
121
|
+
function flattenObject(obj, prefix = "") {
|
|
122
|
+
const result = /* @__PURE__ */ new Map();
|
|
123
|
+
for (const [key, value] of Object.entries(obj)) {
|
|
124
|
+
const fullPath = prefix ? `${prefix}.${key}` : key;
|
|
125
|
+
if (typeof value === "string") {
|
|
126
|
+
result.set(fullPath, value);
|
|
127
|
+
} else if (typeof value === "object" && value !== null) {
|
|
128
|
+
const nested = flattenObject(value, fullPath);
|
|
129
|
+
nested.forEach((val, path) => result.set(path, val));
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
return result;
|
|
133
|
+
}
|
|
134
|
+
function unflattenObject(flatMap) {
|
|
135
|
+
const result = {};
|
|
136
|
+
for (const [path, value] of flatMap.entries()) {
|
|
137
|
+
const keys = path.split(".");
|
|
138
|
+
let current = result;
|
|
139
|
+
for (let i = 0; i < keys.length - 1; i++) {
|
|
140
|
+
const key = keys[i];
|
|
141
|
+
if (!current[key]) {
|
|
142
|
+
current[key] = {};
|
|
143
|
+
}
|
|
144
|
+
current = current[key];
|
|
145
|
+
}
|
|
146
|
+
current[keys[keys.length - 1]] = value;
|
|
147
|
+
}
|
|
148
|
+
return result;
|
|
149
|
+
}
|
|
150
|
+
var TranslationService = class {
|
|
151
|
+
constructor(config) {
|
|
152
|
+
this.config = config;
|
|
153
|
+
}
|
|
154
|
+
async sync() {
|
|
155
|
+
console.log(chalk.bold("\u{1F30D} AI Translation Synchronization System"));
|
|
156
|
+
console.log(chalk.dim("\u2550".repeat(50)));
|
|
157
|
+
console.log("");
|
|
158
|
+
try {
|
|
159
|
+
console.log(`\u2713 Locales found: ${chalk.green(this.config.locales.join(", "))}`);
|
|
160
|
+
console.log(`\u2713 Base locale: ${chalk.cyan(this.config.defaultLocale)}`);
|
|
161
|
+
const basePath = join(this.config.messagesDir, `${this.config.defaultLocale}.json`);
|
|
162
|
+
let baseContent;
|
|
163
|
+
try {
|
|
164
|
+
baseContent = await readFile2(basePath, "utf-8");
|
|
165
|
+
} catch (e) {
|
|
166
|
+
throw new Error(`Could not read base locale file at ${basePath}`);
|
|
167
|
+
}
|
|
168
|
+
const baseObj = JSON.parse(baseContent);
|
|
169
|
+
const baseFlat = flattenObject(baseObj);
|
|
170
|
+
console.log(`\u{1F4D6} Loading ${this.config.defaultLocale}.json (${baseFlat.size} keys total)`);
|
|
171
|
+
console.log("\u{1F510} Loading lock file...");
|
|
172
|
+
const lock = await loadLock(this.config.lockFilePath);
|
|
173
|
+
const targetLocales = this.config.locales.filter((l) => l !== this.config.defaultLocale);
|
|
174
|
+
for (const targetLocale of targetLocales) {
|
|
175
|
+
await this.syncLocale(targetLocale, baseFlat, lock);
|
|
176
|
+
}
|
|
177
|
+
await saveLock(this.config.lockFilePath, lock);
|
|
178
|
+
console.log(`
|
|
179
|
+
\u{1F510} Lock file updated at ${this.config.lockFilePath}`);
|
|
180
|
+
console.log(chalk.dim("\n\u2550".repeat(50)));
|
|
181
|
+
console.log(chalk.green("\u2705 Synchronization complete!\n"));
|
|
182
|
+
} catch (error) {
|
|
183
|
+
console.error(chalk.red("\n\u274C Error during synchronization:"), error);
|
|
184
|
+
throw error;
|
|
185
|
+
}
|
|
186
|
+
}
|
|
187
|
+
async syncLocale(targetLocale, baseFlat, lock) {
|
|
188
|
+
console.log(`
|
|
189
|
+
\u{1F50E} Synchronizing: ${chalk.yellow(targetLocale)}`);
|
|
190
|
+
console.log(chalk.dim("\u2500".repeat(50)));
|
|
191
|
+
const targetPath = join(this.config.messagesDir, `${targetLocale}.json`);
|
|
192
|
+
let targetObj = {};
|
|
193
|
+
try {
|
|
194
|
+
const targetContent = await readFile2(targetPath, "utf-8");
|
|
195
|
+
targetObj = JSON.parse(targetContent);
|
|
196
|
+
} catch {
|
|
197
|
+
console.log(chalk.yellow(`\u26A0\uFE0F File ${targetLocale}.json does not exist, creating new one
|
|
198
|
+
`));
|
|
199
|
+
}
|
|
200
|
+
const targetFlat = flattenObject(targetObj);
|
|
201
|
+
const changes = analyzeChanges(baseFlat, targetFlat, targetLocale, lock);
|
|
202
|
+
const validKeys = new Set(baseFlat.keys());
|
|
203
|
+
const obsoleteKeys = [];
|
|
204
|
+
for (const key of targetFlat.keys()) {
|
|
205
|
+
if (!validKeys.has(key)) {
|
|
206
|
+
obsoleteKeys.push(key);
|
|
207
|
+
}
|
|
208
|
+
}
|
|
209
|
+
console.log("\n\u{1F4CA} Change analysis:");
|
|
210
|
+
console.log(` \u{1F4DD} ${changes.new.length} new key(s)`);
|
|
211
|
+
console.log(` \u{1F504} ${changes.modified.length} modified key(s)`);
|
|
212
|
+
console.log(` \u2705 ${changes.unchanged.length} unchanged key(s)`);
|
|
213
|
+
console.log(` \u{1F5D1}\uFE0F ${obsoleteKeys.length} obsolete key(s)`);
|
|
214
|
+
const toTranslate = [...changes.new, ...changes.modified];
|
|
215
|
+
if (toTranslate.length === 0 && obsoleteKeys.length === 0) {
|
|
216
|
+
console.log(chalk.green(`
|
|
217
|
+
\u2705 ${targetLocale}.json is already fully synchronized`));
|
|
218
|
+
return;
|
|
219
|
+
}
|
|
220
|
+
if (changes.new.length > 0) {
|
|
221
|
+
console.log("\n\u{1F4DD} New keys:");
|
|
222
|
+
changes.new.forEach(({ key }) => console.log(chalk.green(` + ${key}`)));
|
|
223
|
+
}
|
|
224
|
+
if (changes.modified.length > 0) {
|
|
225
|
+
console.log("\n\u{1F504} Modified keys:");
|
|
226
|
+
changes.modified.forEach(({ key, previousSourceText, sourceText }) => {
|
|
227
|
+
console.log(chalk.yellow(` ~ ${key}`));
|
|
228
|
+
console.log(chalk.dim(` Old: "${previousSourceText}"`));
|
|
229
|
+
console.log(chalk.dim(` New: "${sourceText}"`));
|
|
230
|
+
});
|
|
231
|
+
}
|
|
232
|
+
if (toTranslate.length > 0) {
|
|
233
|
+
console.log(chalk.blue(`
|
|
234
|
+
\u{1F916} Translating ${toTranslate.length} keys with AI...`));
|
|
235
|
+
console.log(chalk.dim("\u2500".repeat(50)));
|
|
236
|
+
}
|
|
237
|
+
let translatedCount = 0;
|
|
238
|
+
if (toTranslate.length > 0) {
|
|
239
|
+
for (const change of toTranslate) {
|
|
240
|
+
try {
|
|
241
|
+
const translatedValue = await translate({
|
|
242
|
+
text: change.sourceText,
|
|
243
|
+
lang: this.config.defaultLocale,
|
|
244
|
+
targetLang: targetLocale,
|
|
245
|
+
apiKey: this.config.apiKey,
|
|
246
|
+
model: this.config.model
|
|
247
|
+
});
|
|
248
|
+
targetFlat.set(change.key, translatedValue);
|
|
249
|
+
updateLockEntry(lock, targetLocale, change.key, change.sourceText, translatedValue);
|
|
250
|
+
translatedCount++;
|
|
251
|
+
const icon = change.status === "new" ? "\u2795" : "\u{1F504}";
|
|
252
|
+
console.log(` ${icon} ${change.key}`);
|
|
253
|
+
console.log(chalk.dim(` "${change.sourceText}"`));
|
|
254
|
+
console.log(chalk.cyan(` \u2192 "${translatedValue}"`));
|
|
255
|
+
} catch (error) {
|
|
256
|
+
console.error(chalk.red(` \u274C Error translating ${change.key}:`), error);
|
|
257
|
+
if (change.currentTranslation) {
|
|
258
|
+
targetFlat.set(change.key, change.currentTranslation);
|
|
259
|
+
}
|
|
260
|
+
}
|
|
261
|
+
}
|
|
262
|
+
}
|
|
263
|
+
for (const change of changes.unchanged) {
|
|
264
|
+
if (change.currentTranslation) {
|
|
265
|
+
targetFlat.set(change.key, change.currentTranslation);
|
|
266
|
+
}
|
|
267
|
+
}
|
|
268
|
+
for (const key of obsoleteKeys) {
|
|
269
|
+
targetFlat.delete(key);
|
|
270
|
+
}
|
|
271
|
+
cleanupLock(lock, targetLocale, validKeys);
|
|
272
|
+
const updatedObj = unflattenObject(targetFlat);
|
|
273
|
+
const updatedContent = JSON.stringify(updatedObj, null, 2);
|
|
274
|
+
await writeFile2(targetPath, updatedContent + "\n", "utf-8");
|
|
275
|
+
console.log(chalk.green(`
|
|
276
|
+
\u{1F4BE} ${targetPath} updated`));
|
|
277
|
+
}
|
|
278
|
+
};
|
|
279
|
+
|
|
280
|
+
export {
|
|
281
|
+
translate,
|
|
282
|
+
generateHash,
|
|
283
|
+
loadLock,
|
|
284
|
+
saveLock,
|
|
285
|
+
detectChange,
|
|
286
|
+
updateLockEntry,
|
|
287
|
+
analyzeChanges,
|
|
288
|
+
cleanupLock,
|
|
289
|
+
flattenObject,
|
|
290
|
+
unflattenObject,
|
|
291
|
+
TranslationService
|
|
292
|
+
};
|
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
|