@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 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