i18next-cli 1.46.4 → 1.47.0

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.
Files changed (51) hide show
  1. package/README.md +198 -3
  2. package/dist/cjs/cli.js +50 -1
  3. package/dist/cjs/extractor/core/extractor.js +27 -18
  4. package/dist/cjs/extractor/core/translation-manager.js +10 -3
  5. package/dist/cjs/extractor/parsers/call-expression-handler.js +9 -1
  6. package/dist/cjs/extractor/parsers/jsx-handler.js +10 -1
  7. package/dist/cjs/index.js +5 -0
  8. package/dist/cjs/init.js +68 -12
  9. package/dist/cjs/instrumenter/core/instrumenter.js +1633 -0
  10. package/dist/cjs/instrumenter/core/key-generator.js +71 -0
  11. package/dist/cjs/instrumenter/core/string-detector.js +290 -0
  12. package/dist/cjs/instrumenter/core/transformer.js +339 -0
  13. package/dist/cjs/linter.js +6 -7
  14. package/dist/cjs/utils/jsx-attributes.js +131 -0
  15. package/dist/esm/cli.js +50 -1
  16. package/dist/esm/extractor/core/extractor.js +27 -18
  17. package/dist/esm/extractor/core/translation-manager.js +10 -3
  18. package/dist/esm/extractor/parsers/call-expression-handler.js +9 -1
  19. package/dist/esm/extractor/parsers/jsx-handler.js +10 -1
  20. package/dist/esm/index.js +3 -0
  21. package/dist/esm/init.js +68 -12
  22. package/dist/esm/instrumenter/core/instrumenter.js +1630 -0
  23. package/dist/esm/instrumenter/core/key-generator.js +68 -0
  24. package/dist/esm/instrumenter/core/string-detector.js +288 -0
  25. package/dist/esm/instrumenter/core/transformer.js +336 -0
  26. package/dist/esm/linter.js +6 -7
  27. package/dist/esm/utils/jsx-attributes.js +121 -0
  28. package/package.json +2 -1
  29. package/types/cli.d.ts.map +1 -1
  30. package/types/extractor/core/extractor.d.ts.map +1 -1
  31. package/types/extractor/core/translation-manager.d.ts.map +1 -1
  32. package/types/extractor/parsers/call-expression-handler.d.ts.map +1 -1
  33. package/types/extractor/parsers/jsx-handler.d.ts.map +1 -1
  34. package/types/index.d.ts +2 -1
  35. package/types/index.d.ts.map +1 -1
  36. package/types/init.d.ts.map +1 -1
  37. package/types/instrumenter/core/instrumenter.d.ts +16 -0
  38. package/types/instrumenter/core/instrumenter.d.ts.map +1 -0
  39. package/types/instrumenter/core/key-generator.d.ts +30 -0
  40. package/types/instrumenter/core/key-generator.d.ts.map +1 -0
  41. package/types/instrumenter/core/string-detector.d.ts +27 -0
  42. package/types/instrumenter/core/string-detector.d.ts.map +1 -0
  43. package/types/instrumenter/core/transformer.d.ts +31 -0
  44. package/types/instrumenter/core/transformer.d.ts.map +1 -0
  45. package/types/instrumenter/index.d.ts +6 -0
  46. package/types/instrumenter/index.d.ts.map +1 -0
  47. package/types/linter.d.ts.map +1 -1
  48. package/types/types.d.ts +285 -1
  49. package/types/types.d.ts.map +1 -1
  50. package/types/utils/jsx-attributes.d.ts +68 -0
  51. package/types/utils/jsx-attributes.d.ts.map +1 -0
package/README.md CHANGED
@@ -247,6 +247,163 @@ Analyzes your source code for internationalization issues like hardcoded strings
247
247
  npx i18next-cli lint
248
248
  ```
249
249
 
250
+ ### `instrument`
251
+
252
+ Scans your source code for hardcoded user-facing strings and instruments them with i18next translation calls. This is useful for adding i18next instrumentation to an existing codebase that wasn't built with internationalization in mind.
253
+
254
+ > **⚠️ First-Step Tool:** The `instrument` command uses heuristic-based detection and is designed as a **first pass** to identify and suggest transformation candidates. It will **not catch 100% of cases**, and you should expect both false positives and false negatives. Always review the suggested transformations carefully before committing them to your codebase. Think of it as an intelligent code assistant, not an automated compiler.
255
+
256
+ ```bash
257
+ npx i18next-cli instrument
258
+ ```
259
+
260
+ **Options:**
261
+ - `--dry-run`: Preview changes without writing files to disk
262
+ - `--interactive`: Prompt for approval of each candidate string
263
+ - `--namespace <ns>`: Target a specific namespace for extracted keys
264
+ - `-q, --quiet`: Suppress spinner and output
265
+
266
+ **What it transforms:**
267
+
268
+ The `instrument` command detects four types of transformations:
269
+
270
+ 1. **Simple string → `t()` call:**
271
+ ```javascript
272
+ // Before
273
+ const msg = 'Welcome back';
274
+
275
+ // After
276
+ const msg = t('welcomeBack', 'Welcome back');
277
+ ```
278
+
279
+ 2. **Template literal (static only) → `t()` call:**
280
+ ```javascript
281
+ // Before
282
+ const msg = `Welcome back`;
283
+
284
+ // After
285
+ const msg = t('welcomeBack', 'Welcome back');
286
+ ```
287
+ > Template literals with interpolation (e.g. `` `Hello ${name}` ``) are skipped — they require manual wrapping.
288
+
289
+ 3. **JSX text → JSX expression with `t()`:**
290
+ ```jsx
291
+ // Before
292
+ <h1>Welcome back</h1>
293
+
294
+ // After
295
+ <h1>{t('welcomeBack', 'Welcome back')}</h1>
296
+ ```
297
+
298
+ 4. **JSX mixed content → `<Trans>` component:**
299
+ ```jsx
300
+ // Before
301
+ <p>Click <a href="/docs">here</a> to continue</p>
302
+
303
+ // After
304
+ <p><Trans i18nKey="clickHereLabel">Click <a href="/docs">here</a> to continue</Trans></p>
305
+ ```
306
+
307
+ **Namespace targeting:**
308
+
309
+ Use `--namespace <ns>` to direct extracted keys into a specific namespace. When a non-default namespace is specified:
310
+ - React components use `useTranslation('<ns>')` with clean keys
311
+ - Non-component code uses `i18next.t('key', 'default', { ns: '<ns>' })`
312
+ - In `--interactive` mode you are prompted for the target namespace
313
+
314
+ ```bash
315
+ npx i18next-cli instrument --namespace common
316
+ ```
317
+
318
+ **Custom scorer hook:**
319
+
320
+ Override the built-in confidence heuristic via `extract.instrumentScorer` in your config. The function receives each candidate string and its context, and can:
321
+ - Return a number (0–1) to override the confidence score
322
+ - Return `null` to force-skip the candidate
323
+ - Return `undefined` to fall back to the built-in heuristic
324
+
325
+ ```typescript
326
+ export default defineConfig({
327
+ // ...
328
+ extract: {
329
+ // ...
330
+ instrumentScorer: (content, { file, code, beforeContext, afterContext }) => {
331
+ // Skip strings that belong to your analytics domain
332
+ if (content.startsWith('track_')) return null;
333
+ // Boost strings in your UI layer
334
+ if (file.includes('/components/')) return 0.95;
335
+ // Fall back to built-in detection for everything else
336
+ return undefined;
337
+ }
338
+ }
339
+ });
340
+ ```
341
+
342
+ **What it skips (by design):**
343
+
344
+ The instrumenter uses confidence heuristics to avoid transforming:
345
+ - Test files (`*.test.*`, `*.spec.*`)
346
+ - Empty strings and single characters
347
+ - Pure numbers and numeric IDs
348
+ - URL strings and file paths
349
+ - CSS class names and technical identifiers
350
+ - Developer-facing error codes (all-caps patterns like `ERROR_NOT_FOUND`)
351
+ - `console.log/warn/error` arguments
352
+ - HTML attribute values that appear technical
353
+ - Template literals with only expressions (no static text)
354
+ - Strings already inside `t()` calls or `<Trans>` components
355
+
356
+ **Auto-injection:**
357
+
358
+ When transformations are applied, the command automatically:
359
+ - Injects `import { useTranslation } from 'react-i18next'` in React files (or `import i18next from 'i18next'` for non-React files)
360
+ - Injects `const { t } = useTranslation()` into each React function component that contains transformed strings
361
+ - Detects the project's framework from `package.json` dependencies (React, Next.js, Vue, etc.)
362
+ - Uses `useTranslation()` hook style `t()` inside React components, or `i18next.t()` for utility / non-component code
363
+ - Generates an `i18n.ts` (or `i18n.js` for JS-only projects) initialization file if none exists, pre-configured with [`i18next-resources-to-backend`](https://github.com/i18next/i18next-resources-to-backend) to lazy-load your translation files via dynamic imports
364
+
365
+ **Recommended workflow:**
366
+
367
+ 1. **Preview first:** Always run with `--dry-run` to see what will change:
368
+ ```bash
369
+ npx i18next-cli instrument --dry-run
370
+ ```
371
+
372
+ 2. **Interactive mode for initial migration:** Use `--interactive` to approve each candidate:
373
+ ```bash
374
+ npx i18next-cli instrument --interactive
375
+ ```
376
+
377
+ 3. **Review and commit:** Check the changes, then commit to git before proceeding
378
+
379
+ 4. **Run extraction:** After instrumentation, run `extract` to sync with translation files:
380
+ ```bash
381
+ npx i18next-cli extract
382
+ ```
383
+
384
+ **Limitations:**
385
+
386
+ The `instrument` command uses heuristic-based detection and has the following limitations:
387
+
388
+ - **Heuristic-based:** Detection is based on pattern matching and heuristics, not semantic understanding. Expect false positives (marking non-translatable strings for translation) and false negatives (missing translatable strings).
389
+ - **Requires Manual Review:** Every suggested transformation should be carefully reviewed. The command makes a best-effort guess but cannot understand context like a human developer can.
390
+ - **Plurals & Interpolations:** Strings requiring pluralization or variable interpolation need manual cleanup (the command generates basic `t()` calls without plural handling).
391
+ - **Very Dynamic Strings:** Strings built from concatenation, template operations, or computed values may not be detected correctly.
392
+ - **Framework-specific Patterns:** Some framework-specific translation patterns (e.g., decorators, custom hooks, directives) may not be recognized.
393
+ - **Test Files:** Test files are deliberately excluded to avoid instrumenting mock or test data.
394
+ - **Object Keys & Edge Cases:** Complex usage patterns (strings as object keys, switch cases, etc.) may be incorrectly flagged or missed.
395
+ - **Context Loss:** The heuristics can't understand your domain or application context, so legitimate false positives are expected in specialized codebases.
396
+
397
+ **Expected Workflow:**
398
+
399
+ The intended usage pattern is:
400
+ 1. Run `--dry-run` to preview all suggestions
401
+ 2. Use `--interactive` and **carefully review each suggestion** — consider using `edit-key` or `skip` liberally
402
+ 3. Commit the instrumented code to version control
403
+ 4. Run the full test suite to catch any issues
404
+ 5. Manually fix any false positives or false negatives
405
+ 6. Run `extract` to finalize translation files
406
+
250
407
  ### `migrate-config`
251
408
  Automatically migrates a legacy `i18next-parser.config.js` file to the new `i18next.config.ts` format.
252
409
 
@@ -582,7 +739,7 @@ export default defineConfig({
582
739
 
583
740
  ### Plugin System
584
741
 
585
- Create custom plugins to extend the capabilities of `i18next-cli`. The plugin system provides hooks for both extraction and linting, with a single unified `plugins` array.
742
+ Create custom plugins to extend the capabilities of `i18next-cli`. The plugin system provides hooks for extraction, linting, and instrumentation, with a single unified `plugins` array.
586
743
 
587
744
  **Available Hooks:**
588
745
 
@@ -604,6 +761,16 @@ Create custom plugins to extend the capabilities of `i18next-cli`. The plugin sy
604
761
  - Return `null` to skip linting the file entirely.
605
762
  - `lintOnResult(filePath, issues)`: Runs after each file is linted. Return a new issues array to filter/augment results, or `undefined` to keep as-is.
606
763
 
764
+ **Instrument Plugin Hooks:**
765
+
766
+ - `instrumentSetup(context)`: Runs once before instrumentation starts. Receives `InstrumentPluginContext` with `config` and `logger`.
767
+ - `instrumentExtensions`: Optional extension hint (for example `['.vue']`). Used as a skip hint/optimization.
768
+ - `instrumentOnLoad(code, filePath)`: Runs before scanning each file for hardcoded strings.
769
+ - Return `string` to replace source code before scanning.
770
+ - Return `undefined` to pass through unchanged.
771
+ - Return `null` to skip instrumenting the file entirely.
772
+ - `instrumentOnResult(filePath, candidates)`: Runs after candidates are detected. Return a new `CandidateString[]` to filter/augment results, or `undefined` to keep as-is.
773
+
607
774
  ### Lint Plugin API
608
775
 
609
776
  ```typescript
@@ -614,7 +781,8 @@ import type {
614
781
  LintIssue,
615
782
  } from 'i18next-cli';
616
783
 
617
- // You can type your plugin as Plugin (full surface) or LinterPlugin (lint-focused)
784
+ // You can type your plugin as Plugin (full surface), LinterPlugin (lint-focused),
785
+ // or InstrumenterPlugin (instrument-focused)
618
786
  export const vueLintPlugin = (): LinterPlugin => ({
619
787
  name: 'vue-lint-plugin',
620
788
  lintExtensions: ['.vue'],
@@ -633,7 +801,34 @@ export const vueLintPlugin = (): LinterPlugin => ({
633
801
  });
634
802
  ```
635
803
 
636
- **Config usage (same plugins list for extract + lint):**
804
+ ### Instrument Plugin API
805
+
806
+ ```typescript
807
+ import type {
808
+ InstrumenterPlugin,
809
+ InstrumentPluginContext,
810
+ CandidateString,
811
+ } from 'i18next-cli';
812
+
813
+ export const vueInstrumentPlugin = (): InstrumenterPlugin => ({
814
+ name: 'vue-instrument-plugin',
815
+ instrumentExtensions: ['.vue'],
816
+ instrumentSetup: async (context: InstrumentPluginContext) => {
817
+ context.logger.info('vue instrument plugin initialized');
818
+ },
819
+ instrumentOnLoad: async (code, filePath) => {
820
+ if (!filePath.endsWith('.vue')) return undefined;
821
+ // Extract template block from SFC and return as JSX-like code
822
+ return code;
823
+ },
824
+ instrumentOnResult: async (_filePath, candidates: CandidateString[]) => {
825
+ // Example: only keep high-confidence candidates
826
+ return candidates.filter(c => c.confidence >= 0.5);
827
+ }
828
+ });
829
+ ```
830
+
831
+ **Config usage (same plugins list for extract + lint + instrument):**
637
832
 
638
833
  ```typescript
639
834
  import { defineConfig } from 'i18next-cli';
package/dist/cjs/cli.js CHANGED
@@ -23,12 +23,15 @@ var linter = require('./linter.js');
23
23
  var status = require('./status.js');
24
24
  var locize = require('./locize.js');
25
25
  var renameKey = require('./rename-key.js');
26
+ var instrumenter = require('./instrumenter/core/instrumenter.js');
27
+ require('./utils/jsx-attributes.js');
28
+ require('magic-string');
26
29
 
27
30
  const program = new commander.Command();
28
31
  program
29
32
  .name('i18next-cli')
30
33
  .description('A unified, high-performance i18next CLI.')
31
- .version('1.46.4'); // This string is replaced with the actual version at build time by rollup
34
+ .version('1.47.0'); // This string is replaced with the actual version at build time by rollup
32
35
  // new: global config override option
33
36
  program.option('-c, --config <path>', 'Path to i18next-cli config file (overrides detection)');
34
37
  program
@@ -204,6 +207,52 @@ program
204
207
  }
205
208
  }
206
209
  });
210
+ program
211
+ .command('instrument')
212
+ .description('Scan for hardcoded strings and instrument your code with i18next calls.')
213
+ .option('--dry-run', 'Preview changes without writing files to disk.')
214
+ .option('--interactive', 'Prompt for approval of each candidate string.')
215
+ .option('--namespace <ns>', 'Target a specific namespace for extracted keys.')
216
+ .option('-q, --quiet', 'Suppress spinner and output')
217
+ .action(async (options) => {
218
+ try {
219
+ const cfgPath = program.opts().config;
220
+ const config$1 = await config.ensureConfig(cfgPath);
221
+ const results = await instrumenter.runInstrumenter(config$1, {
222
+ isDryRun: !!options.dryRun,
223
+ isInteractive: !!options.interactive,
224
+ namespace: options.namespace,
225
+ quiet: !!options.quiet
226
+ });
227
+ // Display results
228
+ if (!options.quiet) {
229
+ console.log(node_util.styleText('bold', '\nInstrumentation Summary:'));
230
+ console.log(` Total candidates: ${results.totalCandidates}`);
231
+ console.log(` Approved: ${results.totalTransformed}`);
232
+ console.log(` Skipped: ${results.totalSkipped}`);
233
+ if (results.totalLanguageChanges > 0) {
234
+ console.log(` Language-change sites: ${results.totalLanguageChanges}`);
235
+ }
236
+ if (options.dryRun) {
237
+ console.log(node_util.styleText('blue', '\n📋 Dry-run mode enabled. No files were modified.'));
238
+ console.log('Run again without --dry-run to apply changes.');
239
+ }
240
+ if (results.files.length > 0) {
241
+ console.log(node_util.styleText('green', `\n✅ ${results.files.length} file(s) ready for instrumentation`));
242
+ }
243
+ else {
244
+ console.log(node_util.styleText('yellow', '\n⚠️ No files required instrumentation'));
245
+ }
246
+ if (results.totalTransformed > 0 && !options.dryRun) {
247
+ console.log(node_util.styleText('cyan', '\n💡 Next step: run `i18next-cli extract` to extract the translation keys into your locale files.'));
248
+ }
249
+ }
250
+ }
251
+ catch (error) {
252
+ console.error(node_util.styleText('red', 'Error running instrument command:'), error);
253
+ process.exit(1);
254
+ }
255
+ });
207
256
  program
208
257
  .command('locize-sync')
209
258
  .description('Synchronize local translations with your Locize project.')
@@ -61,9 +61,13 @@ async function runExtractor(config, options = {}) {
61
61
  logger: options.logger
62
62
  });
63
63
  let anyFileUpdated = false;
64
+ let anyNewFile = false;
64
65
  for (const result of results) {
65
66
  if (result.updated) {
66
67
  anyFileUpdated = true;
68
+ if (Object.keys(result.existingTranslations || {}).length === 0) {
69
+ anyNewFile = true;
70
+ }
67
71
  if (!options.isDryRun) {
68
72
  // prefer explicit outputFormat; otherwise infer from file extension per-file
69
73
  const effectiveFormat = config.extract.outputFormat ?? fileUtils.inferFormatFromPath(result.path);
@@ -89,8 +93,10 @@ async function runExtractor(config, options = {}) {
89
93
  : node_util.styleText('bold', 'Extraction complete!');
90
94
  spinner.succeed(completionMessage);
91
95
  // Show the funnel message only if files were actually changed.
92
- if (anyFileUpdated)
93
- await printLocizeFunnel(options.logger);
96
+ // When new translation files are created (new namespace or first extraction),
97
+ // always show the funnel regardless of cooldown.
98
+ if (anyFileUpdated && !options.isDryRun)
99
+ await printLocizeFunnel(options.logger, anyNewFile);
94
100
  return { anyFileUpdated, hasErrors: fileErrors.length > 0 };
95
101
  }
96
102
  catch (error) {
@@ -251,24 +257,27 @@ async function extract(config, { syncPrimaryWithDefaults = false } = {}) {
251
257
  * Prints a promotional message for the locize saveMissing workflow.
252
258
  * This message is shown after a successful extraction that resulted in changes.
253
259
  */
254
- async function printLocizeFunnel(logger$1) {
255
- if (!(await funnelMsgTracker.shouldShowFunnel('extract')))
260
+ async function printLocizeFunnel(logger$1, force) {
261
+ if (!force && !(await funnelMsgTracker.shouldShowFunnel('extract')))
256
262
  return;
257
263
  const internalLogger = logger$1 ?? new logger.ConsoleLogger();
258
- if (typeof internalLogger.info === 'function') {
259
- internalLogger.info(node_util.styleText(['yellow', 'bold'], '\n💡 Tip: Tired of running the extractor manually?'));
260
- internalLogger.info(' Discover a real-time "push" workflow with `saveMissing` and Locize AI,');
261
- internalLogger.info(' where keys are created and translated automatically as you code.');
262
- internalLogger.info(` Learn more: ${node_util.styleText('cyan', 'https://www.locize.com/blog/i18next-savemissing-ai-automation')}`);
263
- internalLogger.info(` Watch the video: ${node_util.styleText('cyan', 'https://youtu.be/joPsZghT3wM')}`);
264
- }
265
- else {
266
- console.log(node_util.styleText(['yellow', 'bold'], '\n💡 Tip: Tired of running the extractor manually?'));
267
- console.log(' Discover a real-time "push" workflow with `saveMissing` and Locize AI,');
268
- console.log(' where keys are created and translated automatically as you code.');
269
- console.log(` Learn more: ${node_util.styleText('cyan', 'https://www.locize.com/blog/i18next-savemissing-ai-automation')}`);
270
- console.log(` Watch the video: ${node_util.styleText('cyan', 'https://youtu.be/joPsZghT3wM')}`);
271
- }
264
+ const lines = [
265
+ node_util.styleText(['yellow', 'bold'], '\n💡 Tip: Tired of running the extractor manually?'),
266
+ ' Discover a real-time "push" workflow with `saveMissing` and Locize AI/MT,',
267
+ ' where keys are created and translated automatically as you code.',
268
+ ` Learn more: ${node_util.styleText('cyan', 'https://www.locize.com/blog/i18next-savemissing-ai-automation')}`,
269
+ ` Watch the video: ${node_util.styleText('cyan', 'https://youtu.be/joPsZghT3wM')}`,
270
+ '',
271
+ ' You can also sync your extracted translations to Locize:',
272
+ ` ${node_util.styleText('cyan', 'npx i18next-cli locize-sync')} – upload/sync translations to Locize`,
273
+ ` ${node_util.styleText('cyan', 'npx i18next-cli locize-migrate')} – migrate local translations to Locize`,
274
+ ' Or import them manually via the Locize UI, API, or locize-cli.',
275
+ ];
276
+ const log = typeof internalLogger.info === 'function'
277
+ ? (msg) => internalLogger.info(msg)
278
+ : (msg) => console.log(msg);
279
+ for (const line of lines)
280
+ log(line);
272
281
  return funnelMsgTracker.recordFunnelShown('extract');
273
282
  }
274
283
 
@@ -338,7 +338,7 @@ function buildNewTranslationsForNs(nsKeys, existingTranslations, config, locale,
338
338
  return false;
339
339
  };
340
340
  // Filter nsKeys to only include keys relevant to this language
341
- const filteredKeys = nsKeys.filter(({ key, hasCount, isOrdinal }) => {
341
+ const filteredKeys = nsKeys.filter(({ key, hasCount, isOrdinal, explicitDefault }) => {
342
342
  // FIRST: Check if key matches preservePatterns and should be excluded
343
343
  if (shouldFilterKey(key)) {
344
344
  return false;
@@ -364,14 +364,21 @@ function buildNewTranslationsForNs(nsKeys, existingTranslations, config, locale,
364
364
  return true;
365
365
  // Otherwise fall through and check the explicit suffix as before.
366
366
  }
367
+ // i18next supports a special _zero form that is NOT part of CLDR plural
368
+ // rules. When the key was explicitly extracted (e.g. from a t() call with
369
+ // `defaultValue_zero`), always include it regardless of the target
370
+ // language's Intl.PluralRules categories.
371
+ // See: https://www.i18next.com/translation-function/plurals#special-zero
372
+ const lastPart = keyParts[keyParts.length - 1];
373
+ if (lastPart === 'zero' && explicitDefault) {
374
+ return true;
375
+ }
367
376
  if (isOrdinal && keyParts.includes('ordinal')) {
368
377
  // For ordinal plurals: key_context_ordinal_category or key_ordinal_category
369
- const lastPart = keyParts[keyParts.length - 1];
370
378
  return targetLanguagePluralCategories.has(`ordinal_${lastPart}`);
371
379
  }
372
380
  else if (hasCount) {
373
381
  // For cardinal plurals: key_context_category or key_category
374
- const lastPart = keyParts[keyParts.length - 1];
375
382
  return targetLanguagePluralCategories.has(lastPart);
376
383
  }
377
384
  return true;
@@ -705,8 +705,16 @@ class CallExpressionHandler {
705
705
  categories.forEach(cat => allPluralCategories.add(cat));
706
706
  }
707
707
  }
708
- const pluralCategories = Array.from(allPluralCategories).sort();
709
708
  const pluralSeparator = this.config.extract.pluralSeparator ?? '_';
709
+ // i18next supports a special _zero form (not part of CLDR plural rules).
710
+ // When defaultValue_zero is present in the options, include 'zero' in the
711
+ // categories so that key_zero is generated with the correct default value.
712
+ // See: https://www.i18next.com/translation-function/plurals#special-zero
713
+ const zeroDefault = astUtils.getObjectPropValue(options, `defaultValue${pluralSeparator}zero`);
714
+ if (typeof zeroDefault === 'string' && !allPluralCategories.has('zero')) {
715
+ allPluralCategories.add('zero');
716
+ }
717
+ const pluralCategories = Array.from(allPluralCategories).sort();
710
718
  // Get all possible default values once at the start
711
719
  const defaultValue = astUtils.getObjectPropValue(options, 'defaultValue');
712
720
  const otherDefault = astUtils.getObjectPropValue(options, `defaultValue${pluralSeparator}other`);
@@ -366,8 +366,17 @@ class JSXHandler {
366
366
  categories.forEach(cat => allPluralCategories.add(cat));
367
367
  }
368
368
  }
369
- const pluralCategories = Array.from(allPluralCategories).sort();
370
369
  const pluralSeparator = this.config.extract.pluralSeparator ?? '_';
370
+ // i18next supports a special _zero form (not part of CLDR plural rules).
371
+ // When defaultValue_zero is present in tOptions, include 'zero' in the
372
+ // categories so that key_zero is generated with the correct default value.
373
+ if (optionsNode) {
374
+ const zeroDefault = astUtils.getObjectPropValue(optionsNode, `defaultValue${pluralSeparator}zero`);
375
+ if (typeof zeroDefault === 'string' && !allPluralCategories.has('zero')) {
376
+ allPluralCategories.add('zero');
377
+ }
378
+ }
379
+ const pluralCategories = Array.from(allPluralCategories).sort();
371
380
  // Get plural-specific default values from tOptions if available
372
381
  let otherDefault;
373
382
  let ordinalOtherDefault;
package/dist/cjs/index.js CHANGED
@@ -10,6 +10,9 @@ var syncer = require('./syncer.js');
10
10
  var status = require('./status.js');
11
11
  var typesGenerator = require('./types-generator.js');
12
12
  var renameKey = require('./rename-key.js');
13
+ var instrumenter = require('./instrumenter/core/instrumenter.js');
14
+ require('./utils/jsx-attributes.js');
15
+ require('magic-string');
13
16
 
14
17
 
15
18
 
@@ -25,3 +28,5 @@ exports.runSyncer = syncer.runSyncer;
25
28
  exports.runStatus = status.runStatus;
26
29
  exports.runTypesGenerator = typesGenerator.runTypesGenerator;
27
30
  exports.runRenameKey = renameKey.runRenameKey;
31
+ exports.runInstrumenter = instrumenter.runInstrumenter;
32
+ exports.writeExtractedKeys = instrumenter.writeExtractedKeys;
package/dist/cjs/init.js CHANGED
@@ -32,6 +32,39 @@ async function isEsmProject() {
32
32
  return true; // Default to ESM if package.json is not found or readable
33
33
  }
34
34
  }
35
+ /**
36
+ * Checks whether i18next-cli is listed as a local dependency of the current project.
37
+ * When running via `npx` without a local install, `defineConfig` would not be available
38
+ * at runtime, so the generated config should fall back to a plain object export.
39
+ *
40
+ * @returns Promise resolving to true if i18next-cli is in dependencies or devDependencies
41
+ */
42
+ async function isCliLocallyInstalled() {
43
+ try {
44
+ const packageJsonPath = node_path.resolve(process.cwd(), 'package.json');
45
+ const content = await promises.readFile(packageJsonPath, 'utf-8');
46
+ const packageJson = JSON.parse(content);
47
+ const deps = { ...packageJson.dependencies, ...packageJson.devDependencies };
48
+ return !!deps['i18next-cli'];
49
+ }
50
+ catch {
51
+ return false;
52
+ }
53
+ }
54
+ /**
55
+ * Checks whether the project uses TypeScript by looking for a tsconfig.json.
56
+ *
57
+ * @returns Promise resolving to true if tsconfig.json exists in the project root
58
+ */
59
+ async function isTypeScriptProject() {
60
+ try {
61
+ await promises.readFile(node_path.resolve(process.cwd(), 'tsconfig.json'));
62
+ return true;
63
+ }
64
+ catch {
65
+ return false;
66
+ }
67
+ }
35
68
  /**
36
69
  * Interactive setup wizard for creating a new i18next-cli configuration file.
37
70
  *
@@ -76,12 +109,17 @@ async function runInit() {
76
109
  if (detectedConfig && typeof detectedConfig.extract?.output === 'function') {
77
110
  delete detectedConfig.extract.output;
78
111
  }
112
+ // Detect whether the project uses TypeScript to set the preferred default
113
+ const projectUsesTs = await isTypeScriptProject();
114
+ const tsChoice = 'TypeScript (i18next.config.ts)';
115
+ const jsChoice = 'JavaScript (i18next.config.js)';
116
+ const fileTypeChoices = projectUsesTs ? [tsChoice, jsChoice] : [jsChoice, tsChoice];
79
117
  const answers = await inquirer.prompt([
80
118
  {
81
119
  type: 'select',
82
120
  name: 'fileType',
83
121
  message: 'What kind of configuration file do you want?',
84
- choices: ['TypeScript (i18next.config.ts)', 'JavaScript (i18next.config.js)'],
122
+ choices: fileTypeChoices,
85
123
  },
86
124
  {
87
125
  type: 'input',
@@ -148,23 +186,41 @@ async function runInit() {
148
186
  // Fallback
149
187
  return JSON.stringify(value);
150
188
  }
189
+ const isLocallyInstalled = await isCliLocallyInstalled();
151
190
  let fileContent = '';
152
- if (isTypeScript) {
153
- fileContent = `import { defineConfig } from 'i18next-cli';
191
+ if (isLocallyInstalled) {
192
+ // i18next-cli is a local dependency — use defineConfig for type-safety
193
+ if (isTypeScript) {
194
+ fileContent = `import { defineConfig } from 'i18next-cli'
154
195
 
155
- export default defineConfig(${toJs(configObject)});`;
156
- }
157
- else if (isEsm) {
158
- fileContent = `import { defineConfig } from 'i18next-cli';
196
+ export default defineConfig(${toJs(configObject)})`;
197
+ }
198
+ else if (isEsm) {
199
+ fileContent = `import { defineConfig } from 'i18next-cli'
159
200
 
160
201
  /** @type {import('i18next-cli').I18nextToolkitConfig} */
161
- export default defineConfig(${toJs(configObject)});`;
162
- }
163
- else { // CJS
164
- fileContent = `const { defineConfig } = require('i18next-cli');
202
+ export default defineConfig(${toJs(configObject)})`;
203
+ }
204
+ else { // CJS
205
+ fileContent = `const { defineConfig } = require('i18next-cli')
165
206
 
166
207
  /** @type {import('i18next-cli').I18nextToolkitConfig} */
167
- module.exports = defineConfig(${toJs(configObject)});`;
208
+ module.exports = defineConfig(${toJs(configObject)})`;
209
+ }
210
+ }
211
+ else {
212
+ // i18next-cli is not locally installed (e.g. npx) — plain config object
213
+ if (isTypeScript) {
214
+ fileContent = `export default ${toJs(configObject)}`;
215
+ }
216
+ else if (isEsm) {
217
+ fileContent = `/** @type {import('i18next-cli').I18nextToolkitConfig} */
218
+ export default ${toJs(configObject)}`;
219
+ }
220
+ else { // CJS
221
+ fileContent = `/** @type {import('i18next-cli').I18nextToolkitConfig} */
222
+ module.exports = ${toJs(configObject)}`;
223
+ }
168
224
  }
169
225
  const outputPath = node_path.resolve(process.cwd(), fileName);
170
226
  await promises.writeFile(outputPath, fileContent.trim());