i18next-cli 1.46.3 → 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 (52) hide show
  1. package/README.md +198 -3
  2. package/dist/cjs/cli.js +50 -1
  3. package/dist/cjs/extractor/core/extractor.js +29 -19
  4. package/dist/cjs/extractor/core/translation-manager.js +18 -10
  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 +29 -19
  17. package/dist/esm/extractor/core/translation-manager.js +18 -10
  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 +3 -2
  32. package/types/extractor/core/translation-manager.d.ts.map +1 -1
  33. package/types/extractor/parsers/call-expression-handler.d.ts.map +1 -1
  34. package/types/extractor/parsers/jsx-handler.d.ts.map +1 -1
  35. package/types/index.d.ts +2 -1
  36. package/types/index.d.ts.map +1 -1
  37. package/types/init.d.ts.map +1 -1
  38. package/types/instrumenter/core/instrumenter.d.ts +16 -0
  39. package/types/instrumenter/core/instrumenter.d.ts.map +1 -0
  40. package/types/instrumenter/core/key-generator.d.ts +30 -0
  41. package/types/instrumenter/core/key-generator.d.ts.map +1 -0
  42. package/types/instrumenter/core/string-detector.d.ts +27 -0
  43. package/types/instrumenter/core/string-detector.d.ts.map +1 -0
  44. package/types/instrumenter/core/transformer.d.ts +31 -0
  45. package/types/instrumenter/core/transformer.d.ts.map +1 -0
  46. package/types/instrumenter/index.d.ts +6 -0
  47. package/types/instrumenter/index.d.ts.map +1 -0
  48. package/types/linter.d.ts.map +1 -1
  49. package/types/types.d.ts +285 -1
  50. package/types/types.d.ts.map +1 -1
  51. package/types/utils/jsx-attributes.d.ts +68 -0
  52. 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.3'); // 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.')
@@ -57,12 +57,17 @@ async function runExtractor(config, options = {}) {
57
57
  spinner.text = `Found ${allKeys.size} unique keys. Updating translation files...`;
58
58
  const results = await translationManager.getTranslations(allKeys, objectKeys, config, {
59
59
  syncPrimaryWithDefaults: options.syncPrimaryWithDefaults,
60
- syncAll: options.syncAll
60
+ syncAll: options.syncAll,
61
+ logger: options.logger
61
62
  });
62
63
  let anyFileUpdated = false;
64
+ let anyNewFile = false;
63
65
  for (const result of results) {
64
66
  if (result.updated) {
65
67
  anyFileUpdated = true;
68
+ if (Object.keys(result.existingTranslations || {}).length === 0) {
69
+ anyNewFile = true;
70
+ }
66
71
  if (!options.isDryRun) {
67
72
  // prefer explicit outputFormat; otherwise infer from file extension per-file
68
73
  const effectiveFormat = config.extract.outputFormat ?? fileUtils.inferFormatFromPath(result.path);
@@ -88,8 +93,10 @@ async function runExtractor(config, options = {}) {
88
93
  : node_util.styleText('bold', 'Extraction complete!');
89
94
  spinner.succeed(completionMessage);
90
95
  // Show the funnel message only if files were actually changed.
91
- if (anyFileUpdated)
92
- 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);
93
100
  return { anyFileUpdated, hasErrors: fileErrors.length > 0 };
94
101
  }
95
102
  catch (error) {
@@ -250,24 +257,27 @@ async function extract(config, { syncPrimaryWithDefaults = false } = {}) {
250
257
  * Prints a promotional message for the locize saveMissing workflow.
251
258
  * This message is shown after a successful extraction that resulted in changes.
252
259
  */
253
- async function printLocizeFunnel(logger$1) {
254
- if (!(await funnelMsgTracker.shouldShowFunnel('extract')))
260
+ async function printLocizeFunnel(logger$1, force) {
261
+ if (!force && !(await funnelMsgTracker.shouldShowFunnel('extract')))
255
262
  return;
256
263
  const internalLogger = logger$1 ?? new logger.ConsoleLogger();
257
- if (typeof internalLogger.info === 'function') {
258
- internalLogger.info(node_util.styleText(['yellow', 'bold'], '\n💡 Tip: Tired of running the extractor manually?'));
259
- internalLogger.info(' Discover a real-time "push" workflow with `saveMissing` and Locize AI,');
260
- internalLogger.info(' where keys are created and translated automatically as you code.');
261
- internalLogger.info(` Learn more: ${node_util.styleText('cyan', 'https://www.locize.com/blog/i18next-savemissing-ai-automation')}`);
262
- internalLogger.info(` Watch the video: ${node_util.styleText('cyan', 'https://youtu.be/joPsZghT3wM')}`);
263
- }
264
- else {
265
- console.log(node_util.styleText(['yellow', 'bold'], '\n💡 Tip: Tired of running the extractor manually?'));
266
- console.log(' Discover a real-time "push" workflow with `saveMissing` and Locize AI,');
267
- console.log(' where keys are created and translated automatically as you code.');
268
- console.log(` Learn more: ${node_util.styleText('cyan', 'https://www.locize.com/blog/i18next-savemissing-ai-automation')}`);
269
- console.log(` Watch the video: ${node_util.styleText('cyan', 'https://youtu.be/joPsZghT3wM')}`);
270
- }
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);
271
281
  return funnelMsgTracker.recordFunnelShown('extract');
272
282
  }
273
283
 
@@ -5,6 +5,7 @@ var glob = require('glob');
5
5
  var nestedObject = require('../../utils/nested-object.js');
6
6
  var fileUtils = require('../../utils/file-utils.js');
7
7
  var defaultValue = require('../../utils/default-value.js');
8
+ var logger = require('../../utils/logger.js');
8
9
 
9
10
  // used for natural language check
10
11
  const chars = [' ', ',', '?', '!', ';'];
@@ -208,7 +209,7 @@ function sortObject(obj, config, customSort) {
208
209
  * A helper function to build a new translation object for a single namespace.
209
210
  * This centralizes the core logic of merging keys.
210
211
  */
211
- function buildNewTranslationsForNs(nsKeys, existingTranslations, config, locale, namespace, preservePatterns = [], objectKeys = new Set(), syncPrimaryWithDefaults = false, syncAll = false) {
212
+ function buildNewTranslationsForNs(nsKeys, existingTranslations, config, locale, namespace, preservePatterns = [], objectKeys = new Set(), syncPrimaryWithDefaults = false, syncAll = false, logger$1 = new logger.ConsoleLogger()) {
212
213
  const { keySeparator = '.', sort = true, removeUnusedKeys = true, primaryLanguage, defaultValue: emptyDefaultValue = '', pluralSeparator = '_', contextSeparator = '_', preserveContextVariants = false, } = config.extract;
213
214
  const nsSep = typeof config.extract.nsSeparator === 'string' ? config.extract.nsSeparator : ':';
214
215
  // Keep the raw configured defaultValue so we can distinguish:
@@ -337,7 +338,7 @@ function buildNewTranslationsForNs(nsKeys, existingTranslations, config, locale,
337
338
  return false;
338
339
  };
339
340
  // Filter nsKeys to only include keys relevant to this language
340
- const filteredKeys = nsKeys.filter(({ key, hasCount, isOrdinal }) => {
341
+ const filteredKeys = nsKeys.filter(({ key, hasCount, isOrdinal, explicitDefault }) => {
341
342
  // FIRST: Check if key matches preservePatterns and should be excluded
342
343
  if (shouldFilterKey(key)) {
343
344
  return false;
@@ -363,14 +364,21 @@ function buildNewTranslationsForNs(nsKeys, existingTranslations, config, locale,
363
364
  return true;
364
365
  // Otherwise fall through and check the explicit suffix as before.
365
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
+ }
366
376
  if (isOrdinal && keyParts.includes('ordinal')) {
367
377
  // For ordinal plurals: key_context_ordinal_category or key_ordinal_category
368
- const lastPart = keyParts[keyParts.length - 1];
369
378
  return targetLanguagePluralCategories.has(`ordinal_${lastPart}`);
370
379
  }
371
380
  else if (hasCount) {
372
381
  // For cardinal plurals: key_context_category or key_category
373
- const lastPart = keyParts[keyParts.length - 1];
374
382
  return targetLanguagePluralCategories.has(lastPart);
375
383
  }
376
384
  return true;
@@ -694,13 +702,13 @@ function buildNewTranslationsForNs(nsKeys, existingTranslations, config, locale,
694
702
  // that was already written by a different extracted key, e.g.:
695
703
  // t("a.b") => sets a.b = string
696
704
  // t("a.b.c") => tries to descend into a.b which is already a string
697
- // In that situation we skip the conflicting key and emit a console.error so
705
+ // In that situation we skip the conflicting key and emit a log error so
698
706
  // developers see the problem immediately — a skipped key becomes a missing
699
707
  // translation at runtime.
700
708
  if (separator && typeof separator === 'string') {
701
709
  const conflictingPath = findNestingConflict(newTranslations, key, separator);
702
710
  if (conflictingPath !== null) {
703
- console.error(`[i18next-toolkit] Nesting conflict: key "${key}" conflicts with existing key "${conflictingPath}". ` +
711
+ logger$1.error(`Nesting conflict: key "${key}" conflicts with existing key "${conflictingPath}". ` +
704
712
  `"${key}" will be skipped — fix the overlapping key paths in your source code to avoid missing translations at runtime.`);
705
713
  continue;
706
714
  }
@@ -784,7 +792,7 @@ function buildNewTranslationsForNs(nsKeys, existingTranslations, config, locale,
784
792
  * // Results contain update status and new/existing translations for each locale.
785
793
  * ```
786
794
  */
787
- async function getTranslations(keys, objectKeys, config, { syncPrimaryWithDefaults = false, syncAll = false } = {}) {
795
+ async function getTranslations(keys, objectKeys, config, { syncPrimaryWithDefaults = false, syncAll = false, logger: logger$1 = new logger.ConsoleLogger() } = {}) {
788
796
  config.extract.primaryLanguage ||= config.locales[0] || 'en';
789
797
  config.extract.secondaryLanguages ||= config.locales.filter((l) => l !== config?.extract?.primaryLanguage);
790
798
  const patternsToPreserve = [...(config.extract.preservePatterns || [])];
@@ -872,12 +880,12 @@ async function getTranslations(keys, objectKeys, config, { syncPrimaryWithDefaul
872
880
  const nsKeys = keysByNS.get(nsKey) || [];
873
881
  if (nsKey === NO_NS_TOKEN) {
874
882
  // keys without namespace -> merged into top-level of the merged file
875
- const built = buildNewTranslationsForNs(nsKeys, existingMergedFile, config, locale, undefined, preservePatterns, objectKeys, syncPrimaryWithDefaults);
883
+ const built = buildNewTranslationsForNs(nsKeys, existingMergedFile, config, locale, undefined, preservePatterns, objectKeys, syncPrimaryWithDefaults, undefined, logger$1);
876
884
  Object.assign(newMergedTranslations, built);
877
885
  }
878
886
  else {
879
887
  const existingTranslations = existingMergedFile[nsKey] || {};
880
- newMergedTranslations[nsKey] = buildNewTranslationsForNs(nsKeys, existingTranslations, config, locale, nsKey, preservePatterns, objectKeys, syncPrimaryWithDefaults);
888
+ newMergedTranslations[nsKey] = buildNewTranslationsForNs(nsKeys, existingTranslations, config, locale, nsKey, preservePatterns, objectKeys, syncPrimaryWithDefaults, undefined, logger$1);
881
889
  }
882
890
  }
883
891
  // Preserve ignored namespaces as-is from the existing merged file
@@ -915,7 +923,7 @@ async function getTranslations(keys, objectKeys, config, { syncPrimaryWithDefaul
915
923
  const outputPath = fileUtils.getOutputPath(config.extract.output, locale, ns);
916
924
  const fullPath = node_path.resolve(process.cwd(), outputPath);
917
925
  const existingTranslations = await fileUtils.loadTranslationFile(fullPath) || {};
918
- const newTranslations = buildNewTranslationsForNs(nsKeys, existingTranslations, config, locale, ns, preservePatterns, objectKeys, syncPrimaryWithDefaults, syncAll);
926
+ const newTranslations = buildNewTranslationsForNs(nsKeys, existingTranslations, config, locale, ns, preservePatterns, objectKeys, syncPrimaryWithDefaults, syncAll, logger$1);
919
927
  const oldContent = JSON.stringify(existingTranslations, null, indentation);
920
928
  const newContent = JSON.stringify(newTranslations, null, indentation);
921
929
  // Push one result per namespace file
@@ -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());