i18nizer 0.5.0 → 0.5.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.
@@ -8,6 +8,8 @@ import { extractTexts } from "../core/ast/extract-text.js";
8
8
  import { insertUseTranslations } from "../core/ast/insert-user-translations.js";
9
9
  import { parseFile } from "../core/ast/parse-file.js";
10
10
  import { replaceTempKeysWithT } from "../core/ast/replace-text-with-text.js";
11
+ import { TranslationCache } from "../core/cache/translation-cache.js";
12
+ import { Deduplicator } from "../core/deduplication/deduplicator.js";
11
13
  import { parseAiJson } from "../core/i18n/parse-ai-json.js";
12
14
  import { saveSourceFile } from "../core/i18n/sace-source-file.js";
13
15
  import { writeLocaleFiles } from "../core/i18n/write-files.js";
@@ -30,6 +32,11 @@ export default class Extract extends Command {
30
32
  char: "p",
31
33
  description: "AI provider (gemini | huggingface), optional",
32
34
  }),
35
+ "use-ai-keys": Flags.boolean({
36
+ allowNo: true,
37
+ default: true,
38
+ description: "Use AI to generate human-readable keys (default: true)",
39
+ }),
33
40
  };
34
41
  async run() {
35
42
  const { args, flags } = await this.parse(Extract);
@@ -51,17 +58,58 @@ export default class Extract extends Command {
51
58
  this.log(chalk.yellow("⚠️ No translatable texts found."));
52
59
  return;
53
60
  }
54
- this.log(`🔍 Found ${chalk.green(texts.length)} translatable texts`);
55
61
  const componentName = path
56
62
  .basename(args.file)
57
63
  .replace(/\.(tsx|jsx)$/, "");
58
64
  const locales = flags.locales.split(",");
65
+ // Count unique strings
66
+ const uniqueTexts = new Set(texts.map((t) => t.text));
67
+ this.log(`🔍 Extracting strings... (${chalk.green(texts.length)} found, ${chalk.green(uniqueTexts.size)} unique)`);
68
+ // Initialize cache and deduplicator
69
+ const cwd = process.cwd();
70
+ const projectDir = path.join(cwd, ".i18nizer");
71
+ const cache = new TranslationCache(projectDir);
72
+ const deduplicator = new Deduplicator(cache, flags["use-ai-keys"], provider);
73
+ // Step 1: Generate keys (batch, cache-first)
74
+ let keySpinner;
75
+ if (flags["use-ai-keys"]) {
76
+ keySpinner = ora("🧠 Generating keys (batch)...").start();
77
+ }
78
+ else {
79
+ this.log("🔑 Generating keys (deterministic)...");
80
+ }
81
+ const textList = texts.map((t) => t.text);
82
+ const deduplicationResults = await deduplicator.deduplicateBatch(textList, componentName, false // Standalone extraction: generate fresh keys without reusing from cache
83
+ );
84
+ const stats = deduplicator.getStats();
85
+ if (keySpinner) {
86
+ keySpinner.succeed(`🧠 Generated ${chalk.green(uniqueTexts.size)} keys`);
87
+ }
88
+ else {
89
+ this.log(`✅ Generated ${chalk.green(uniqueTexts.size)} keys (deterministic)`);
90
+ }
91
+ this.log(`💾 Cache hits: ${chalk.green(stats.cacheHits)}`);
92
+ this.log(`🤖 AI requests used: ${chalk.green(stats.aiRequestsUsed)}`);
93
+ // Map results back to texts
94
+ const mappedTexts = texts.map((t) => {
95
+ const result = deduplicationResults.get(t.text);
96
+ return {
97
+ isCached: result.isCached,
98
+ key: result.key,
99
+ node: t.node,
100
+ placeholders: t.placeholders,
101
+ tempKey: t.tempKey,
102
+ text: t.text,
103
+ };
104
+ });
105
+ // Step 2: Generate translations (batch, separate from key generation)
59
106
  const spinner = ora(`💬 Generating translations with ${provider}...`).start();
60
107
  try {
108
+ // Build prompt with the keys we already generated
61
109
  const prompt = buildPrompt({
62
110
  componentName,
63
111
  locales,
64
- texts: texts.map((t) => ({ tempKey: t.tempKey, text: t.text })),
112
+ texts: mappedTexts.map((t) => ({ tempKey: t.tempKey, text: t.text })),
65
113
  });
66
114
  const raw = await generateTranslations(prompt, provider);
67
115
  if (!raw)
@@ -70,32 +118,37 @@ export default class Extract extends Command {
70
118
  const namespace = componentName;
71
119
  const jsonNamespace = json[namespace] || {};
72
120
  const i18nJson = {};
73
- for (const [, translations] of Object.entries(jsonNamespace)) {
74
- const { key } = translations;
75
- i18nJson[key] = {};
121
+ // Use our generated keys, not the AI's keys
122
+ for (const mapped of mappedTexts) {
123
+ const translations = jsonNamespace[mapped.tempKey];
124
+ if (!translations) {
125
+ throw new Error(`No translations found for tempKey: ${mapped.tempKey}`);
126
+ }
127
+ i18nJson[mapped.key] = {};
76
128
  for (const locale of locales) {
77
- i18nJson[key][locale] = translations[locale];
129
+ i18nJson[mapped.key][locale] = translations[locale];
78
130
  }
131
+ // Update cache
132
+ cache.set({
133
+ componentName,
134
+ key: mapped.key,
135
+ locales: i18nJson[mapped.key],
136
+ text: mapped.text,
137
+ });
79
138
  }
80
139
  writeLocaleFiles(componentName, { [componentName]: i18nJson }, locales);
81
140
  spinner.succeed(`✅ Translations generated with ${provider}`);
82
- // const aiGeneratedKeys = Object.keys(json[namespace] || {});
83
- const mapped = texts.map(t => {
84
- const translations = jsonNamespace[t.tempKey];
85
- if (!translations) {
86
- throw new Error(`No translations found for tempKey: ${t.tempKey}`);
87
- }
88
- return {
89
- key: translations.key,
90
- node: t.node,
91
- placeholders: t.placeholders,
92
- tempKey: t.tempKey
93
- };
94
- });
95
- this.log(`🔗 Mapped ${chalk.green(mapped.length)} texts to keys`);
141
+ this.log(`🔗 Mapped ${chalk.green(mappedTexts.length)} texts to keys`);
96
142
  insertUseTranslations(sourceFile, componentName);
97
- replaceTempKeysWithT(mapped);
143
+ replaceTempKeysWithT(mappedTexts.map((m) => ({
144
+ key: m.key,
145
+ node: m.node,
146
+ placeholders: m.placeholders,
147
+ tempKey: m.tempKey,
148
+ })));
98
149
  saveSourceFile(sourceFile);
150
+ // Save cache
151
+ cache.save();
99
152
  this.log(chalk.green("✨ Component rewritten with t() calls"));
100
153
  this.log(chalk.green(`🌍 JSON files generated using ${provider}`));
101
154
  }
@@ -123,10 +123,13 @@ export default class Translate extends Command {
123
123
  continue;
124
124
  }
125
125
  totalExtracted += texts.length;
126
- // Deduplicate and assign keys (now async)
126
+ // Deduplicate and assign keys using batch processing
127
+ const textList = texts.map((t) => t.text);
127
128
  // eslint-disable-next-line no-await-in-loop
128
- const mappedTexts = await Promise.all(texts.map(async (t) => {
129
- const result = await deduplicator.deduplicate(t.text, componentName, config.behavior.detectDuplicates);
129
+ const deduplicationResults = await deduplicator.deduplicateBatch(textList, componentName, config.behavior.detectDuplicates);
130
+ // Map results back to texts
131
+ const mappedTexts = texts.map((t) => {
132
+ const result = deduplicationResults.get(t.text);
130
133
  if (result.isReused)
131
134
  totalReused++;
132
135
  if (result.isCached)
@@ -139,7 +142,7 @@ export default class Translate extends Command {
139
142
  tempKey: t.tempKey,
140
143
  text: t.text,
141
144
  };
142
- }));
145
+ });
143
146
  // Build translations JSON
144
147
  const i18nJson = {};
145
148
  for (const mapped of mappedTexts) {
@@ -241,14 +244,18 @@ export default class Translate extends Command {
241
244
  if (!flags["dry-run"]) {
242
245
  cache.save();
243
246
  }
247
+ // Get statistics from deduplicator
248
+ const stats = deduplicator.getStats();
244
249
  // Summary
245
250
  this.log("");
246
251
  this.log(chalk.green("🎉 Translation complete!"));
247
252
  this.log(chalk.cyan("📊 Summary:"));
248
253
  this.log(` Files processed: ${chalk.bold(filesToProcess.length)}`);
249
254
  this.log(` Strings extracted: ${chalk.bold(totalExtracted)}`);
255
+ this.log(` Unique strings: ${chalk.bold(stats.uniqueStrings)}`);
250
256
  this.log(` Keys reused: ${chalk.bold(totalReused)}`);
251
257
  this.log(` Cached translations: ${chalk.bold(totalCached)}`);
258
+ this.log(` AI requests (keys): ${chalk.bold(stats.aiRequestsUsed)}`);
252
259
  this.log("");
253
260
  }
254
261
  }
@@ -1,7 +1,77 @@
1
1
  import { generateTranslations } from "./client.js";
2
+ /**
3
+ * Generate English camelCase keys for multiple texts in a single AI request
4
+ * This is the preferred method for efficiency
5
+ *
6
+ * @param texts - Array of texts to generate keys for
7
+ * @param provider - AI provider to use
8
+ * @returns Map of text to generated key, or empty map on failure
9
+ */
10
+ export async function generateEnglishKeysBatch(texts, provider = "huggingface") {
11
+ if (texts.length === 0) {
12
+ return new Map();
13
+ }
14
+ const prompt = `
15
+ You are an i18n key generator. Generate concise, meaningful camelCase keys in English for the following texts.
16
+
17
+ RULES:
18
+ - Output ONLY a JSON object mapping each text to its camelCase key
19
+ - Keys should be 2-4 words maximum
20
+ - Keys must be in English
21
+ - Keys should describe the content/purpose
22
+ - Do NOT include explanations, comments, or any other text
23
+ - Output format: { "original text": "camelCaseKey" }
24
+
25
+ TEXTS:
26
+ ${texts.map((t, i) => `${i + 1}. ${JSON.stringify(t)}`).join("\n")}
27
+
28
+ EXAMPLES:
29
+ { "Bienvenido de nuevo": "welcomeBack", "Por favor inicia sesión": "pleaseSignIn" }
30
+
31
+ OUTPUT (JSON only):`.trim();
32
+ try {
33
+ const response = await generateTranslations(prompt, provider);
34
+ if (!response)
35
+ return new Map();
36
+ // Extract JSON from response (handle cases where AI adds markdown or extra text)
37
+ let jsonText = response.trim();
38
+ // Remove markdown code blocks if present
39
+ if (jsonText.startsWith("```")) {
40
+ jsonText = jsonText.replace(/^```(?:json)?\s*\n/, "").replace(/\n```\s*$/, "");
41
+ }
42
+ const parsed = JSON.parse(jsonText);
43
+ const result = new Map();
44
+ // Validate and add each key
45
+ for (const [text, key] of Object.entries(parsed)) {
46
+ // Validate it's a valid camelCase identifier
47
+ if (typeof key === "string" && /^[a-z][a-zA-Z0-9]*$/.test(key)) {
48
+ result.set(text, key);
49
+ }
50
+ }
51
+ return result;
52
+ }
53
+ catch (error) {
54
+ console.error("❌ Error generating English keys with AI:", error);
55
+ return new Map();
56
+ }
57
+ }
2
58
  /**
3
59
  * Generate an English camelCase key from text using AI
4
60
  * This ensures keys are always in English regardless of source language
61
+ *
62
+ * @deprecated Use generateEnglishKeysBatch for better efficiency. This method
63
+ * will be removed in a future version. To migrate, collect all texts and call
64
+ * generateEnglishKeysBatch once:
65
+ *
66
+ * @example
67
+ * // Before:
68
+ * const key1 = await generateEnglishKey(text1);
69
+ * const key2 = await generateEnglishKey(text2);
70
+ *
71
+ * // After:
72
+ * const keyMap = await generateEnglishKeysBatch([text1, text2]);
73
+ * const key1 = keyMap.get(text1);
74
+ * const key2 = keyMap.get(text2);
5
75
  */
6
76
  export async function generateEnglishKey(text, provider = "huggingface") {
7
77
  const prompt = `
@@ -14,7 +84,7 @@ RULES:
14
84
  - Key should describe the content/purpose
15
85
  - Do NOT include quotes, explanations, or any other text
16
86
 
17
- TEXT: "${text.replaceAll('"', '\\"')}"
87
+ TEXT: ${JSON.stringify(text)}
18
88
 
19
89
  EXAMPLES:
20
90
  TEXT: "Bienvenido de nuevo" -> welcomeBack
@@ -1,4 +1,4 @@
1
- import { generateEnglishKey } from "../ai/generate-english-key.js";
1
+ import { generateEnglishKeysBatch } from "../ai/generate-english-key.js";
2
2
  import { generateKey, generateUniqueKey } from "../i18n/generate-key.js";
3
3
  /**
4
4
  * Deduplicate strings and assign deterministic keys
@@ -8,45 +8,96 @@ export class Deduplicator {
8
8
  provider;
9
9
  useAiForKeys;
10
10
  usedKeys = new Set();
11
+ seenTexts = new Set(); // Track all texts seen across all batches
12
+ stats = {
13
+ aiRequestsUsed: 0,
14
+ cacheHits: 0,
15
+ totalStrings: 0,
16
+ uniqueStrings: 0,
17
+ };
11
18
  constructor(cache, useAiForKeys = true, provider = "huggingface") {
12
19
  this.cache = cache;
13
20
  this.provider = provider;
14
21
  this.useAiForKeys = useAiForKeys;
15
22
  }
16
23
  /**
17
- * Process a text and return a deterministic key
18
- * Reuses existing keys if the text was seen before
24
+ * Process multiple texts in batch and return deterministic keys
25
+ * This is the preferred method for efficiency with AI
19
26
  */
20
- async deduplicate(text, componentName, detectDuplicates) {
21
- // Check cache first
22
- const cached = this.cache.get(text);
23
- if (cached && detectDuplicates) {
24
- // Reuse existing key from cache
25
- this.usedKeys.add(cached.key);
26
- return {
27
- isCached: true,
28
- isReused: true,
29
- key: cached.key,
30
- };
27
+ async deduplicateBatch(texts, componentName, detectDuplicates) {
28
+ this.stats.totalStrings += texts.length;
29
+ const results = new Map();
30
+ const uncachedTexts = [];
31
+ // First pass: check cache and collect uncached texts
32
+ for (const text of texts) {
33
+ const cached = this.cache.get(text);
34
+ if (cached && detectDuplicates) {
35
+ // Reuse existing key from cache
36
+ this.usedKeys.add(cached.key);
37
+ results.set(text, {
38
+ isCached: true,
39
+ isReused: true,
40
+ key: cached.key,
41
+ });
42
+ this.stats.cacheHits++;
43
+ }
44
+ else {
45
+ uncachedTexts.push(text);
46
+ }
31
47
  }
32
- // Generate a new deterministic key
33
- let baseKey;
34
- if (this.useAiForKeys && !cached) {
35
- // Try to generate English key using AI
36
- const aiKey = await generateEnglishKey(text, this.provider);
37
- baseKey = aiKey ?? generateKey(text);
48
+ // Track unique strings across all batches
49
+ for (const text of texts) {
50
+ if (!this.seenTexts.has(text)) {
51
+ this.seenTexts.add(text);
52
+ this.stats.uniqueStrings++;
53
+ }
38
54
  }
39
- else {
40
- // Use deterministic key generation (fallback or when AI is disabled)
41
- baseKey = generateKey(text);
55
+ // Second pass: generate keys for uncached texts
56
+ if (uncachedTexts.length > 0) {
57
+ let aiKeyMap = new Map();
58
+ if (this.useAiForKeys) {
59
+ // Batch AI request for all uncached texts
60
+ aiKeyMap = await generateEnglishKeysBatch(uncachedTexts, this.provider);
61
+ if (aiKeyMap.size > 0) {
62
+ this.stats.aiRequestsUsed++;
63
+ }
64
+ }
65
+ // Generate keys for remaining uncached texts
66
+ for (const text of uncachedTexts) {
67
+ const aiKey = aiKeyMap.get(text);
68
+ const baseKey = aiKey ?? generateKey(text);
69
+ const uniqueKey = generateUniqueKey(baseKey, this.usedKeys);
70
+ this.usedKeys.add(uniqueKey);
71
+ results.set(text, {
72
+ isCached: false,
73
+ isReused: false,
74
+ key: uniqueKey,
75
+ });
76
+ }
42
77
  }
43
- const uniqueKey = generateUniqueKey(baseKey, this.usedKeys);
44
- this.usedKeys.add(uniqueKey);
45
- return {
46
- isCached: false,
47
- isReused: false,
48
- key: uniqueKey,
49
- };
78
+ return results;
79
+ }
80
+ /**
81
+ * Process a single text and return a deterministic key
82
+ * Reuses existing keys if the text was seen before
83
+ *
84
+ * @deprecated Use deduplicateBatch for better efficiency. This method will be
85
+ * removed in v1.0. To migrate, collect texts and call deduplicateBatch once:
86
+ *
87
+ * @example
88
+ * // Before:
89
+ * const result1 = await deduplicator.deduplicate(text1, comp, true);
90
+ * const result2 = await deduplicator.deduplicate(text2, comp, true);
91
+ *
92
+ * // After:
93
+ * const results = await deduplicator.deduplicateBatch([text1, text2], comp, true);
94
+ * const result1 = results.get(text1);
95
+ * const result2 = results.get(text2);
96
+ */
97
+ async deduplicate(text, componentName, detectDuplicates) {
98
+ // Use batch method with single item for consistency
99
+ const results = await this.deduplicateBatch([text], componentName, detectDuplicates);
100
+ return results.get(text);
50
101
  }
51
102
  /**
52
103
  * Get all used keys
@@ -54,4 +105,22 @@ export class Deduplicator {
54
105
  getUsedKeys() {
55
106
  return new Set(this.usedKeys);
56
107
  }
108
+ /**
109
+ * Get deduplication statistics
110
+ */
111
+ getStats() {
112
+ return { ...this.stats };
113
+ }
114
+ /**
115
+ * Reset statistics
116
+ */
117
+ resetStats() {
118
+ this.stats = {
119
+ aiRequestsUsed: 0,
120
+ cacheHits: 0,
121
+ totalStrings: 0,
122
+ uniqueStrings: 0,
123
+ };
124
+ this.seenTexts.clear();
125
+ }
57
126
  }
@@ -27,6 +27,12 @@
27
27
  "hasDynamicHelp": false,
28
28
  "multiple": false,
29
29
  "type": "option"
30
+ },
31
+ "use-ai-keys": {
32
+ "description": "Use AI to generate human-readable keys (default: true)",
33
+ "name": "use-ai-keys",
34
+ "allowNo": true,
35
+ "type": "boolean"
30
36
  }
31
37
  },
32
38
  "hasDynamicHelp": false,
@@ -301,5 +307,5 @@
301
307
  ]
302
308
  }
303
309
  },
304
- "version": "0.5.0"
310
+ "version": "0.5.1"
305
311
  }
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "i18nizer",
3
3
  "description": "CLI to extract texts from JSX/TSX and generate i18n JSON with AI translations",
4
- "version": "0.5.0",
4
+ "version": "0.5.1",
5
5
  "author": "Yoannis Sanchez Soto",
6
6
  "bin": "./bin/run.js",
7
7
  "bugs": "https://github.com/yossTheDev/i18nizer/issues",