@i18n-auto/core 0.1.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/LICENSE +21 -0
- package/README.md +496 -0
- package/dist/cli.cjs +597 -0
- package/dist/cli.cjs.map +1 -0
- package/dist/cli.d.cts +1 -0
- package/dist/cli.d.ts +1 -0
- package/dist/cli.js +591 -0
- package/dist/cli.js.map +1 -0
- package/dist/index.cjs +172 -0
- package/dist/index.cjs.map +1 -0
- package/dist/index.d.cts +108 -0
- package/dist/index.d.ts +108 -0
- package/dist/index.js +167 -0
- package/dist/index.js.map +1 -0
- package/package.json +84 -0
package/dist/cli.cjs
ADDED
|
@@ -0,0 +1,597 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
'use strict';
|
|
3
|
+
|
|
4
|
+
var path = require('path');
|
|
5
|
+
var commander = require('commander');
|
|
6
|
+
var tinyglobby = require('tinyglobby');
|
|
7
|
+
var promises = require('fs/promises');
|
|
8
|
+
var parser = require('@babel/parser');
|
|
9
|
+
var traverseImport = require('@babel/traverse');
|
|
10
|
+
var translate = require('@google-cloud/translate');
|
|
11
|
+
var crypto = require('crypto');
|
|
12
|
+
var fs = require('fs');
|
|
13
|
+
|
|
14
|
+
function _interopDefault (e) { return e && e.__esModule ? e : { default: e }; }
|
|
15
|
+
|
|
16
|
+
var traverseImport__default = /*#__PURE__*/_interopDefault(traverseImport);
|
|
17
|
+
|
|
18
|
+
var traverse = typeof traverseImport__default.default === "function" ? traverseImport__default.default : traverseImport__default.default.default;
|
|
19
|
+
var FUNCTION_NAME = "i18nTranslate";
|
|
20
|
+
var PACKAGE_NAME = "@i18n-auto/core";
|
|
21
|
+
async function extractTranslations(filePaths) {
|
|
22
|
+
const entries = [];
|
|
23
|
+
const seenKeys = /* @__PURE__ */ new Set();
|
|
24
|
+
for (const filePath of filePaths) {
|
|
25
|
+
const fileEntries = await extractFromFile(filePath);
|
|
26
|
+
for (const entry of fileEntries) {
|
|
27
|
+
if (seenKeys.has(entry.key)) {
|
|
28
|
+
continue;
|
|
29
|
+
}
|
|
30
|
+
seenKeys.add(entry.key);
|
|
31
|
+
entries.push(entry);
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
return entries;
|
|
35
|
+
}
|
|
36
|
+
async function extractFromFile(filePath) {
|
|
37
|
+
const code = await promises.readFile(filePath, "utf-8");
|
|
38
|
+
if (!code.includes(FUNCTION_NAME)) {
|
|
39
|
+
return [];
|
|
40
|
+
}
|
|
41
|
+
let ast;
|
|
42
|
+
try {
|
|
43
|
+
ast = parser.parse(code, {
|
|
44
|
+
sourceType: "module",
|
|
45
|
+
plugins: ["typescript", "jsx", "decorators-legacy"]
|
|
46
|
+
});
|
|
47
|
+
} catch (error) {
|
|
48
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
49
|
+
console.warn(`[i18n-auto] Failed to parse ${filePath}: ${message}`);
|
|
50
|
+
return [];
|
|
51
|
+
}
|
|
52
|
+
if (!hasI18nImport(ast)) {
|
|
53
|
+
return [];
|
|
54
|
+
}
|
|
55
|
+
const entries = [];
|
|
56
|
+
traverse(ast, {
|
|
57
|
+
CallExpression(path) {
|
|
58
|
+
const result = extractCall(path.node, filePath);
|
|
59
|
+
if (result) {
|
|
60
|
+
entries.push(result);
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
});
|
|
64
|
+
return entries;
|
|
65
|
+
}
|
|
66
|
+
function hasI18nImport(ast) {
|
|
67
|
+
let found = false;
|
|
68
|
+
traverse(ast, {
|
|
69
|
+
ImportDeclaration(path) {
|
|
70
|
+
if (path.node.source.value !== PACKAGE_NAME) {
|
|
71
|
+
return;
|
|
72
|
+
}
|
|
73
|
+
for (const specifier of path.node.specifiers) {
|
|
74
|
+
if (specifier.type === "ImportSpecifier" && specifier.imported.type === "Identifier" && specifier.imported.name === FUNCTION_NAME) {
|
|
75
|
+
found = true;
|
|
76
|
+
path.stop();
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
});
|
|
81
|
+
return found;
|
|
82
|
+
}
|
|
83
|
+
function extractCall(node, filePath) {
|
|
84
|
+
if (node.callee.type !== "Identifier" || node.callee.name !== FUNCTION_NAME) {
|
|
85
|
+
return null;
|
|
86
|
+
}
|
|
87
|
+
const args = node.arguments;
|
|
88
|
+
const line = node.loc?.start.line ?? 0;
|
|
89
|
+
if (args.length < 2 || args[0].type !== "StringLiteral") {
|
|
90
|
+
console.warn(
|
|
91
|
+
`[i18n-auto] Skipping i18nTranslate() at ${filePath}:${line} \u2014 first argument is not a string literal`
|
|
92
|
+
);
|
|
93
|
+
return null;
|
|
94
|
+
}
|
|
95
|
+
if (args[1].type !== "ObjectExpression") {
|
|
96
|
+
console.warn(
|
|
97
|
+
`[i18n-auto] Skipping i18nTranslate() at ${filePath}:${line} \u2014 second argument is not an object`
|
|
98
|
+
);
|
|
99
|
+
return null;
|
|
100
|
+
}
|
|
101
|
+
const key = extractKeyFromObject(args[1]);
|
|
102
|
+
if (!key) {
|
|
103
|
+
console.warn(
|
|
104
|
+
`[i18n-auto] Skipping i18nTranslate() at ${filePath}:${line} \u2014 missing or non-literal 'key' property`
|
|
105
|
+
);
|
|
106
|
+
return null;
|
|
107
|
+
}
|
|
108
|
+
const lock = extractLockFromObject(args[1]);
|
|
109
|
+
return {
|
|
110
|
+
text: args[0].value,
|
|
111
|
+
key,
|
|
112
|
+
lock,
|
|
113
|
+
filePath,
|
|
114
|
+
line
|
|
115
|
+
};
|
|
116
|
+
}
|
|
117
|
+
function extractKeyFromObject(node) {
|
|
118
|
+
for (const prop of node.properties) {
|
|
119
|
+
if (prop.type === "ObjectProperty" && prop.key.type === "Identifier" && prop.key.name === "key" && prop.value.type === "StringLiteral") {
|
|
120
|
+
return prop.value.value;
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
return null;
|
|
124
|
+
}
|
|
125
|
+
function extractLockFromObject(node) {
|
|
126
|
+
for (const prop of node.properties) {
|
|
127
|
+
if (prop.type === "ObjectProperty" && prop.key.type === "Identifier" && prop.key.name === "lock" && prop.value.type === "BooleanLiteral") {
|
|
128
|
+
return prop.value.value;
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
return false;
|
|
132
|
+
}
|
|
133
|
+
async function extractConfig(filePath) {
|
|
134
|
+
const code = await promises.readFile(filePath, "utf-8");
|
|
135
|
+
if (!code.includes("initI18n")) {
|
|
136
|
+
return null;
|
|
137
|
+
}
|
|
138
|
+
let ast;
|
|
139
|
+
try {
|
|
140
|
+
ast = parser.parse(code, {
|
|
141
|
+
sourceType: "module",
|
|
142
|
+
plugins: ["typescript", "jsx", "decorators-legacy"]
|
|
143
|
+
});
|
|
144
|
+
} catch (error) {
|
|
145
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
146
|
+
console.warn(`[i18n-auto] Failed to parse ${filePath}: ${message}`);
|
|
147
|
+
return null;
|
|
148
|
+
}
|
|
149
|
+
if (!hasInitImport(ast)) {
|
|
150
|
+
return null;
|
|
151
|
+
}
|
|
152
|
+
return findInitConfig(ast);
|
|
153
|
+
}
|
|
154
|
+
function hasInitImport(ast) {
|
|
155
|
+
let found = false;
|
|
156
|
+
traverse(ast, {
|
|
157
|
+
ImportDeclaration(path) {
|
|
158
|
+
if (path.node.source.value !== PACKAGE_NAME) {
|
|
159
|
+
return;
|
|
160
|
+
}
|
|
161
|
+
for (const specifier of path.node.specifiers) {
|
|
162
|
+
if (specifier.type === "ImportSpecifier" && specifier.imported.type === "Identifier" && specifier.imported.name === "initI18n") {
|
|
163
|
+
found = true;
|
|
164
|
+
path.stop();
|
|
165
|
+
}
|
|
166
|
+
}
|
|
167
|
+
}
|
|
168
|
+
});
|
|
169
|
+
return found;
|
|
170
|
+
}
|
|
171
|
+
function findInitConfig(ast) {
|
|
172
|
+
let config = null;
|
|
173
|
+
traverse(ast, {
|
|
174
|
+
CallExpression(path) {
|
|
175
|
+
if (path.node.callee.type !== "Identifier" || path.node.callee.name !== "initI18n") {
|
|
176
|
+
return;
|
|
177
|
+
}
|
|
178
|
+
const args = path.node.arguments;
|
|
179
|
+
if (args.length === 0 || args[0].type !== "ObjectExpression") {
|
|
180
|
+
return;
|
|
181
|
+
}
|
|
182
|
+
config = parseConfigObject(args[0]);
|
|
183
|
+
path.stop();
|
|
184
|
+
}
|
|
185
|
+
});
|
|
186
|
+
return config;
|
|
187
|
+
}
|
|
188
|
+
function parseConfigObject(node) {
|
|
189
|
+
const config = {};
|
|
190
|
+
for (const prop of node.properties) {
|
|
191
|
+
if (prop.type !== "ObjectProperty" || prop.key.type !== "Identifier") {
|
|
192
|
+
continue;
|
|
193
|
+
}
|
|
194
|
+
const name = prop.key.name;
|
|
195
|
+
const value = prop.value;
|
|
196
|
+
if (value.type === "StringLiteral") {
|
|
197
|
+
config[name] = value.value;
|
|
198
|
+
continue;
|
|
199
|
+
}
|
|
200
|
+
if (value.type === "ArrayExpression") {
|
|
201
|
+
const items = [];
|
|
202
|
+
for (const element of value.elements) {
|
|
203
|
+
if (element && element.type === "StringLiteral") {
|
|
204
|
+
items.push(element.value);
|
|
205
|
+
}
|
|
206
|
+
}
|
|
207
|
+
if (items.length > 0) {
|
|
208
|
+
config[name] = items;
|
|
209
|
+
}
|
|
210
|
+
continue;
|
|
211
|
+
}
|
|
212
|
+
if (value.type === "MemberExpression" && value.object.type === "MemberExpression" && value.object.object.type === "Identifier" && value.object.object.name === "process" && value.object.property.type === "Identifier" && value.object.property.name === "env" && value.property.type === "Identifier") {
|
|
213
|
+
if (name === "apiKey") {
|
|
214
|
+
config["apiKeyEnvVar"] = value.property.name;
|
|
215
|
+
}
|
|
216
|
+
continue;
|
|
217
|
+
}
|
|
218
|
+
}
|
|
219
|
+
return config;
|
|
220
|
+
}
|
|
221
|
+
var GOOGLE_BATCH_LIMIT = 128;
|
|
222
|
+
var GoogleProvider = class {
|
|
223
|
+
client;
|
|
224
|
+
/**
|
|
225
|
+
* @param apiKey - Google Cloud API key for authentication
|
|
226
|
+
*/
|
|
227
|
+
constructor(apiKey) {
|
|
228
|
+
this.client = new translate.v2.Translate({ key: apiKey });
|
|
229
|
+
}
|
|
230
|
+
/**
|
|
231
|
+
* Translates an array of strings from one locale to another using Google Translate.
|
|
232
|
+
* Automatically chunks large arrays to respect API limits.
|
|
233
|
+
* @param texts - Array of source strings to translate
|
|
234
|
+
* @param from - Source locale (e.g., 'en')
|
|
235
|
+
* @param to - Target locale (e.g., 'ur')
|
|
236
|
+
* @returns Array of translated strings in the same order
|
|
237
|
+
* @throws Error if the Google Translate API call fails
|
|
238
|
+
*/
|
|
239
|
+
async translateBatch(texts, from, to) {
|
|
240
|
+
if (texts.length === 0) {
|
|
241
|
+
return [];
|
|
242
|
+
}
|
|
243
|
+
const protected_ = texts.map((text) => this.protectPlaceholders(text));
|
|
244
|
+
const protectedTexts = protected_.map((p) => p.protected);
|
|
245
|
+
const chunks = this.chunk(protectedTexts, GOOGLE_BATCH_LIMIT);
|
|
246
|
+
const results = [];
|
|
247
|
+
for (const chunk of chunks) {
|
|
248
|
+
try {
|
|
249
|
+
const [translations] = await this.client.translate(chunk, { from, to });
|
|
250
|
+
const translated = Array.isArray(translations) ? translations : [translations];
|
|
251
|
+
results.push(...translated);
|
|
252
|
+
} catch (error) {
|
|
253
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
254
|
+
console.error(`[i18n-auto] Google Translate API error: ${message}`);
|
|
255
|
+
throw new Error(`[i18n-auto] Failed to translate batch: ${message}`);
|
|
256
|
+
}
|
|
257
|
+
}
|
|
258
|
+
return results.map(
|
|
259
|
+
(text, i) => this.restorePlaceholders(text, protected_[i].placeholders)
|
|
260
|
+
);
|
|
261
|
+
}
|
|
262
|
+
/**
|
|
263
|
+
* Replaces {{placeholder}} patterns with XML tokens that Google Translate preserves.
|
|
264
|
+
* @param text - The source string containing {{placeholder}} patterns
|
|
265
|
+
* @returns The protected string and an array of original placeholders
|
|
266
|
+
*/
|
|
267
|
+
protectPlaceholders(text) {
|
|
268
|
+
const placeholders = [];
|
|
269
|
+
const protectedText = text.replace(/\{\{\s*\w+\s*\}\}/g, (match) => {
|
|
270
|
+
const index = placeholders.length;
|
|
271
|
+
placeholders.push(match);
|
|
272
|
+
return `<x${index}>`;
|
|
273
|
+
});
|
|
274
|
+
return { protected: protectedText, placeholders };
|
|
275
|
+
}
|
|
276
|
+
/**
|
|
277
|
+
* Restores XML tokens back to original {{placeholder}} patterns.
|
|
278
|
+
* @param text - The translated string containing XML tokens
|
|
279
|
+
* @param placeholders - The original placeholder patterns in order
|
|
280
|
+
* @returns The string with {{placeholder}} patterns restored
|
|
281
|
+
*/
|
|
282
|
+
restorePlaceholders(text, placeholders) {
|
|
283
|
+
return text.replace(/<x(\d+)>/g, (_, index) => {
|
|
284
|
+
return placeholders[Number(index)] ?? `<x${index}>`;
|
|
285
|
+
});
|
|
286
|
+
}
|
|
287
|
+
/**
|
|
288
|
+
* Splits an array into smaller chunks of a given size.
|
|
289
|
+
* @param items - Array to split
|
|
290
|
+
* @param size - Maximum chunk size
|
|
291
|
+
* @returns Array of chunks
|
|
292
|
+
*/
|
|
293
|
+
chunk(items, size) {
|
|
294
|
+
const chunks = [];
|
|
295
|
+
for (let i = 0; i < items.length; i += size) {
|
|
296
|
+
chunks.push(items.slice(i, i + size));
|
|
297
|
+
}
|
|
298
|
+
return chunks;
|
|
299
|
+
}
|
|
300
|
+
};
|
|
301
|
+
|
|
302
|
+
// src/providers/provider.factory.ts
|
|
303
|
+
var SUPPORTED_PROVIDERS = ["google"];
|
|
304
|
+
function getSupportedProviders() {
|
|
305
|
+
return [...SUPPORTED_PROVIDERS];
|
|
306
|
+
}
|
|
307
|
+
function createProvider(name, apiKey) {
|
|
308
|
+
switch (name) {
|
|
309
|
+
case "google":
|
|
310
|
+
return new GoogleProvider(apiKey);
|
|
311
|
+
default:
|
|
312
|
+
throw new Error(
|
|
313
|
+
`[i18n-auto] Provider '${name}' is not supported. Available: ${SUPPORTED_PROVIDERS.join(", ")}`
|
|
314
|
+
);
|
|
315
|
+
}
|
|
316
|
+
}
|
|
317
|
+
var FileCache = class {
|
|
318
|
+
cachePath;
|
|
319
|
+
/**
|
|
320
|
+
* @param cachePath - Directory path where locale JSON files are stored
|
|
321
|
+
*/
|
|
322
|
+
constructor(cachePath) {
|
|
323
|
+
this.cachePath = cachePath;
|
|
324
|
+
}
|
|
325
|
+
/**
|
|
326
|
+
* Loads all translations for a given locale from its JSON file.
|
|
327
|
+
* @param locale - The locale to load (e.g., 'ur', 'fr')
|
|
328
|
+
* @returns Key-value map of translations, or empty object if file doesn't exist
|
|
329
|
+
*/
|
|
330
|
+
async load(locale) {
|
|
331
|
+
const filePath = this.getFilePath(locale);
|
|
332
|
+
if (!fs.existsSync(filePath)) {
|
|
333
|
+
return {};
|
|
334
|
+
}
|
|
335
|
+
const content = await promises.readFile(filePath, "utf-8");
|
|
336
|
+
return JSON.parse(content);
|
|
337
|
+
}
|
|
338
|
+
/**
|
|
339
|
+
* Synchronously loads all translations for a given locale.
|
|
340
|
+
* Used by the runtime t() method for fast, synchronous lookups.
|
|
341
|
+
* @param locale - The locale to load
|
|
342
|
+
* @returns Key-value map of translations, or empty object if file doesn't exist
|
|
343
|
+
*/
|
|
344
|
+
loadSync(locale) {
|
|
345
|
+
const filePath = this.getFilePath(locale);
|
|
346
|
+
if (!fs.existsSync(filePath)) {
|
|
347
|
+
return {};
|
|
348
|
+
}
|
|
349
|
+
const content = fs.readFileSync(filePath, "utf-8");
|
|
350
|
+
return JSON.parse(content);
|
|
351
|
+
}
|
|
352
|
+
/**
|
|
353
|
+
* Saves translations for a locale to its JSON file.
|
|
354
|
+
* Creates the directory if it doesn't exist.
|
|
355
|
+
* @param locale - The locale to save
|
|
356
|
+
* @param data - Key-value map of translations
|
|
357
|
+
*/
|
|
358
|
+
async save(locale, data) {
|
|
359
|
+
await promises.mkdir(this.cachePath, { recursive: true });
|
|
360
|
+
const filePath = this.getFilePath(locale);
|
|
361
|
+
const content = JSON.stringify(data, null, 2);
|
|
362
|
+
await promises.writeFile(filePath, content, "utf-8");
|
|
363
|
+
}
|
|
364
|
+
/**
|
|
365
|
+
* Returns the set of keys that already have translations for a locale.
|
|
366
|
+
* Used by CLI to determine which strings are new and need translating.
|
|
367
|
+
* @param locale - The locale to check
|
|
368
|
+
* @returns Set of existing translation keys
|
|
369
|
+
*/
|
|
370
|
+
async getExistingKeys(locale) {
|
|
371
|
+
const data = await this.load(locale);
|
|
372
|
+
return new Set(Object.keys(data));
|
|
373
|
+
}
|
|
374
|
+
/**
|
|
375
|
+
* Computes a short SHA-256 hash of a string.
|
|
376
|
+
* Used to detect source text changes without storing full text.
|
|
377
|
+
* @param text - The source text to hash
|
|
378
|
+
* @returns 8-character hex hash
|
|
379
|
+
*/
|
|
380
|
+
static hash(text) {
|
|
381
|
+
return crypto.createHash("sha256").update(text).digest("hex").slice(0, 8);
|
|
382
|
+
}
|
|
383
|
+
/**
|
|
384
|
+
* Loads the metadata file (.meta.json) containing source text hashes.
|
|
385
|
+
* Used by CLI to detect when source text has changed for an existing key.
|
|
386
|
+
* @returns Key-value map of translation key to source text hash
|
|
387
|
+
*/
|
|
388
|
+
async loadMeta() {
|
|
389
|
+
const filePath = path.join(this.cachePath, ".meta.json");
|
|
390
|
+
if (!fs.existsSync(filePath)) {
|
|
391
|
+
return {};
|
|
392
|
+
}
|
|
393
|
+
const content = await promises.readFile(filePath, "utf-8");
|
|
394
|
+
return JSON.parse(content);
|
|
395
|
+
}
|
|
396
|
+
/**
|
|
397
|
+
* Saves the metadata file (.meta.json) with updated source text hashes.
|
|
398
|
+
* @param data - Key-value map of translation key to source text hash
|
|
399
|
+
*/
|
|
400
|
+
async saveMeta(data) {
|
|
401
|
+
await promises.mkdir(this.cachePath, { recursive: true });
|
|
402
|
+
const filePath = path.join(this.cachePath, ".meta.json");
|
|
403
|
+
const content = JSON.stringify(data, null, 2);
|
|
404
|
+
await promises.writeFile(filePath, content, "utf-8");
|
|
405
|
+
}
|
|
406
|
+
/**
|
|
407
|
+
* Builds the file path for a locale's JSON file.
|
|
408
|
+
* @param locale - The locale identifier
|
|
409
|
+
* @returns Full file path
|
|
410
|
+
*/
|
|
411
|
+
getFilePath(locale) {
|
|
412
|
+
return path.join(this.cachePath, `${locale}.json`);
|
|
413
|
+
}
|
|
414
|
+
};
|
|
415
|
+
|
|
416
|
+
// src/cli.ts
|
|
417
|
+
var program = new commander.Command();
|
|
418
|
+
program.name("i18n-auto").description("Auto-translate i18n strings by scanning source code").version("0.1.0");
|
|
419
|
+
program.command("translate").description("Scan source files and translate extracted strings").requiredOption("--config <path>", "Path to the file containing initI18n() call").option("--force", "Re-translate all strings, ignoring cache").action(async (options) => {
|
|
420
|
+
try {
|
|
421
|
+
await runTranslate(options.config, options.force ?? false);
|
|
422
|
+
} catch (error) {
|
|
423
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
424
|
+
console.error(`[i18n-auto] ${message}`);
|
|
425
|
+
process.exit(1);
|
|
426
|
+
}
|
|
427
|
+
});
|
|
428
|
+
program.command("stats").description("Show translation statistics per locale").requiredOption("--config <path>", "Path to the file containing initI18n() call").action(async (options) => {
|
|
429
|
+
try {
|
|
430
|
+
await runStats(options.config);
|
|
431
|
+
} catch (error) {
|
|
432
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
433
|
+
console.error(`[i18n-auto] ${message}`);
|
|
434
|
+
process.exit(1);
|
|
435
|
+
}
|
|
436
|
+
});
|
|
437
|
+
program.parse();
|
|
438
|
+
var SOURCE_EXTENSIONS = ["ts", "tsx", "js", "jsx"];
|
|
439
|
+
async function readConfig(configPath) {
|
|
440
|
+
const absolutePath = path.resolve(configPath);
|
|
441
|
+
const config = await extractConfig(absolutePath);
|
|
442
|
+
if (!config) {
|
|
443
|
+
throw new Error(
|
|
444
|
+
`No initI18n() call found in ${configPath}. Make sure the file imports initI18n from '@i18n-auto/core'.`
|
|
445
|
+
);
|
|
446
|
+
}
|
|
447
|
+
const provider = config.provider ?? "google";
|
|
448
|
+
const supported = getSupportedProviders();
|
|
449
|
+
if (!supported.includes(provider)) {
|
|
450
|
+
throw new Error(
|
|
451
|
+
`Provider '${provider}' is not supported. Available: ${supported.join(", ")}`
|
|
452
|
+
);
|
|
453
|
+
}
|
|
454
|
+
if (!config.apiKeyEnvVar) {
|
|
455
|
+
throw new Error(
|
|
456
|
+
"No apiKey found in initI18n() config. Use: apiKey: process.env.YOUR_API_KEY"
|
|
457
|
+
);
|
|
458
|
+
}
|
|
459
|
+
const apiKey = process.env[config.apiKeyEnvVar];
|
|
460
|
+
if (!apiKey) {
|
|
461
|
+
throw new Error(
|
|
462
|
+
`Environment variable '${config.apiKeyEnvVar}' is not set. Set it before running the CLI.`
|
|
463
|
+
);
|
|
464
|
+
}
|
|
465
|
+
const locales = config.locales ?? [];
|
|
466
|
+
if (locales.length === 0) {
|
|
467
|
+
throw new Error(
|
|
468
|
+
"No locales found in initI18n() config. Add: locales: ['ur', 'fr', 'de']"
|
|
469
|
+
);
|
|
470
|
+
}
|
|
471
|
+
return {
|
|
472
|
+
provider,
|
|
473
|
+
apiKey,
|
|
474
|
+
defaultLocale: config.defaultLocale ?? "en",
|
|
475
|
+
cachePath: path.resolve(config.cachePath ?? "./locales"),
|
|
476
|
+
locales,
|
|
477
|
+
sourcePath: path.resolve(config.sourcePath ?? "./src")
|
|
478
|
+
};
|
|
479
|
+
}
|
|
480
|
+
async function runTranslate(configPath, force) {
|
|
481
|
+
const config = await readConfig(configPath);
|
|
482
|
+
const patterns = SOURCE_EXTENSIONS.map((ext) => `**/*.${ext}`);
|
|
483
|
+
const filePaths = await tinyglobby.glob(patterns, {
|
|
484
|
+
cwd: config.sourcePath,
|
|
485
|
+
absolute: true,
|
|
486
|
+
ignore: ["**/node_modules/**", "**/dist/**"]
|
|
487
|
+
});
|
|
488
|
+
if (filePaths.length === 0) {
|
|
489
|
+
console.warn(`[i18n-auto] No source files found in ${config.sourcePath}`);
|
|
490
|
+
return;
|
|
491
|
+
}
|
|
492
|
+
console.warn(`[i18n-auto] Scanning ${filePaths.length} files...`);
|
|
493
|
+
const entries = await extractTranslations(filePaths);
|
|
494
|
+
if (entries.length === 0) {
|
|
495
|
+
console.warn("[i18n-auto] No i18nTranslate() calls found");
|
|
496
|
+
return;
|
|
497
|
+
}
|
|
498
|
+
const lockedEntries = entries.filter((entry) => entry.lock);
|
|
499
|
+
const translatableEntries = entries.filter((entry) => !entry.lock);
|
|
500
|
+
console.warn(`[i18n-auto] Found ${translatableEntries.length} translatable entries`);
|
|
501
|
+
if (lockedEntries.length > 0) {
|
|
502
|
+
console.warn(`[i18n-auto] Found ${lockedEntries.length} locked key(s) (manual translation)`);
|
|
503
|
+
}
|
|
504
|
+
const provider = createProvider(config.provider, config.apiKey);
|
|
505
|
+
const cache = new FileCache(config.cachePath);
|
|
506
|
+
const meta = await cache.loadMeta();
|
|
507
|
+
const updatedMeta = { ...meta };
|
|
508
|
+
for (const entry of lockedEntries) {
|
|
509
|
+
updatedMeta[entry.key] = "locked";
|
|
510
|
+
}
|
|
511
|
+
let totalTranslated = 0;
|
|
512
|
+
for (const locale of config.locales) {
|
|
513
|
+
if (locale === config.defaultLocale) {
|
|
514
|
+
console.warn(`[i18n-auto] Skipping source locale '${locale}'`);
|
|
515
|
+
continue;
|
|
516
|
+
}
|
|
517
|
+
const existingData = await cache.load(locale);
|
|
518
|
+
let lockedAdded = 0;
|
|
519
|
+
for (const entry of lockedEntries) {
|
|
520
|
+
if (!(entry.key in existingData)) {
|
|
521
|
+
existingData[entry.key] = "";
|
|
522
|
+
lockedAdded++;
|
|
523
|
+
}
|
|
524
|
+
}
|
|
525
|
+
if (lockedAdded > 0) {
|
|
526
|
+
await cache.save(locale, existingData);
|
|
527
|
+
console.warn(`[i18n-auto] ${locale}: Added ${lockedAdded} locked key(s) for manual translation`);
|
|
528
|
+
}
|
|
529
|
+
let entriesToTranslate;
|
|
530
|
+
if (force) {
|
|
531
|
+
entriesToTranslate = translatableEntries;
|
|
532
|
+
console.warn(`[i18n-auto] ${locale}: Force mode \u2014 re-translating all ${translatableEntries.length} strings...`);
|
|
533
|
+
} else {
|
|
534
|
+
const existingKeys = await cache.getExistingKeys(locale);
|
|
535
|
+
const newEntries = translatableEntries.filter((entry) => !existingKeys.has(entry.key));
|
|
536
|
+
const changedEntries = translatableEntries.filter(
|
|
537
|
+
(entry) => existingKeys.has(entry.key) && meta[entry.key] !== void 0 && meta[entry.key] !== "locked" && meta[entry.key] !== FileCache.hash(entry.text)
|
|
538
|
+
);
|
|
539
|
+
entriesToTranslate = [...newEntries, ...changedEntries];
|
|
540
|
+
if (newEntries.length > 0) {
|
|
541
|
+
console.warn(`[i18n-auto] ${locale}: Translating ${newEntries.length} new strings...`);
|
|
542
|
+
}
|
|
543
|
+
if (changedEntries.length > 0) {
|
|
544
|
+
console.warn(`[i18n-auto] ${locale}: Re-translating ${changedEntries.length} changed strings...`);
|
|
545
|
+
}
|
|
546
|
+
}
|
|
547
|
+
if (entriesToTranslate.length === 0 && lockedAdded === 0) {
|
|
548
|
+
console.warn(`[i18n-auto] ${locale}: All ${entries.length} strings up to date`);
|
|
549
|
+
continue;
|
|
550
|
+
}
|
|
551
|
+
if (entriesToTranslate.length === 0) {
|
|
552
|
+
continue;
|
|
553
|
+
}
|
|
554
|
+
const textsToTranslate = entriesToTranslate.map((entry) => entry.text);
|
|
555
|
+
const translated = await provider.translateBatch(textsToTranslate, config.defaultLocale, locale);
|
|
556
|
+
for (let i = 0; i < entriesToTranslate.length; i++) {
|
|
557
|
+
existingData[entriesToTranslate[i].key] = translated[i];
|
|
558
|
+
}
|
|
559
|
+
await cache.save(locale, existingData);
|
|
560
|
+
console.warn(`[i18n-auto] ${locale}: Translated ${entriesToTranslate.length} strings`);
|
|
561
|
+
totalTranslated += entriesToTranslate.length;
|
|
562
|
+
}
|
|
563
|
+
for (const entry of translatableEntries) {
|
|
564
|
+
updatedMeta[entry.key] = FileCache.hash(entry.text);
|
|
565
|
+
}
|
|
566
|
+
await cache.saveMeta(updatedMeta);
|
|
567
|
+
console.warn(
|
|
568
|
+
`[i18n-auto] Done! Translated ${totalTranslated} strings for ${config.locales.length} locale(s)`
|
|
569
|
+
);
|
|
570
|
+
}
|
|
571
|
+
async function runStats(configPath) {
|
|
572
|
+
const config = await readConfig(configPath);
|
|
573
|
+
const cache = new FileCache(config.cachePath);
|
|
574
|
+
const patterns = SOURCE_EXTENSIONS.map((ext) => `**/*.${ext}`);
|
|
575
|
+
const filePaths = await tinyglobby.glob(patterns, {
|
|
576
|
+
cwd: config.sourcePath,
|
|
577
|
+
absolute: true,
|
|
578
|
+
ignore: ["**/node_modules/**", "**/dist/**"]
|
|
579
|
+
});
|
|
580
|
+
const entries = await extractTranslations(filePaths);
|
|
581
|
+
const totalKeys = entries.length;
|
|
582
|
+
console.warn(`[i18n-auto] Total translation keys found: ${totalKeys}`);
|
|
583
|
+
console.warn("");
|
|
584
|
+
for (const locale of config.locales) {
|
|
585
|
+
if (locale === config.defaultLocale) {
|
|
586
|
+
continue;
|
|
587
|
+
}
|
|
588
|
+
const existingKeys = await cache.getExistingKeys(locale);
|
|
589
|
+
const translated = existingKeys.size;
|
|
590
|
+
const missing = totalKeys - translated;
|
|
591
|
+
console.warn(
|
|
592
|
+
` ${locale}: ${translated}/${totalKeys} translated` + (missing > 0 ? ` (${missing} missing)` : " \u2713")
|
|
593
|
+
);
|
|
594
|
+
}
|
|
595
|
+
}
|
|
596
|
+
//# sourceMappingURL=cli.cjs.map
|
|
597
|
+
//# sourceMappingURL=cli.cjs.map
|