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 +312 -4
- package/dist/commands/extract.js +23 -6
- package/dist/commands/translate.js +26 -5
- package/dist/core/ai/client.js +10 -7
- package/dist/core/ast/extract-text.js +176 -0
- package/dist/core/ast/replace-text-with-text.js +24 -1
- package/dist/core/config/config-manager.js +26 -7
- package/dist/types/config.js +23 -15
- package/oclif.manifest.json +1 -1
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -13,16 +13,44 @@
|
|
|
13
13
|
|
|
14
14
|
---
|
|
15
15
|
|
|
16
|
-
**i18nizer
|
|
16
|
+
**i18nizer automates the boring parts of i18n.**
|
|
17
17
|
|
|
18
|
-
|
|
18
|
+
If your project already uses **i18next** or **next-intl**, i18nizer:
|
|
19
19
|
|
|
20
|
-
|
|
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
|
|
package/dist/commands/extract.js
CHANGED
|
@@ -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
|
-
|
|
131
|
-
|
|
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
|
-
|
|
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
|
-
//
|
|
161
|
-
|
|
162
|
-
|
|
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,
|
package/dist/core/ai/client.js
CHANGED
|
@@ -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
|
-
|
|
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:
|
|
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
|
-
|
|
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:
|
|
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
|
-
|
|
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:
|
|
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
|
|
17
|
+
if (deps.next) {
|
|
17
18
|
return "nextjs";
|
|
18
19
|
}
|
|
19
|
-
if (deps
|
|
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
|
|
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
|
-
...
|
|
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;
|
package/dist/types/config.js
CHANGED
|
@@ -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
|
-
|
|
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
|
-
"
|
|
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: "
|
|
94
|
-
source: "
|
|
100
|
+
named: "useTranslations",
|
|
101
|
+
source: "next-intl",
|
|
95
102
|
},
|
|
96
103
|
},
|
|
104
|
+
i18nLibrary: "next-intl",
|
|
97
105
|
},
|
|
98
|
-
|
|
99
|
-
i18nLibrary: "custom",
|
|
106
|
+
"react-i18next": {
|
|
100
107
|
i18n: {
|
|
101
108
|
function: "t",
|
|
102
109
|
import: {
|
|
103
|
-
named: "
|
|
104
|
-
source: "
|
|
110
|
+
named: "useTranslation",
|
|
111
|
+
source: "react-i18next",
|
|
105
112
|
},
|
|
106
113
|
},
|
|
114
|
+
i18nLibrary: "react-i18next",
|
|
107
115
|
},
|
|
108
116
|
};
|
package/oclif.manifest.json
CHANGED
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.
|
|
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",
|