i18next-cli 1.46.4 → 1.47.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 +198 -3
- package/dist/cjs/cli.js +50 -1
- package/dist/cjs/extractor/core/extractor.js +27 -18
- package/dist/cjs/extractor/core/translation-manager.js +10 -3
- package/dist/cjs/extractor/parsers/call-expression-handler.js +9 -1
- package/dist/cjs/extractor/parsers/jsx-handler.js +10 -1
- package/dist/cjs/index.js +5 -0
- package/dist/cjs/init.js +68 -12
- package/dist/cjs/instrumenter/core/instrumenter.js +1633 -0
- package/dist/cjs/instrumenter/core/key-generator.js +71 -0
- package/dist/cjs/instrumenter/core/string-detector.js +290 -0
- package/dist/cjs/instrumenter/core/transformer.js +339 -0
- package/dist/cjs/linter.js +8 -9
- package/dist/cjs/utils/jsx-attributes.js +131 -0
- package/dist/esm/cli.js +50 -1
- package/dist/esm/extractor/core/extractor.js +27 -18
- package/dist/esm/extractor/core/translation-manager.js +10 -3
- package/dist/esm/extractor/parsers/call-expression-handler.js +9 -1
- package/dist/esm/extractor/parsers/jsx-handler.js +10 -1
- package/dist/esm/index.js +3 -0
- package/dist/esm/init.js +68 -12
- package/dist/esm/instrumenter/core/instrumenter.js +1630 -0
- package/dist/esm/instrumenter/core/key-generator.js +68 -0
- package/dist/esm/instrumenter/core/string-detector.js +288 -0
- package/dist/esm/instrumenter/core/transformer.js +336 -0
- package/dist/esm/linter.js +8 -9
- package/dist/esm/utils/jsx-attributes.js +121 -0
- package/package.json +2 -1
- package/types/cli.d.ts.map +1 -1
- package/types/extractor/core/extractor.d.ts.map +1 -1
- package/types/extractor/core/translation-manager.d.ts.map +1 -1
- package/types/extractor/parsers/call-expression-handler.d.ts.map +1 -1
- package/types/extractor/parsers/jsx-handler.d.ts.map +1 -1
- package/types/index.d.ts +2 -1
- package/types/index.d.ts.map +1 -1
- package/types/init.d.ts.map +1 -1
- package/types/instrumenter/core/instrumenter.d.ts +16 -0
- package/types/instrumenter/core/instrumenter.d.ts.map +1 -0
- package/types/instrumenter/core/key-generator.d.ts +30 -0
- package/types/instrumenter/core/key-generator.d.ts.map +1 -0
- package/types/instrumenter/core/string-detector.d.ts +27 -0
- package/types/instrumenter/core/string-detector.d.ts.map +1 -0
- package/types/instrumenter/core/transformer.d.ts +31 -0
- package/types/instrumenter/core/transformer.d.ts.map +1 -0
- package/types/instrumenter/index.d.ts +6 -0
- package/types/instrumenter/index.d.ts.map +1 -0
- package/types/linter.d.ts.map +1 -1
- package/types/types.d.ts +285 -1
- package/types/types.d.ts.map +1 -1
- package/types/utils/jsx-attributes.d.ts +68 -0
- 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
|
|
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)
|
|
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
|
-
|
|
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.
|
|
34
|
+
.version('1.47.1'); // 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
|
-
|
|
93
|
-
|
|
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
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
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:
|
|
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 (
|
|
153
|
-
|
|
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
|
-
|
|
158
|
-
|
|
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
|
-
|
|
164
|
-
|
|
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());
|