i18next-cli 1.54.2 → 1.56.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 (34) hide show
  1. package/README.md +10 -3
  2. package/dist/cjs/cli.js +66 -2
  3. package/dist/cjs/extractor/core/ast-visitors.js +11 -1
  4. package/dist/cjs/extractor/core/extractor.js +1 -1
  5. package/dist/cjs/extractor/core/translation-manager.js +127 -54
  6. package/dist/cjs/extractor/parsers/call-expression-handler.js +17 -93
  7. package/dist/cjs/extractor/parsers/expression-resolver.js +96 -11
  8. package/dist/cjs/status.js +73 -12
  9. package/dist/cjs/utils/context-variants.js +59 -0
  10. package/dist/cjs/utils/nesting.js +100 -0
  11. package/dist/esm/cli.js +66 -2
  12. package/dist/esm/extractor/core/ast-visitors.js +11 -1
  13. package/dist/esm/extractor/core/extractor.js +1 -1
  14. package/dist/esm/extractor/core/translation-manager.js +126 -53
  15. package/dist/esm/extractor/parsers/call-expression-handler.js +17 -93
  16. package/dist/esm/extractor/parsers/expression-resolver.js +96 -11
  17. package/dist/esm/status.js +74 -13
  18. package/dist/esm/utils/context-variants.js +57 -0
  19. package/dist/esm/utils/nesting.js +98 -0
  20. package/package.json +1 -1
  21. package/types/cli.d.ts.map +1 -1
  22. package/types/extractor/core/ast-visitors.d.ts.map +1 -1
  23. package/types/extractor/core/extractor.d.ts +1 -0
  24. package/types/extractor/core/extractor.d.ts.map +1 -1
  25. package/types/extractor/core/translation-manager.d.ts.map +1 -1
  26. package/types/extractor/parsers/call-expression-handler.d.ts +3 -2
  27. package/types/extractor/parsers/call-expression-handler.d.ts.map +1 -1
  28. package/types/extractor/parsers/expression-resolver.d.ts +11 -0
  29. package/types/extractor/parsers/expression-resolver.d.ts.map +1 -1
  30. package/types/status.d.ts.map +1 -1
  31. package/types/utils/context-variants.d.ts +22 -0
  32. package/types/utils/context-variants.d.ts.map +1 -0
  33. package/types/utils/nesting.d.ts +36 -0
  34. package/types/utils/nesting.d.ts.map +1 -0
@@ -18,6 +18,11 @@ class ExpressionResolver {
18
18
  // Shared (cross-file) table for type aliases — populated alongside typeAliasTable.
19
19
  // Persists across resetFileSymbols() so exported type aliases are visible to importers.
20
20
  sharedTypeAliasTable = new Map();
21
+ // Shared (cross-file) table for function return-value sets. Populated from
22
+ // both explicit return-type annotations and body-inferred return values so
23
+ // that `t(fn())` / `const x = fn(); t(\`...${x}...\`)` work across files.
24
+ // Persists across resetFileSymbols() just like the other shared tables.
25
+ sharedFunctionReturnTable = new Map();
21
26
  // Temporary per-scope variable overrides, used to inject .map() / .forEach()
22
27
  // callback parameters while the callback body is being walked.
23
28
  temporaryVariables = new Map();
@@ -168,15 +173,23 @@ class ExpressionResolver {
168
173
  return;
169
174
  }
170
175
  // pattern 3 (arrow function variant):
171
- // `const fn = (): 'a' | 'b' => ...` — capture the explicit return type annotation.
176
+ // `const fn = (): 'a' | 'b' => ...` — capture the explicit return type annotation,
177
+ // OR fall back to walking the body's return expressions / expression body
178
+ // when no annotation is present (mirrors TS's own return-type inference).
172
179
  if (unwrappedInit.type === 'ArrowFunctionExpression' || unwrappedInit.type === 'FunctionExpression') {
180
+ let returnVals = [];
173
181
  const rawReturnType = unwrappedInit.returnType ?? unwrappedInit.typeAnnotation;
174
182
  if (rawReturnType) {
183
+ // Explicit annotation — trust it even when it resolves to [].
175
184
  const tsType = rawReturnType.typeAnnotation ?? rawReturnType;
176
- const returnVals = this.resolvePossibleStringValuesFromType(tsType);
177
- if (returnVals.length > 0) {
178
- this.variableTable.set(name, returnVals);
179
- }
185
+ returnVals = this.resolvePossibleStringValuesFromType(tsType);
186
+ }
187
+ else {
188
+ returnVals = this.inferReturnValuesFromFunctionBody(unwrappedInit);
189
+ }
190
+ if (returnVals.length > 0) {
191
+ this.variableTable.set(name, returnVals);
192
+ this.sharedFunctionReturnTable.set(name, returnVals);
180
193
  }
181
194
  }
182
195
  }
@@ -231,19 +244,85 @@ class ExpressionResolver {
231
244
  // or directly in `.returnType` (FunctionExpression / ArrowFunctionExpression).
232
245
  const fn = node.function ?? node;
233
246
  const rawReturnType = fn.returnType ?? fn.typeAnnotation;
234
- if (!rawReturnType)
235
- return;
236
- // Unwrap TsTypeAnnotation wrapper if present
237
- const tsType = rawReturnType.typeAnnotation ?? rawReturnType;
238
- const vals = this.resolvePossibleStringValuesFromType(tsType);
247
+ let vals = [];
248
+ if (rawReturnType) {
249
+ // Unwrap TsTypeAnnotation wrapper if present. Explicit annotations are
250
+ // authoritative: if the author declared the return type we trust it,
251
+ // even when it resolves to [] (e.g. plain `string`). Falling back to
252
+ // body inference in that case would invent keys the author deliberately
253
+ // opted out of.
254
+ const tsType = rawReturnType.typeAnnotation ?? rawReturnType;
255
+ vals = this.resolvePossibleStringValuesFromType(tsType);
256
+ }
257
+ else {
258
+ // No annotation — infer from body. Mirrors TS's own return-type
259
+ // inference for functions like:
260
+ // function getCurrentAppType() {
261
+ // if (...) return OrganizationType.ROUTING;
262
+ // if (...) return OrganizationType.CONTRACTOR;
263
+ // }
264
+ vals = this.inferReturnValuesFromFunctionBody(fn);
265
+ }
239
266
  if (vals.length > 0) {
240
267
  this.variableTable.set(name, vals);
268
+ this.sharedFunctionReturnTable.set(name, vals);
241
269
  }
242
270
  }
243
271
  catch {
244
272
  // noop
245
273
  }
246
274
  }
275
+ /**
276
+ * Walk a function body's ReturnStatements and union the statically-resolvable
277
+ * string values of their argument expressions. Does NOT descend into nested
278
+ * function declarations (their returns belong to the inner function, not us).
279
+ *
280
+ * This is how we mirror TypeScript's implicit return-type inference for the
281
+ * purpose of extracting translation keys — we don't need exhaustiveness, just
282
+ * the set of string values any return statement could produce.
283
+ */
284
+ inferReturnValuesFromFunctionBody(fn) {
285
+ const body = fn?.body;
286
+ if (!body)
287
+ return [];
288
+ const collected = [];
289
+ const visit = (n) => {
290
+ if (!n || typeof n !== 'object')
291
+ return;
292
+ // Don't descend into nested function bodies — their returns aren't ours.
293
+ if (n !== body && (n.type === 'FunctionDeclaration' ||
294
+ n.type === 'FunctionExpression' ||
295
+ n.type === 'ArrowFunctionExpression'))
296
+ return;
297
+ if (n.type === 'ReturnStatement' && n.argument) {
298
+ const vals = this.resolvePossibleStringValuesFromExpression(n.argument);
299
+ if (vals.length > 0)
300
+ collected.push(...vals);
301
+ }
302
+ for (const key of Object.keys(n)) {
303
+ const child = n[key];
304
+ if (Array.isArray(child)) {
305
+ for (const item of child) {
306
+ if (item && typeof item === 'object')
307
+ visit(item);
308
+ }
309
+ }
310
+ else if (child && typeof child === 'object' && typeof child.type === 'string') {
311
+ visit(child);
312
+ }
313
+ }
314
+ };
315
+ // Arrow functions with an expression body (no BlockStatement) — `() => expr` —
316
+ // have their return expression directly as `body`.
317
+ if (body.type !== 'BlockStatement') {
318
+ const vals = this.resolvePossibleStringValuesFromExpression(body);
319
+ if (vals.length > 0)
320
+ return Array.from(new Set(vals));
321
+ return [];
322
+ }
323
+ visit(body);
324
+ return Array.from(new Set(collected));
325
+ }
247
326
  /**
248
327
  * Extract a raw TsType node from an identifier's type annotation.
249
328
  * SWC may wrap it in a `TsTypeAnnotation` node — this unwraps it.
@@ -503,7 +582,10 @@ class ExpressionResolver {
503
582
  catch { }
504
583
  }
505
584
  // pattern 3:
506
- // `t(fn())` — resolve to the function's known return-type union when captured.
585
+ // `t(fn())` — resolve to the function's known return-value set (either
586
+ // from an explicit annotation or inferred from the function body). Check
587
+ // the per-file variable table first (same-file capture) and fall back to
588
+ // the shared cross-file table populated during pre-scan.
507
589
  if (expression.type === 'CallExpression') {
508
590
  try {
509
591
  const callee = expression.callee;
@@ -511,6 +593,9 @@ class ExpressionResolver {
511
593
  const v = this.variableTable.get(callee.value);
512
594
  if (Array.isArray(v) && v.length > 0)
513
595
  return v;
596
+ const sv = this.sharedFunctionReturnTable.get(callee.value);
597
+ if (sv && sv.length > 0)
598
+ return sv;
514
599
  }
515
600
  }
516
601
  catch { }
@@ -10,6 +10,7 @@ require('glob');
10
10
  var nestedObject = require('./utils/nested-object.js');
11
11
  var fileUtils = require('./utils/file-utils.js');
12
12
  var pluralRules = require('./utils/plural-rules.js');
13
+ var contextVariants = require('./utils/context-variants.js');
13
14
  var funnelMsgTracker = require('./utils/funnel-msg-tracker.js');
14
15
  require('./extractor/parsers/jsx-parser.js');
15
16
 
@@ -82,7 +83,8 @@ async function generateStatusReport(config) {
82
83
  config.extract.primaryLanguage ||= config.locales[0] || 'en';
83
84
  config.extract.secondaryLanguages ||= config.locales.filter((l) => l !== config?.extract?.primaryLanguage);
84
85
  const { allKeys: allExtractedKeys } = await keyFinder.findKeys(config);
85
- const { secondaryLanguages, keySeparator = '.', defaultNS = 'translation', mergeNamespaces = false, pluralSeparator = '_', fallbackNS } = config.extract;
86
+ const { secondaryLanguages, keySeparator = '.', defaultNS = 'translation', mergeNamespaces = false, pluralSeparator = '_', contextSeparator = '_', fallbackNS } = config.extract;
87
+ const primaryLanguage = config.extract.primaryLanguage || config.locales[0] || 'en';
86
88
  const keysByNs = new Map();
87
89
  for (const key of allExtractedKeys.values()) {
88
90
  const ns = key.ns || defaultNS || 'translation';
@@ -105,6 +107,40 @@ async function generateStatusReport(config) {
105
107
  keysByNs,
106
108
  locales: new Map(),
107
109
  };
110
+ // Discover context variants that live in the primary translation file but
111
+ // are not directly extracted as keys (see issue #243). When source code uses
112
+ // a dynamic context value like `t('exportType', { context: type })`, the
113
+ // extractor can only tag the base key as "accepting context"; the actual
114
+ // context values (`_gas`, `_water`, ...) are only visible in the primary
115
+ // translation file. Without this scan, status never checks those variants
116
+ // for translation gaps in secondary locales.
117
+ const keysAcceptingContext = new Set();
118
+ for (const keys of keysByNs.values()) {
119
+ for (const k of keys) {
120
+ if (k.keyAcceptingContext)
121
+ keysAcceptingContext.add(k.keyAcceptingContext);
122
+ }
123
+ }
124
+ const contextVariantsByNs = new Map();
125
+ if (keysAcceptingContext.size > 0) {
126
+ const primaryMerged = mergeNamespaces
127
+ ? ((await fileUtils.loadTranslationFile(node_path.resolve(process.cwd(), fileUtils.getOutputPath(config.extract.output, primaryLanguage, (defaultNS === false ? 'translation' : (defaultNS || 'translation')))))) || {})
128
+ : null;
129
+ for (const ns of keysByNs.keys()) {
130
+ const primaryNsTranslations = mergeNamespaces
131
+ ? (primaryMerged?.[ns] ?? primaryMerged ?? {})
132
+ : ((await fileUtils.loadTranslationFile(node_path.resolve(process.cwd(), fileUtils.getOutputPath(config.extract.output, primaryLanguage, ns)))) || {});
133
+ const primaryKeys = nestedObject.getNestedKeys(primaryNsTranslations, keySeparator ?? '.');
134
+ const variants = [];
135
+ for (const primaryKey of primaryKeys) {
136
+ if (contextVariants.isContextVariantOfAcceptingKey(primaryKey, keysAcceptingContext, pluralSeparator, contextSeparator)) {
137
+ variants.push(primaryKey);
138
+ }
139
+ }
140
+ if (variants.length > 0)
141
+ contextVariantsByNs.set(ns, variants);
142
+ }
143
+ }
108
144
  for (const locale of secondaryLanguages) {
109
145
  let totalTranslatedForLocale = 0;
110
146
  let totalEmptyForLocale = 0;
@@ -172,6 +208,7 @@ async function generateStatusReport(config) {
172
208
  }
173
209
  return primaryState;
174
210
  };
211
+ const processedKeys = new Set();
175
212
  for (const { key: baseKey, hasCount, isOrdinal, isExpandedPlural } of keysInNs) {
176
213
  if (hasCount) {
177
214
  if (isExpandedPlural) {
@@ -185,7 +222,8 @@ async function generateStatusReport(config) {
185
222
  // Get the plural categories for this locale
186
223
  const localePluralCategories = getLocalePluralCategories(locale, isOrdinalVariant);
187
224
  // Only count this key if it's a plural form used by this locale
188
- if (localePluralCategories.includes(category)) {
225
+ if (localePluralCategories.includes(category) && !processedKeys.has(baseKey)) {
226
+ processedKeys.add(baseKey);
189
227
  totalInNs++;
190
228
  const state = resolveAndClassify(baseKey);
191
229
  if (state === 'translated')
@@ -202,10 +240,13 @@ async function generateStatusReport(config) {
202
240
  // Expand it according to THIS locale's plural rules
203
241
  const localePluralCategories = getLocalePluralCategories(locale, isOrdinal || false);
204
242
  for (const category of localePluralCategories) {
205
- totalInNs++;
206
243
  const pluralKey = isOrdinal
207
244
  ? `${baseKey}${pluralSeparator}ordinal${pluralSeparator}${category}`
208
245
  : `${baseKey}${pluralSeparator}${category}`;
246
+ if (processedKeys.has(pluralKey))
247
+ continue;
248
+ processedKeys.add(pluralKey);
249
+ totalInNs++;
209
250
  const state = resolveAndClassify(pluralKey);
210
251
  if (state === 'translated')
211
252
  translatedInNs++;
@@ -218,17 +259,37 @@ async function generateStatusReport(config) {
218
259
  }
219
260
  }
220
261
  else {
221
- totalInNs++;
222
- const state = resolveAndClassify(baseKey);
223
- if (state === 'translated')
224
- translatedInNs++;
225
- else if (state === 'empty')
226
- emptyInNs++;
227
- else
228
- absentInNs++;
229
- keyDetails.push({ key: baseKey, state });
262
+ if (!processedKeys.has(baseKey)) {
263
+ processedKeys.add(baseKey);
264
+ totalInNs++;
265
+ const state = resolveAndClassify(baseKey);
266
+ if (state === 'translated')
267
+ translatedInNs++;
268
+ else if (state === 'empty')
269
+ emptyInNs++;
270
+ else
271
+ absentInNs++;
272
+ keyDetails.push({ key: baseKey, state });
273
+ }
230
274
  }
231
275
  }
276
+ // Additionally check context variants discovered in the primary file
277
+ // (see issue #243). Skip variants already counted via extracted keys.
278
+ const contextVariants = contextVariantsByNs.get(ns) || [];
279
+ for (const variantKey of contextVariants) {
280
+ if (processedKeys.has(variantKey))
281
+ continue;
282
+ processedKeys.add(variantKey);
283
+ totalInNs++;
284
+ const state = resolveAndClassify(variantKey);
285
+ if (state === 'translated')
286
+ translatedInNs++;
287
+ else if (state === 'empty')
288
+ emptyInNs++;
289
+ else
290
+ absentInNs++;
291
+ keyDetails.push({ key: variantKey, state });
292
+ }
232
293
  namespaces.set(ns, { totalKeys: totalInNs, translatedKeys: translatedInNs, emptyKeys: emptyInNs, absentKeys: absentInNs, keyDetails });
233
294
  totalTranslatedForLocale += translatedInNs;
234
295
  totalEmptyForLocale += emptyInNs;
@@ -0,0 +1,59 @@
1
+ 'use strict';
2
+
3
+ /**
4
+ * Helpers for reasoning about context variants of translation keys.
5
+ *
6
+ * A "key accepting context" is a base key that was called with a `context`
7
+ * option in source code (e.g. `t('friend', { context: gender })`). Its
8
+ * variants in the translation file look like `<base><contextSeparator><ctx>`
9
+ * (optionally suffixed with a CLDR plural form).
10
+ */
11
+ const pluralForms = ['zero', 'one', 'two', 'few', 'many', 'other'];
12
+ /**
13
+ * Checks if an existing key is a context variant of a base key that accepts context.
14
+ * Handles:
15
+ * - Keys suffixed with a CLDR plural form (e.g. `friend_male_one`).
16
+ * - Context values that contain the separator (e.g. `mc_laren`).
17
+ *
18
+ * @param existingKey - The key from the translation file to check
19
+ * @param keysAcceptingContext - Set of base keys that were used with context in source code
20
+ * @param pluralSeparator - The separator used for plural forms (default: '_')
21
+ * @param contextSeparator - The separator used for context variants (default: '_')
22
+ * @returns true if the existing key is a context variant of a key accepting context
23
+ */
24
+ function isContextVariantOfAcceptingKey(existingKey, keysAcceptingContext, pluralSeparator, contextSeparator) {
25
+ if (keysAcceptingContext.size === 0) {
26
+ return false;
27
+ }
28
+ let potentialBaseKey = existingKey;
29
+ // First, try removing plural suffixes if present
30
+ for (const form of pluralForms) {
31
+ if (potentialBaseKey.endsWith(`${pluralSeparator}${form}`)) {
32
+ potentialBaseKey = potentialBaseKey.slice(0, -(pluralSeparator.length + form.length));
33
+ break;
34
+ }
35
+ if (potentialBaseKey.endsWith(`${pluralSeparator}ordinal${pluralSeparator}${form}`)) {
36
+ potentialBaseKey = potentialBaseKey.slice(0, -(pluralSeparator.length + 'ordinal'.length + pluralSeparator.length + form.length));
37
+ break;
38
+ }
39
+ }
40
+ // The context value itself may contain the separator — try every possible
41
+ // split to find a base that matches an accepting-context key.
42
+ const parts = potentialBaseKey.split(contextSeparator);
43
+ if (parts.length > 1) {
44
+ for (let i = 1; i < parts.length; i++) {
45
+ const baseWithoutContext = parts.slice(0, -i).join(contextSeparator);
46
+ if (keysAcceptingContext.has(baseWithoutContext)) {
47
+ return true;
48
+ }
49
+ }
50
+ }
51
+ // Also accept the plural-stripped key itself as a direct match
52
+ // (e.g. `friend_other` → base `friend`).
53
+ if (keysAcceptingContext.has(potentialBaseKey)) {
54
+ return true;
55
+ }
56
+ return false;
57
+ }
58
+
59
+ exports.isContextVariantOfAcceptingKey = isContextVariantOfAcceptingKey;
@@ -0,0 +1,100 @@
1
+ 'use strict';
2
+
3
+ /**
4
+ * Shared parser for i18next-style nested translation references of the form
5
+ * `$t(key, { options })`.
6
+ *
7
+ * Used in two places:
8
+ * 1. The extractor's AST pass scans source-code keys and default values for
9
+ * nested references and registers the referenced keys (so they show up in
10
+ * output translation files).
11
+ * 2. The translation-manager uses it during `removeUnusedKeys` cleanup so
12
+ * keys that are only referenced from inside a translation value (and thus
13
+ * invisible to the AST pass) are preserved instead of being deleted.
14
+ */
15
+ const naturalLanguageChars = /[ ,?!;]/;
16
+ const looksLikeNaturalLanguage = (s) => naturalLanguageChars.test(s);
17
+ const escapeRegex = (str) => str.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
18
+ /**
19
+ * Scans a string for `$t(...)` references and returns metadata about each one.
20
+ * The implementation mirrors the behaviour of i18next's Interpolator so that
21
+ * the extractor and translation manager agree on what counts as a reference.
22
+ */
23
+ function parseNestedReferences(text, config) {
24
+ if (!text || typeof text !== 'string')
25
+ return [];
26
+ const prefix = config.nestingPrefix ?? '$t(';
27
+ const suffix = config.nestingSuffix ?? ')';
28
+ const separator = config.nestingOptionsSeparator ?? ',';
29
+ const nsSeparator = config.nsSeparator ?? ':';
30
+ const escapedPrefix = escapeRegex(prefix);
31
+ const escapedSuffix = escapeRegex(suffix);
32
+ // Regex adapted from i18next Interpolator.js — matches `$t(key)` or
33
+ // `$t(key, { options })` with (limited) support for balanced parens and
34
+ // quoted strings.
35
+ const nestingRegexp = new RegExp(`${escapedPrefix}((?:[^()"']+|"[^"]*"|'[^']*'|\\((?:[^()]|"[^"]*"|'[^']*')*\\))*?)${escapedSuffix}`, 'g');
36
+ const results = [];
37
+ let match;
38
+ while ((match = nestingRegexp.exec(text)) !== null) {
39
+ const content = match[1];
40
+ if (!content)
41
+ continue;
42
+ let key = content;
43
+ let optionsString = '';
44
+ if (content.indexOf(separator) < 0) {
45
+ key = content.trim();
46
+ }
47
+ else {
48
+ // i18next does: const c = key.split(new RegExp(`${sep}[ ]*{`));
49
+ // This assumes options start with `{`.
50
+ const sepRegex = new RegExp(`${escapeRegex(separator)}[ ]*{`);
51
+ const parts = content.split(sepRegex);
52
+ if (parts.length > 1) {
53
+ key = parts[0].trim();
54
+ optionsString = `{${parts.slice(1).join(separator + ' {')}`;
55
+ }
56
+ else {
57
+ const sepIdx = content.indexOf(separator);
58
+ key = content.substring(0, sepIdx).trim();
59
+ optionsString = content.substring(sepIdx + 1).trim();
60
+ }
61
+ }
62
+ if ((key.startsWith("'") && key.endsWith("'")) || (key.startsWith('"') && key.endsWith('"'))) {
63
+ key = key.slice(1, -1);
64
+ }
65
+ if (!key)
66
+ continue;
67
+ let ns;
68
+ if (nsSeparator && typeof nsSeparator === 'string' && key.includes(nsSeparator)) {
69
+ const parts = key.split(nsSeparator);
70
+ const candidateNs = parts[0];
71
+ if (!looksLikeNaturalLanguage(candidateNs)) {
72
+ ns = parts.shift();
73
+ key = parts.join(nsSeparator);
74
+ if (!key || key.trim() === '')
75
+ continue;
76
+ }
77
+ else {
78
+ ns = config.defaultNS;
79
+ }
80
+ }
81
+ else {
82
+ ns = config.defaultNS;
83
+ }
84
+ let hasCount = false;
85
+ let context;
86
+ if (optionsString) {
87
+ if (/['"]?count['"]?\s*:/.test(optionsString)) {
88
+ hasCount = true;
89
+ }
90
+ const contextMatch = /['"]?context['"]?\s*:\s*(['"])(.*?)\1/.exec(optionsString);
91
+ if (contextMatch) {
92
+ context = contextMatch[2];
93
+ }
94
+ }
95
+ results.push({ key, ns, hasCount, context });
96
+ }
97
+ return results;
98
+ }
99
+
100
+ exports.parseNestedReferences = parseNestedReferences;
package/dist/esm/cli.js CHANGED
@@ -9,6 +9,7 @@ import { detectConfig } from './heuristic-config.js';
9
9
  import { runExtractor } from './extractor/core/extractor.js';
10
10
  import './extractor/parsers/jsx-parser.js';
11
11
  import 'node:path';
12
+ import { getNestedKeys, getNestedValue } from './utils/nested-object.js';
12
13
  import 'node:fs/promises';
13
14
  import 'jiti';
14
15
  import '@croct/json5-parser';
@@ -29,7 +30,7 @@ const program = new Command();
29
30
  program
30
31
  .name('i18next-cli')
31
32
  .description('A unified, high-performance i18next CLI.')
32
- .version('1.54.2'); // This string is replaced with the actual version at build time by rollup
33
+ .version('1.56.0'); // This string is replaced with the actual version at build time by rollup
33
34
  // new: global config override option
34
35
  program.option('-c, --config <path>', 'Path to i18next-cli config file (overrides detection)');
35
36
  program
@@ -49,7 +50,7 @@ program
49
50
  const runExtract = async () => {
50
51
  // --sync-all implies sync-primary behavior
51
52
  const syncPrimary = !!options.syncPrimary || !!options.syncAll;
52
- const { anyFileUpdated, hasErrors } = await runExtractor(config, {
53
+ const { anyFileUpdated, hasErrors, results } = await runExtractor(config, {
53
54
  isWatchMode: !!options.watch,
54
55
  isDryRun: !!options.dryRun,
55
56
  syncPrimaryWithDefaults: syncPrimary,
@@ -63,6 +64,7 @@ program
63
64
  }
64
65
  else if (options.ci && anyFileUpdated) {
65
66
  console.error('❌ Some files were updated. This should not happen in CI mode.');
67
+ printCiDiff(results, config);
66
68
  process.exit(1);
67
69
  }
68
70
  if (hasErrors && !options.watch) {
@@ -326,5 +328,67 @@ const expandGlobs = async (patterns = []) => {
326
328
  const sets = await Promise.all(arr.map(p => glob(p || '', { nodir: true })));
327
329
  return Array.from(new Set(sets.flat()));
328
330
  };
331
+ function printCiDiff(results, config) {
332
+ const rawSep = config.extract.keySeparator;
333
+ const keySeparator = rawSep === false ? false : (rawSep ?? '.');
334
+ for (const result of results) {
335
+ if (!result.updated)
336
+ continue;
337
+ const existing = result.existingTranslations || {};
338
+ const next = result.newTranslations || {};
339
+ const oldKeys = new Set(getNestedKeys(existing, keySeparator));
340
+ const newKeys = new Set(getNestedKeys(next, keySeparator));
341
+ const added = [];
342
+ const removed = [];
343
+ const changed = [];
344
+ for (const k of newKeys) {
345
+ if (!oldKeys.has(k)) {
346
+ added.push(k);
347
+ }
348
+ else {
349
+ const oldVal = getNestedValue(existing, k, keySeparator);
350
+ const newVal = getNestedValue(next, k, keySeparator);
351
+ if (oldVal !== newVal)
352
+ changed.push(k);
353
+ }
354
+ }
355
+ for (const k of oldKeys) {
356
+ if (!newKeys.has(k))
357
+ removed.push(k);
358
+ }
359
+ const nsLabel = result.namespace
360
+ ? ` [${result.locale}/${result.namespace}]`
361
+ : ` [${result.locale}]`;
362
+ console.error(`\n ${result.path}${nsLabel}`);
363
+ if (added.length === 0 && removed.length === 0 && changed.length === 0) {
364
+ console.error(' (no key differences — only formatting or ordering changes)');
365
+ continue;
366
+ }
367
+ added.sort();
368
+ removed.sort();
369
+ changed.sort();
370
+ for (const k of added) {
371
+ const v = getNestedValue(next, k, keySeparator);
372
+ console.error(styleText('green', ` + ${k}: ${formatCiDiffValue(v)}`));
373
+ }
374
+ for (const k of removed) {
375
+ const v = getNestedValue(existing, k, keySeparator);
376
+ console.error(styleText('red', ` - ${k}: ${formatCiDiffValue(v)}`));
377
+ }
378
+ for (const k of changed) {
379
+ const oldV = getNestedValue(existing, k, keySeparator);
380
+ const newV = getNestedValue(next, k, keySeparator);
381
+ console.error(styleText('yellow', ` ~ ${k}: ${formatCiDiffValue(oldV)} → ${formatCiDiffValue(newV)}`));
382
+ }
383
+ }
384
+ }
385
+ function formatCiDiffValue(value) {
386
+ try {
387
+ return JSON.stringify(value);
388
+ }
389
+ catch {
390
+ return String(value);
391
+ }
392
+ }
329
393
 
330
394
  export { program };
@@ -94,6 +94,16 @@ class ASTVisitors {
94
94
  this.scopeManager.handleVariableDeclarator(node);
95
95
  this.expressionResolver.captureVariableDeclarator(node);
96
96
  break;
97
+ case 'TSEnumDeclaration':
98
+ case 'TsEnumDeclaration':
99
+ case 'TsEnumDecl':
100
+ // Enums → ExpressionResolver.sharedEnumTable. Needed in pre-scan so
101
+ // that function bodies referencing enum members (e.g. `return
102
+ // OrganizationType.ROUTING`) can be resolved by the body-inference
103
+ // branch of captureFunctionDeclaration when we hit the function later
104
+ // in the same file.
105
+ this.expressionResolver.captureEnumDeclaration(node);
106
+ break;
97
107
  case 'TsTypeAliasDeclaration':
98
108
  case 'TSTypeAliasDeclaration':
99
109
  case 'TsTypeAliasDecl':
@@ -102,7 +112,7 @@ class ASTVisitors {
102
112
  break;
103
113
  case 'FunctionDeclaration':
104
114
  case 'FnDecl':
105
- // Return-type annotations for t(fn()) patterns
115
+ // Return-type annotations or inferred return values for t(fn()) patterns
106
116
  this.expressionResolver.captureFunctionDeclaration(node);
107
117
  break;
108
118
  }
@@ -97,7 +97,7 @@ async function runExtractor(config, options = {}) {
97
97
  // always show the funnel regardless of cooldown.
98
98
  if (anyFileUpdated && !options.isDryRun && !options.quiet)
99
99
  await printLocizeFunnel(options.logger, anyNewFile);
100
- return { anyFileUpdated, hasErrors: fileErrors.length > 0 };
100
+ return { anyFileUpdated, hasErrors: fileErrors.length > 0, results };
101
101
  }
102
102
  catch (error) {
103
103
  spinner.fail(styleText('red', 'Extraction failed.'));