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