i18nizer 0.6.2 → 0.7.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 CHANGED
@@ -13,16 +13,44 @@
13
13
 
14
14
  ---
15
15
 
16
- **i18nizer** is a developer-first CLI that **extracts translatable strings from JSX/TSX files**, automatically generates **i18n JSON files**, and **rewrites your components to use `t()`**.
16
+ **i18nizer automates the boring parts of i18n.**
17
17
 
18
- It is designed to be fast, scriptable, CI-friendly, and completely **independent from your project configuration**.
18
+ If your project already uses **i18next** or **next-intl**, i18nizer:
19
19
 
20
- ### Supported AI Providers
20
+ - extracts hardcoded strings from JSX/TSX
21
+ - generates i18n JSON files using **AI-assisted translations**
22
+ - creates readable English keys (**AI-powered & cached**)
23
+ - rewrites your components to use `t("key")`
24
+
25
+ No runtime, no lock-in, no SaaS.
26
+ Just a CLI that fits into your existing development workflow.
27
+
28
+ ## 🤖 How translations work
29
+
30
+ i18nizer uses AI providers (OpenAI, Gemini, Hugging Face) to generate translations
31
+ at development time.
32
+
33
+ - Translations are generated once and stored as plain JSON files
34
+ - Output is fully editable and version-controlled
35
+ - No AI is required at runtime
36
+ - Cached translations avoid repeated AI requests
37
+
38
+ You own the result — i18nizer only automates the process.
39
+
40
+ ## Who is this for?
41
+
42
+ - React / Next.js developers already using i18next or next-intl
43
+ - Teams tired of manually extracting strings and managing i18n JSONs
44
+ - Projects that want automation without changing runtime behavior
45
+
46
+ ## Supported AI Providers
21
47
 
22
48
  - **OpenAI**
23
49
  - **Google Gemini**
24
50
  - **Hugging Face (DeepSeek)**
25
51
 
52
+ Used for development-time translations and key generation.
53
+
26
54
  ---
27
55
 
28
56
  ## 🚀 Installation
@@ -62,6 +90,7 @@ i18nizer start
62
90
  ```
63
91
 
64
92
  This launches an **interactive setup** that will:
93
+
65
94
  - 🔍 Auto-detect your framework (Next.js or React)
66
95
  - 🔍 Auto-detect your i18n library (next-intl, react-i18next, i18next)
67
96
  - ❓ Ask you to confirm or change the detected settings
@@ -93,6 +122,7 @@ i18nizer start --framework custom --i18n custom
93
122
  ```
94
123
 
95
124
  **Available options:**
125
+
96
126
  - `--framework`: `nextjs`, `react`, `custom`
97
127
  - `--i18n`: `next-intl`, `react-i18next`, `i18next`, `custom`
98
128
  - `--yes`, `-y`: Skip interactive prompts
@@ -133,6 +163,7 @@ i18nizer regenerate
133
163
  ```
134
164
 
135
165
  This command regenerates the `i18n/messages.generated.ts` file by scanning all JSON files in your messages directory. Use this when:
166
+
136
167
  - You manually add or remove translation JSON files
137
168
  - You rename JSON files
138
169
  - The aggregator becomes out of sync with your messages
@@ -251,6 +282,68 @@ Legacy standalone mode (without `i18nizer start`):
251
282
 
252
283
  ## ⚙️ Configuration
253
284
 
285
+ The `i18nizer.config.yml` file controls all aspects of how i18nizer processes your project. Here's a complete reference:
286
+
287
+ ### Complete Configuration Reference
288
+
289
+ ```yaml
290
+ # Framework and i18n library settings
291
+ framework: react # nextjs | react | custom
292
+ i18nLibrary: react-i18next # next-intl | react-i18next | i18next | custom
293
+
294
+ # AI provider configuration
295
+ ai:
296
+ provider: openai # openai | gemini | huggingface
297
+ model: gpt-4 # AI model to use for translations
298
+
299
+ # Directory paths
300
+ paths:
301
+ src: src # Source code directory
302
+ i18n: i18n # i18n output directory
303
+
304
+ # i18n function configuration
305
+ i18n:
306
+ function: t # Translation function name
307
+ import:
308
+ source: react-i18next # Package to import from
309
+ named: useTranslation # Named import
310
+
311
+ # Translation file settings
312
+ messages:
313
+ path: messages # Where to store translation JSON files
314
+ defaultLocale: en # Default locale
315
+ locales: # List of supported locales
316
+ - en
317
+ - es
318
+ format: json # Output format (currently only json)
319
+
320
+ # Behavior settings
321
+ behavior:
322
+ detectDuplicates: true # Reuse keys for duplicate strings across components
323
+ opinionatedStructure: true # Use opinionated file structure
324
+ autoInjectT: true # Auto-inject translation hooks (disable for Next.js Server Components)
325
+ useAiForKeys: true # Use AI to generate English keys
326
+ allowedFunctions: # Functions whose string arguments should be translated
327
+ - alert
328
+ - confirm
329
+ - prompt
330
+ allowedMemberFunctions: # Member functions whose string arguments should be translated
331
+ - toast.error
332
+ - toast.info
333
+ - toast.success
334
+ - toast.warn
335
+ allowedProps: # JSX props that should be translated
336
+ - alt
337
+ - aria-label
338
+ - aria-placeholder
339
+ - helperText
340
+ - label
341
+ - placeholder
342
+ - text
343
+ - title
344
+ - tooltip
345
+ ```
346
+
254
347
  ### Translation Function Injection (`autoInjectT`)
255
348
 
256
349
  i18nizer can automatically inject translation hooks into your components:
@@ -262,6 +355,7 @@ const t = useTranslations("ComponentName");
262
355
  This behavior is controlled by `behavior.autoInjectT` in `i18nizer.config.yml`:
263
356
 
264
357
  **Next.js Projects** (disabled by default):
358
+
265
359
  ```yaml
266
360
  behavior:
267
361
  autoInjectT: false # Disabled to avoid breaking Server Components
@@ -272,6 +366,7 @@ behavior:
272
366
  - You manually add the translation hook where appropriate
273
367
 
274
368
  **React Projects** (enabled by default):
369
+
275
370
  ```yaml
276
371
  behavior:
277
372
  autoInjectT: true # Safe for Client Components
@@ -281,6 +376,7 @@ behavior:
281
376
  - Works seamlessly with React components
282
377
 
283
378
  **Why disabled for Next.js?**
379
+
284
380
  - Automatically detecting Server vs Client Components is ambiguous
285
381
  - Injecting hooks in Server Components causes runtime errors
286
382
  - User has full control over translation function placement
@@ -297,12 +393,14 @@ behavior:
297
393
  ```
298
394
 
299
395
  **Benefits:**
396
+
300
397
  - **Consistent keys**: Keys are always in English, even if your source text is in Spanish, French, German, etc.
301
398
  - **Readable**: `welcomeBack` instead of `bienvenidoDeNuevo`
302
399
  - **Stable**: Keys are cached per source text for deterministic behavior across runs
303
400
  - **Minimal diffs**: Same source text always produces the same key
304
401
 
305
402
  **How it works:**
403
+
306
404
  1. First run: AI generates an English key for each source text
307
405
  2. Key is cached with the source text hash
308
406
  3. Subsequent runs: Cached key is reused (no AI call needed)
@@ -311,12 +409,14 @@ behavior:
311
409
  **Example:**
312
410
 
313
411
  Source text (Spanish):
412
+
314
413
  ```tsx
315
414
  <h1>Bienvenido de nuevo</h1>
316
415
  <button>Iniciar sesión</button>
317
416
  ```
318
417
 
319
418
  Generated keys (English):
419
+
320
420
  ```json
321
421
  {
322
422
  "welcomeBack": "Bienvenido de nuevo",
@@ -335,6 +435,86 @@ behavior:
335
435
 
336
436
  Note: Keys will be in the source language (e.g., `bienvenidoDeNuevo` for Spanish text).
337
437
 
438
+ ### AI Provider and Model Configuration
439
+
440
+ i18nizer supports multiple AI providers for translations. You can configure the provider and model in `i18nizer.config.yml`:
441
+
442
+ ```yaml
443
+ ai:
444
+ provider: openai # openai | gemini | huggingface
445
+ model: gpt-4 # AI model name
446
+ ```
447
+
448
+ **Supported Providers:**
449
+
450
+ - **OpenAI** (default): Uses OpenAI API with models like `gpt-4`, `gpt-4o-mini`, etc.
451
+ - **Google Gemini**: Uses Google's Gemini API with models like `gemini-2.5-flash`, `gemini-pro`
452
+ - **Hugging Face**: Uses Hugging Face Inference API with models like `deepseek-ai/DeepSeek-V3.2`
453
+
454
+ **Default Configuration:**
455
+
456
+ ```yaml
457
+ ai:
458
+ provider: openai
459
+ model: gpt-4
460
+ ```
461
+
462
+ **Example Configurations:**
463
+
464
+ For Google Gemini:
465
+ ```yaml
466
+ ai:
467
+ provider: gemini
468
+ model: gemini-2.5-flash
469
+ ```
470
+
471
+ For Hugging Face:
472
+ ```yaml
473
+ ai:
474
+ provider: huggingface
475
+ model: deepseek-ai/DeepSeek-V3.2
476
+ ```
477
+
478
+ **Note:** You still need to set up API keys using:
479
+ ```bash
480
+ i18nizer keys --setOpenAI <YOUR_OPENAI_API_KEY>
481
+ i18nizer keys --setGemini <YOUR_GEMINI_API_KEY>
482
+ i18nizer keys --setHF <YOUR_HUGGING_FACE_API_KEY>
483
+ ```
484
+
485
+ The provider setting in config file will be used by default, but you can override it on a per-command basis using the `--provider` flag:
486
+ ```bash
487
+ i18nizer translate <file> --provider gemini
488
+ ```
489
+
490
+ ### Paths Configuration
491
+
492
+ Configure default paths for your source code and i18n files in `i18nizer.config.yml`:
493
+
494
+ ```yaml
495
+ paths:
496
+ src: src # Source directory
497
+ i18n: i18n # i18n output directory
498
+ ```
499
+
500
+ **Default Configuration:**
501
+
502
+ ```yaml
503
+ paths:
504
+ src: src
505
+ i18n: i18n
506
+ ```
507
+
508
+ **Custom Example:**
509
+
510
+ ```yaml
511
+ paths:
512
+ src: source
513
+ i18n: locales
514
+ ```
515
+
516
+ These paths serve as defaults and can help organize your project structure. The `messages.path` setting (which specifies where translation JSON files are stored) is separate from `paths.i18n`.
517
+
338
518
  ---
339
519
 
340
520
  ## ✨ Features
@@ -350,6 +530,8 @@ Note: Keys will be in the source language (e.g., `bienvenidoDeNuevo` for Spanish
350
530
  - **Configurable behavior** (allowed functions, props, member functions)
351
531
  - **Dry-run mode** to preview changes
352
532
  - **JSON output preview** with `--show-json`
533
+ - **Pluralization documentation** - comprehensive guide for ICU message format
534
+ - **Rich text formatting documentation** - patterns for JSX within translations
353
535
  - Project-wide or single-file translation
354
536
  - Works with **JSX & TSX**
355
537
  - Rewrites components automatically (`t("key")`)
@@ -380,15 +562,138 @@ Note: Keys will be in the source language (e.g., `bienvenidoDeNuevo` for Spanish
380
562
 
381
563
  ---
382
564
 
565
+ ## 🎨 Advanced i18n Patterns
566
+
567
+ i18nizer extracts translatable strings and generates standard i18n JSON files. For advanced patterns like pluralization and rich text formatting, you can leverage the built-in features of i18next and next-intl.
568
+
569
+ ### Pluralization Support
570
+
571
+ Both i18next and next-intl support ICU message format for handling plurals. After i18nizer extracts your strings, you can enhance them with plural rules.
572
+
573
+ **Before i18nizer:**
574
+ ```tsx
575
+ <p>{count} {count === 1 ? 'item' : 'items'} in cart</p>
576
+ ```
577
+
578
+ **After i18nizer extraction:**
579
+ ```tsx
580
+ <p>{t('itemCount', { count })} {t('itemsLabel', { count })}</p>
581
+ ```
582
+
583
+ **Manual enhancement to use pluralization:**
584
+ ```tsx
585
+ <p>{t('itemsInCart', { count })}</p>
586
+ ```
587
+
588
+ **Translation with ICU plural format:**
589
+ ```json
590
+ {
591
+ "itemsInCart": "{count, plural, =0 {No items} one {# item} other {# items}} in cart"
592
+ }
593
+ ```
594
+
595
+ **How plurals work:**
596
+ - `=0` - Exact match for zero
597
+ - `one` - Singular form (1 item in English)
598
+ - `other` - Plural form (2+ items)
599
+ - `#` - Placeholder for the count number
600
+
601
+ **Additional plural categories** (language-dependent):
602
+ - `zero`, `one`, `two`, `few`, `many`, `other`
603
+
604
+ ### Rich Text Formatting
605
+
606
+ When you need JSX elements within translated text (like links or bold text), both i18next and next-intl provide rich text formatting capabilities.
607
+
608
+ **Before i18nizer:**
609
+ ```tsx
610
+ <p>By clicking Sign Up, you agree to our <a href="/terms">Terms of Service</a></p>
611
+ ```
612
+
613
+ **After i18nizer extraction:**
614
+ ```tsx
615
+ <p>{t('byClickingSignUpYouAgree')} <a href="/terms">{t('termsOfService')}</a></p>
616
+ ```
617
+
618
+ **Manual enhancement with rich text (next-intl):**
619
+ ```tsx
620
+ <p>{t.rich('signUpAgreement', {
621
+ terms: (chunks) => <a href="/terms">{chunks}</a>
622
+ })}</p>
623
+ ```
624
+
625
+ **Translation:**
626
+ ```json
627
+ {
628
+ "signUpAgreement": "By clicking Sign Up, you agree to our <terms>Terms of Service</terms>"
629
+ }
630
+ ```
631
+
632
+ **Using rich text with i18next:**
633
+ ```tsx
634
+ import { Trans } from 'react-i18next';
635
+
636
+ <p>
637
+ <Trans i18nKey="signUpAgreement">
638
+ By clicking Sign Up, you agree to our <a href="/terms">Terms of Service</a>
639
+ </Trans>
640
+ </p>
641
+ ```
642
+
643
+ **Translation:**
644
+ ```json
645
+ {
646
+ "signUpAgreement": "By clicking Sign Up, you agree to our <1>Terms of Service</1>"
647
+ }
648
+ ```
649
+
650
+ ### More Examples
651
+
652
+ **Date and time formatting:**
653
+ ```tsx
654
+ // Using next-intl
655
+ <p>{t('lastUpdated', { date: new Date() })}</p>
656
+
657
+ // Translation
658
+ {
659
+ "lastUpdated": "Last updated: {date, date, medium}"
660
+ }
661
+ ```
662
+
663
+ **Number formatting:**
664
+ ```tsx
665
+ // Using next-intl
666
+ <p>{t('price', { amount: 99.99 })}</p>
667
+
668
+ // Translation
669
+ {
670
+ "price": "{amount, number, currency}"
671
+ }
672
+ ```
673
+
674
+ **Nested plurals:**
675
+ ```tsx
676
+ <p>{t('cartSummary', { itemCount: 5, totalPrice: 149.99 })}</p>
677
+
678
+ // Translation
679
+ {
680
+ "cartSummary": "{itemCount, plural, one {# item} other {# items}} • Total: ${totalPrice}"
681
+ }
682
+ ```
683
+
684
+ ---
685
+
383
686
  ## 🔮 Roadmap
384
687
 
385
688
  ### ✅ Phase 0: Foundation & Reliability (Complete)
689
+
386
690
  - Stable extraction and replacement
387
691
  - Deterministic key generation
388
692
  - Comprehensive test coverage
389
693
  - JSON output quality
390
694
 
391
695
  ### ✅ Phase 1: Project Integration (Complete)
696
+
392
697
  - `i18nizer start` command for project initialization
393
698
  - `i18nizer translate` command with `--all` flag
394
699
  - Configuration system with YAML
@@ -399,11 +704,13 @@ Note: Keys will be in the source language (e.g., `bienvenidoDeNuevo` for Spanish
399
704
  - Dry-run and JSON preview modes
400
705
 
401
706
  ### 🚧 Phase 2: Advanced Features (Planned)
707
+
708
+ - [ ] Automatic pluralization detection and conversion
709
+ - [ ] Automatic rich text formatting detection
402
710
  - [ ] Watch mode for continuous translation
403
711
  - [ ] Non-AI fallback mode
404
712
  - [ ] Framework support (Vue, Svelte)
405
713
  - [ ] Additional i18n library presets
406
- - [ ] Pluralization support
407
714
  - [ ] Context-aware translations
408
715
  - [ ] Translation memory and glossary
409
716
 
@@ -412,6 +719,7 @@ Note: Keys will be in the source language (e.g., `bienvenidoDeNuevo` for Spanish
412
719
  ## ⚠️ Current Limitations
413
720
 
414
721
  - AI-generated keys may vary between runs (deterministic fallback available)
722
+ - Cached keys minimize diffs across runs.
415
723
  - Only supports React JSX/TSX (no Vue, Svelte yet)
416
724
  - Does not handle runtime-only string generation
417
725
 
@@ -97,9 +97,14 @@ export default class Extract extends Command {
97
97
  const result = deduplicationResults.get(t.text);
98
98
  return {
99
99
  isCached: result.isCached,
100
+ isPlural: t.isPlural,
101
+ isRichText: t.isRichText,
100
102
  key: result.key,
101
103
  node: t.node,
102
104
  placeholders: t.placeholders,
105
+ pluralForms: t.pluralForms,
106
+ pluralVariable: t.pluralVariable,
107
+ richTextElements: t.richTextElements,
103
108
  tempKey: t.tempKey,
104
109
  text: t.text,
105
110
  };
@@ -122,13 +127,22 @@ export default class Extract extends Command {
122
127
  const i18nJson = {};
123
128
  // Use our generated keys, not the AI's keys
124
129
  for (const mapped of mappedTexts) {
125
- const translations = jsonNamespace[mapped.tempKey];
126
- if (!translations) {
127
- throw new Error(`No translations found for tempKey: ${mapped.tempKey}`);
128
- }
129
130
  i18nJson[mapped.key] = {};
130
- for (const locale of locales) {
131
- i18nJson[mapped.key][locale] = translations[locale];
131
+ // For plural forms, generate ICU format directly
132
+ if (mapped.isPlural && mapped.pluralForms) {
133
+ for (const locale of locales) {
134
+ const icuFormat = `{${mapped.pluralVariable}, plural, one {${mapped.pluralForms.one}} other {${mapped.pluralForms.other}}}`;
135
+ i18nJson[mapped.key][locale] = icuFormat;
136
+ }
137
+ }
138
+ else {
139
+ const translations = jsonNamespace[mapped.tempKey];
140
+ if (!translations) {
141
+ throw new Error(`No translations found for tempKey: ${mapped.tempKey}`);
142
+ }
143
+ for (const locale of locales) {
144
+ i18nJson[mapped.key][locale] = translations[locale];
145
+ }
132
146
  }
133
147
  // Update cache
134
148
  cache.set({
@@ -143,9 +157,12 @@ export default class Extract extends Command {
143
157
  this.log(`🔗 Mapped ${chalk.green(mappedTexts.length)} texts to keys`);
144
158
  insertUseTranslations(sourceFile, componentName);
145
159
  replaceTempKeysWithT(mappedTexts.map((m) => ({
160
+ isPlural: m.isPlural,
161
+ isRichText: m.isRichText,
146
162
  key: m.key,
147
163
  node: m.node,
148
164
  placeholders: m.placeholders,
165
+ richTextElements: m.richTextElements,
149
166
  tempKey: m.tempKey,
150
167
  })));
151
168
  saveSourceFile(sourceFile);
@@ -78,7 +78,8 @@ export default class Translate extends Command {
78
78
  const locales = flags.locales
79
79
  ? flags.locales.split(",")
80
80
  : config.messages.locales || [config.messages.defaultLocale, "es"]; // Use config locales or default fallback
81
- let provider = "huggingface";
81
+ // Use provider from config or flag (flag takes precedence)
82
+ let provider = config.ai?.provider || "huggingface";
82
83
  if (flags.provider) {
83
84
  const p = flags.provider.toLowerCase();
84
85
  if (!VALID_PROVIDERS.includes(p)) {
@@ -86,6 +87,8 @@ export default class Translate extends Command {
86
87
  }
87
88
  provider = p;
88
89
  }
90
+ // Use model from config (can be undefined if not specified)
91
+ const aiModel = config.ai?.model;
89
92
  // Get files to process
90
93
  const filesToProcess = flags.all
91
94
  ? findProjectComponents(cwd)
@@ -137,9 +140,14 @@ export default class Translate extends Command {
137
140
  totalCached++;
138
141
  return {
139
142
  isCached: result.isCached,
143
+ isPlural: t.isPlural,
144
+ isRichText: t.isRichText,
140
145
  key: result.key,
141
146
  node: t.node,
142
147
  placeholders: t.placeholders,
148
+ pluralForms: t.pluralForms,
149
+ pluralVariable: t.pluralVariable,
150
+ richTextElements: t.richTextElements,
143
151
  tempKey: t.tempKey,
144
152
  text: t.text,
145
153
  };
@@ -157,9 +165,19 @@ export default class Translate extends Command {
157
165
  }
158
166
  }
159
167
  else {
160
- // Will need AI translation
161
- for (const locale of locales) {
162
- i18nJson[mapped.key][locale] = ""; // Placeholder
168
+ // For plural forms, generate ICU format
169
+ if (mapped.isPlural && mapped.pluralForms) {
170
+ for (const locale of locales) {
171
+ // Generate ICU plural format string
172
+ const icuFormat = `{${mapped.pluralVariable}, plural, one {${mapped.pluralForms.one}} other {${mapped.pluralForms.other}}}`;
173
+ i18nJson[mapped.key][locale] = icuFormat;
174
+ }
175
+ }
176
+ else {
177
+ // Will need AI translation
178
+ for (const locale of locales) {
179
+ i18nJson[mapped.key][locale] = ""; // Placeholder
180
+ }
163
181
  }
164
182
  }
165
183
  }
@@ -176,7 +194,7 @@ export default class Translate extends Command {
176
194
  })),
177
195
  });
178
196
  // eslint-disable-next-line no-await-in-loop
179
- const raw = await generateTranslations(prompt, provider);
197
+ const raw = await generateTranslations(prompt, provider, aiModel);
180
198
  if (!raw)
181
199
  throw new Error("AI did not return any data");
182
200
  const aiJson = parseAiJson(raw);
@@ -221,9 +239,12 @@ export default class Translate extends Command {
221
239
  insertUseTranslations(sourceFile, componentName);
222
240
  }
223
241
  replaceTempKeysWithT(mappedTexts.map((m) => ({
242
+ isPlural: m.isPlural,
243
+ isRichText: m.isRichText,
224
244
  key: m.key,
225
245
  node: m.node,
226
246
  placeholders: m.placeholders,
247
+ richTextElements: m.richTextElements,
227
248
  tempKey: m.tempKey,
228
249
  })), {
229
250
  allowedFunctions: config.behavior.allowedFunctions,
@@ -19,18 +19,19 @@ function loadApiKeys() {
19
19
  return {};
20
20
  }
21
21
  }
22
- export async function generateTranslations(prompt, provider = "huggingface") {
22
+ export async function generateTranslations(prompt, provider = "huggingface", model) {
23
23
  const keys = loadApiKeys();
24
24
  switch (provider) {
25
25
  case "gemini": {
26
26
  const apiKey = keys.gemini;
27
27
  if (!apiKey)
28
28
  throw new Error("Gemini API key is not set.");
29
- console.log("🤖 Using Google Gemini...");
29
+ const geminiModel = model || "gemini-2.5-flash";
30
+ console.log(`🤖 Using Google Gemini (${geminiModel})...`);
30
31
  const gemini = new GoogleGenAI({ apiKey });
31
32
  const result = await gemini.models.generateContent({
32
33
  contents: prompt,
33
- model: "gemini-2.5-flash",
34
+ model: geminiModel,
34
35
  });
35
36
  return result.text;
36
37
  }
@@ -38,12 +39,13 @@ export async function generateTranslations(prompt, provider = "huggingface") {
38
39
  const apiKey = keys.huggingface;
39
40
  if (!apiKey)
40
41
  throw new Error("Hugging Face API key is not set.");
41
- console.log("🤖 Using Hugging Face (DeepSeek-V3.2)...");
42
+ const hfModel = model || "deepseek-ai/DeepSeek-V3.2";
43
+ console.log(`🤖 Using Hugging Face (${hfModel})...`);
42
44
  const hfClient = new HFClient(apiKey);
43
45
  try {
44
46
  const chatCompletion = await hfClient.chatCompletion({
45
47
  messages: [{ content: prompt, role: "user" }],
46
- model: "deepseek-ai/DeepSeek-V3.2",
48
+ model: hfModel,
47
49
  });
48
50
  return chatCompletion.choices?.[0]?.message?.content || (typeof chatCompletion.output_text === "string" ? chatCompletion.output_text : undefined);
49
51
  }
@@ -56,12 +58,13 @@ export async function generateTranslations(prompt, provider = "huggingface") {
56
58
  const apiKey = keys.openai;
57
59
  if (!apiKey)
58
60
  throw new Error("OpenAI API key is not set.");
59
- console.log("🤖 Using OpenAI...");
61
+ const openaiModel = model || "gpt-4o-mini";
62
+ console.log(`🤖 Using OpenAI (${openaiModel})...`);
60
63
  const openai = new OpenAI({ apiKey });
61
64
  try {
62
65
  const completion = await openai.chat.completions.create({
63
66
  messages: [{ content: prompt, role: "user" }],
64
- model: "gpt-4o-mini",
67
+ model: openaiModel,
65
68
  });
66
69
  return completion.choices?.[0]?.message?.content || "";
67
70
  }
@@ -34,6 +34,138 @@ function processTemplateLiteral(node) {
34
34
  }
35
35
  return null;
36
36
  }
37
+ /**
38
+ * Extract rich text content from JSX element with child elements
39
+ * Converts: <p>Click <a>here</a> to continue</p>
40
+ * To: "Click <a>here</a> to continue"
41
+ */
42
+ function extractRichTextContent(jsxElement) {
43
+ if (!Node.isJsxElement(jsxElement)) {
44
+ return null;
45
+ }
46
+ const pattern = detectRichTextPattern(jsxElement);
47
+ if (!pattern) {
48
+ return null;
49
+ }
50
+ const children = jsxElement.getJsxChildren();
51
+ let richText = '';
52
+ const elements = [];
53
+ const seenPlaceholders = new Set();
54
+ for (const child of children) {
55
+ if (Node.isJsxText(child)) {
56
+ richText += child.getText();
57
+ }
58
+ else if (Node.isJsxElement(child)) {
59
+ const opening = child.getOpeningElement();
60
+ const tagName = opening.getTagNameNode().getText();
61
+ const placeholder = tagName.toLowerCase();
62
+ // Get inner text of the element
63
+ const innerText = child.getJsxChildren()
64
+ .filter(c => Node.isJsxText(c))
65
+ .map(c => c.getText())
66
+ .join('');
67
+ // Add to rich text with placeholder
68
+ richText += `<${placeholder}>${innerText}</${placeholder}>`;
69
+ // Track unique elements
70
+ if (!seenPlaceholders.has(placeholder)) {
71
+ elements.push({ tag: tagName, placeholder });
72
+ seenPlaceholders.add(placeholder);
73
+ }
74
+ }
75
+ }
76
+ return {
77
+ elements,
78
+ text: richText.trim(),
79
+ };
80
+ }
81
+ /**
82
+ * Detect rich text pattern - JSX element containing both text and child JSX elements
83
+ * Example: <p>Click <a>here</a> to continue</p>
84
+ */
85
+ function detectRichTextPattern(jsxElement) {
86
+ if (!Node.isJsxElement(jsxElement) && !Node.isJsxSelfClosingElement(jsxElement)) {
87
+ return null;
88
+ }
89
+ if (Node.isJsxSelfClosingElement(jsxElement)) {
90
+ return null; // Self-closing elements don't have rich text
91
+ }
92
+ const children = jsxElement.getJsxChildren();
93
+ let hasText = false;
94
+ let hasJsxElement = false;
95
+ const elements = [];
96
+ for (const child of children) {
97
+ if (Node.isJsxText(child)) {
98
+ const text = child.getText().trim();
99
+ if (text.length > 0) {
100
+ hasText = true;
101
+ }
102
+ }
103
+ else if (Node.isJsxElement(child) || Node.isJsxSelfClosingElement(child)) {
104
+ hasJsxElement = true;
105
+ // Get tag name
106
+ const opening = Node.isJsxElement(child)
107
+ ? child.getOpeningElement()
108
+ : child;
109
+ const tagName = opening.getTagNameNode().getText();
110
+ // Generate placeholder name based on tag
111
+ const placeholder = tagName.toLowerCase();
112
+ elements.push({ tag: tagName, placeholder });
113
+ }
114
+ }
115
+ // Rich text pattern: has both text and JSX elements
116
+ if (hasText && hasJsxElement) {
117
+ return { elements, hasText: true };
118
+ }
119
+ return null;
120
+ }
121
+ /**
122
+ * Detect pluralization pattern in a ternary expression
123
+ * Pattern: variable === 1 ? 'singular' : 'plural'
124
+ */
125
+ function detectPluralizationPattern(expr) {
126
+ if (!Node.isConditionalExpression(expr))
127
+ return null;
128
+ const condition = expr.getCondition();
129
+ const whenTrue = expr.getWhenTrue();
130
+ const whenFalse = expr.getWhenFalse();
131
+ // Check if condition is a binary expression (e.g., count === 1 or count == 1)
132
+ if (!Node.isBinaryExpression(condition))
133
+ return null;
134
+ const operator = condition.getOperatorToken().getText();
135
+ if (operator !== "===" && operator !== "==")
136
+ return null;
137
+ const left = condition.getLeft();
138
+ const right = condition.getRight();
139
+ // Check if one side is 1 and the other is a variable
140
+ let variable = null;
141
+ let isCheckingForOne = false;
142
+ if (Node.isNumericLiteral(right) && right.getLiteralValue() === 1 && Node.isIdentifier(left)) {
143
+ variable = left.getText();
144
+ isCheckingForOne = true;
145
+ }
146
+ else if (Node.isNumericLiteral(left) && left.getLiteralValue() === 1 && Node.isIdentifier(right)) {
147
+ variable = right.getText();
148
+ isCheckingForOne = true;
149
+ }
150
+ if (!variable || !isCheckingForOne)
151
+ return null;
152
+ // Extract singular and plural forms
153
+ let singular = null;
154
+ let plural = null;
155
+ if (Node.isStringLiteral(whenTrue)) {
156
+ singular = whenTrue.getLiteralText();
157
+ }
158
+ if (Node.isStringLiteral(whenFalse)) {
159
+ plural = whenFalse.getLiteralText();
160
+ }
161
+ if (!singular || !plural)
162
+ return null;
163
+ return {
164
+ pluralVariable: variable,
165
+ one: singular,
166
+ other: plural,
167
+ };
168
+ }
37
169
  function getFullCallName(node) {
38
170
  if (Node.isIdentifier(node))
39
171
  return node.getText();
@@ -73,6 +205,31 @@ function extractStringsFromExpression(expr, results, seenNodes) {
73
205
  }
74
206
  // Ternary operator: condition ? whenTrue : whenFalse
75
207
  if (Node.isConditionalExpression(expr)) {
208
+ // Check if this is a pluralization pattern
209
+ const pluralPattern = detectPluralizationPattern(expr);
210
+ if (pluralPattern) {
211
+ // This is a pluralization pattern, create a single entry for it
212
+ const tempKey = `i$fdw_${tempIdCounter++}`;
213
+ const text = pluralPattern.other; // Use plural form as base text
214
+ results.push({
215
+ node: expr,
216
+ placeholders: [pluralPattern.pluralVariable],
217
+ tempKey,
218
+ text,
219
+ isPlural: true,
220
+ pluralVariable: pluralPattern.pluralVariable,
221
+ pluralForms: {
222
+ one: pluralPattern.one,
223
+ other: pluralPattern.other,
224
+ },
225
+ });
226
+ seenNodes.add(expr);
227
+ // Mark child nodes as seen to avoid duplicate extraction
228
+ seenNodes.add(expr.getWhenTrue());
229
+ seenNodes.add(expr.getWhenFalse());
230
+ return;
231
+ }
232
+ // Not a pluralization pattern, extract strings normally
76
233
  extractStringsFromExpression(expr.getWhenTrue(), results, seenNodes);
77
234
  extractStringsFromExpression(expr.getWhenFalse(), results, seenNodes);
78
235
  return;
@@ -102,6 +259,25 @@ export function extractTexts(sourceFile, options = {}) {
102
259
  sourceFile.forEachDescendant((node) => {
103
260
  if (seenNodes.has(node))
104
261
  return;
262
+ // Check for rich text pattern in JSX elements
263
+ if (Node.isJsxElement(node)) {
264
+ const richContent = extractRichTextContent(node);
265
+ if (richContent) {
266
+ const tempKey = `i$fdw_${tempIdCounter++}`;
267
+ results.push({
268
+ isRichText: true,
269
+ node,
270
+ placeholders: richContent.elements.map(e => e.placeholder),
271
+ richTextElements: richContent.elements,
272
+ tempKey,
273
+ text: richContent.text,
274
+ });
275
+ seenNodes.add(node);
276
+ // Mark all children as seen to avoid duplicate extraction
277
+ node.getJsxChildren().forEach(child => seenNodes.add(child));
278
+ return;
279
+ }
280
+ }
105
281
  let text = null;
106
282
  let placeholders = [];
107
283
  // JSXText
@@ -33,12 +33,35 @@ export function replaceTempKeysWithT(mapped, options = {}) {
33
33
  const allowedProps = new Set(options.allowedProps ?? [...defaultAllowedProps]);
34
34
  const allowedFunctions = new Set(options.allowedFunctions ?? [...defaultAllowedFunctions]);
35
35
  const allowedMemberFunctions = new Set(options.allowedMemberFunctions ?? [...defaultAllowedMemberFunctions]);
36
- for (const { key, node, placeholders = [] } of mapped) {
36
+ for (const { key, node, placeholders = [], isPlural = false, isRichText = false, richTextElements = [] } of mapped) {
37
+ // For rich text patterns, generate t.rich() call
38
+ if (isRichText && Node.isJsxElement(node)) {
39
+ // Build formatter functions for each element
40
+ const formatters = richTextElements.map(elem => {
41
+ return `${elem.placeholder}: (chunks) => <${elem.tag}>{chunks}</${elem.tag}>`;
42
+ }).join(', ');
43
+ const richCall = `t.rich("${key}", { ${formatters} })`;
44
+ // Replace the entire JSX element with {t.rich(...)}
45
+ node.replaceWithText(`{${richCall}}`);
46
+ continue;
47
+ }
37
48
  // Build placeholders string if any
38
49
  const placeholdersText = placeholders.length > 0
39
50
  ? `{ ${placeholders.map(p => `${p}: ${p}`).join(", ")} }`
40
51
  : "";
41
52
  const tCall = `t("${key}"${placeholdersText ? `, ${placeholdersText}` : ""})`;
53
+ // For plural patterns (ternary expressions), replace the entire ternary
54
+ if (isPlural && Node.isConditionalExpression(node)) {
55
+ const parent = node.getParent();
56
+ // In JSX expression
57
+ if (Node.isJsxExpression(parent)) {
58
+ node.replaceWithText(tCall);
59
+ return;
60
+ }
61
+ // In other contexts, replace with t() call
62
+ node.replaceWithText(tCall);
63
+ continue;
64
+ }
42
65
  // Replace JSXText nodes → {t("key")}
43
66
  if (Node.isJsxText(node)) {
44
67
  node.replaceWithText(`{${tCall}}`);
@@ -1,9 +1,10 @@
1
+ import yaml from "js-yaml";
1
2
  import fs from "node:fs";
2
3
  import path from "node:path";
3
- import yaml from "js-yaml";
4
4
  import { DEFAULT_CONFIG, FRAMEWORK_PRESETS, I18N_LIBRARY_CONFIGS } from "../../types/config.js";
5
5
  const CONFIG_FILE_NAME = "i18nizer.config.yml";
6
6
  const PROJECT_DIR_NAME = ".i18nizer";
7
+ const VALID_AI_PROVIDERS = ["openai", "gemini", "huggingface"];
7
8
  /**
8
9
  * Detect project type by looking for framework-specific files
9
10
  */
@@ -13,10 +14,10 @@ export function detectFramework(cwd) {
13
14
  try {
14
15
  const packageJson = JSON.parse(fs.readFileSync(packageJsonPath, "utf8"));
15
16
  const deps = { ...packageJson.dependencies, ...packageJson.devDependencies };
16
- if (deps["next"]) {
17
+ if (deps.next) {
17
18
  return "nextjs";
18
19
  }
19
- if (deps["react"]) {
20
+ if (deps.react) {
20
21
  return "react";
21
22
  }
22
23
  }
@@ -42,7 +43,7 @@ export function detectI18nLibrary(cwd) {
42
43
  if (deps["react-i18next"]) {
43
44
  return "react-i18next";
44
45
  }
45
- if (deps["i18next"]) {
46
+ if (deps.i18next) {
46
47
  return "i18next";
47
48
  }
48
49
  }
@@ -52,6 +53,12 @@ export function detectI18nLibrary(cwd) {
52
53
  }
53
54
  return null; // No i18n library detected
54
55
  }
56
+ /**
57
+ * Validate AI provider value
58
+ */
59
+ export function validateAiProvider(provider) {
60
+ return VALID_AI_PROVIDERS.includes(provider);
61
+ }
55
62
  /**
56
63
  * Load configuration from the project root
57
64
  */
@@ -63,6 +70,10 @@ export function loadConfig(cwd) {
63
70
  try {
64
71
  const fileContent = fs.readFileSync(configPath, "utf8");
65
72
  const parsed = yaml.load(fileContent, { schema: yaml.CORE_SCHEMA });
73
+ // Validate AI provider if specified
74
+ if (parsed.ai?.provider && !validateAiProvider(parsed.ai.provider)) {
75
+ throw new Error(`Invalid AI provider: ${parsed.ai.provider}. Valid options: ${VALID_AI_PROVIDERS.join(", ")}`);
76
+ }
66
77
  // Deep merge with defaults
67
78
  return mergeConfig(DEFAULT_CONFIG, parsed);
68
79
  }
@@ -75,24 +86,32 @@ export function loadConfig(cwd) {
75
86
  */
76
87
  function mergeConfig(base, override) {
77
88
  return {
89
+ ai: override.ai ? {
90
+ ...base.ai,
91
+ ...override.ai,
92
+ } : base.ai,
78
93
  behavior: {
79
94
  ...base.behavior,
80
95
  ...override.behavior,
81
96
  },
82
97
  framework: override.framework ?? base.framework,
83
- i18nLibrary: override.i18nLibrary ?? base.i18nLibrary,
84
98
  i18n: {
85
99
  ...base.i18n,
86
100
  ...override.i18n,
87
101
  import: {
88
102
  ...base.i18n.import,
89
- ...(override.i18n?.import ?? {}),
103
+ ...override.i18n?.import,
90
104
  },
91
105
  },
106
+ i18nLibrary: override.i18nLibrary ?? base.i18nLibrary,
92
107
  messages: {
93
108
  ...base.messages,
94
109
  ...override.messages,
95
110
  },
111
+ paths: override.paths ? {
112
+ ...base.paths,
113
+ ...override.paths,
114
+ } : base.paths,
96
115
  };
97
116
  }
98
117
  /**
@@ -112,8 +131,8 @@ export function generateConfig(framework, i18nLibrary) {
112
131
  const i18nConfig = I18N_LIBRARY_CONFIGS[i18nLibrary];
113
132
  return {
114
133
  ...baseConfig,
115
- i18nLibrary: i18nConfig.i18nLibrary,
116
134
  i18n: i18nConfig.i18n ?? baseConfig.i18n,
135
+ i18nLibrary: i18nConfig.i18nLibrary,
117
136
  };
118
137
  }
119
138
  return baseConfig;
@@ -2,6 +2,10 @@
2
2
  * Configuration types for i18nizer
3
3
  */
4
4
  export const DEFAULT_CONFIG = {
5
+ ai: {
6
+ model: "gpt-4",
7
+ provider: "openai",
8
+ },
5
9
  behavior: {
6
10
  allowedFunctions: ["alert", "confirm", "prompt"],
7
11
  allowedMemberFunctions: ["toast.error", "toast.info", "toast.success", "toast.warn"],
@@ -35,12 +39,15 @@ export const DEFAULT_CONFIG = {
35
39
  locales: ["en", "es"],
36
40
  path: "messages",
37
41
  },
42
+ paths: {
43
+ i18n: "i18n",
44
+ src: "src",
45
+ },
38
46
  };
39
47
  export const FRAMEWORK_PRESETS = {
40
48
  custom: {},
41
49
  nextjs: {
42
50
  framework: "nextjs",
43
- i18nLibrary: "next-intl",
44
51
  i18n: {
45
52
  function: "t",
46
53
  import: {
@@ -48,10 +55,10 @@ export const FRAMEWORK_PRESETS = {
48
55
  source: "next-intl",
49
56
  },
50
57
  },
58
+ i18nLibrary: "next-intl",
51
59
  },
52
60
  react: {
53
61
  framework: "react",
54
- i18nLibrary: "react-i18next",
55
62
  i18n: {
56
63
  function: "t",
57
64
  import: {
@@ -59,14 +66,14 @@ export const FRAMEWORK_PRESETS = {
59
66
  source: "react-i18next",
60
67
  },
61
68
  },
69
+ i18nLibrary: "react-i18next",
62
70
  },
63
71
  };
64
72
  /**
65
73
  * I18n library-specific configurations
66
74
  */
67
75
  export const I18N_LIBRARY_CONFIGS = {
68
- "next-intl": {
69
- i18nLibrary: "next-intl",
76
+ custom: {
70
77
  i18n: {
71
78
  function: "t",
72
79
  import: {
@@ -74,35 +81,36 @@ export const I18N_LIBRARY_CONFIGS = {
74
81
  source: "next-intl",
75
82
  },
76
83
  },
84
+ i18nLibrary: "custom",
77
85
  },
78
- "react-i18next": {
79
- i18nLibrary: "react-i18next",
86
+ "i18next": {
80
87
  i18n: {
81
88
  function: "t",
82
89
  import: {
83
90
  named: "useTranslation",
84
- source: "react-i18next",
91
+ source: "react-i18next", // Default to react-i18next for React apps
85
92
  },
86
93
  },
87
- },
88
- "i18next": {
89
94
  i18nLibrary: "i18next",
95
+ },
96
+ "next-intl": {
90
97
  i18n: {
91
98
  function: "t",
92
99
  import: {
93
- named: "useTranslation",
94
- source: "react-i18next", // Default to react-i18next for React apps
100
+ named: "useTranslations",
101
+ source: "next-intl",
95
102
  },
96
103
  },
104
+ i18nLibrary: "next-intl",
97
105
  },
98
- custom: {
99
- i18nLibrary: "custom",
106
+ "react-i18next": {
100
107
  i18n: {
101
108
  function: "t",
102
109
  import: {
103
- named: "useTranslations",
104
- source: "next-intl",
110
+ named: "useTranslation",
111
+ source: "react-i18next",
105
112
  },
106
113
  },
114
+ i18nLibrary: "react-i18next",
107
115
  },
108
116
  };
@@ -330,5 +330,5 @@
330
330
  ]
331
331
  }
332
332
  },
333
- "version": "0.6.2"
333
+ "version": "0.7.1"
334
334
  }
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.6.2",
4
+ "version": "0.7.1",
5
5
  "author": "Yoannis Sanchez Soto",
6
6
  "bin": "./bin/run.js",
7
7
  "bugs": "https://github.com/yossTheDev/i18nizer/issues",