i18next-cli 1.55.0 → 1.56.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 CHANGED
@@ -607,9 +607,12 @@ export default defineConfig({
607
607
  ],
608
608
 
609
609
  /**
610
- * When true, preserves all context variants of keys that use context parameters.
611
- * For example, if 'friend' is used with context, all variants like 'friend_male',
612
- * 'friend_female', etc. are preserved even if not explicitly found in source code.
610
+ * When true, preserves all context variants of keys that use context parameters,
611
+ * across every configured locale. For example, if 'friend' is used with a context
612
+ * option in source code, variants like 'friend_male' and 'friend_female' are kept
613
+ * in the primary language even when they're not referenced explicitly, and are
614
+ * propagated to secondary locales with empty placeholders so every language ends
615
+ * up with the same key skeleton.
613
616
  * (default: false)
614
617
  */
615
618
  preserveContextVariants: false,
@@ -661,6 +664,10 @@ export default defineConfig({
661
664
 
662
665
  // Prefix for nested translations.
663
666
  // Controls how nested $t(...) calls inside strings are detected.
667
+ // Nested references are scanned in BOTH source code (keys and defaultValues
668
+ // passed to t()) and in the values of existing translation files, so keys
669
+ // reachable only via `$t(...)` inside a translation value are preserved by
670
+ // `extract` and expanded into the correct per-locale plural skeleton.
664
671
  // Example: '$t('
665
672
  nestingPrefix: '$t(', // Default: '$t('
666
673
 
package/dist/cjs/cli.js CHANGED
@@ -32,7 +32,7 @@ const program = new commander.Command();
32
32
  program
33
33
  .name('i18next-cli')
34
34
  .description('A unified, high-performance i18next CLI.')
35
- .version('1.55.0'); // This string is replaced with the actual version at build time by rollup
35
+ .version('1.56.1'); // This string is replaced with the actual version at build time by rollup
36
36
  // new: global config override option
37
37
  program.option('-c, --config <path>', 'Path to i18next-cli config file (overrides detection)');
38
38
  program
@@ -7,6 +7,8 @@ var fileUtils = require('../../utils/file-utils.js');
7
7
  var defaultValue = require('../../utils/default-value.js');
8
8
  var logger = require('../../utils/logger.js');
9
9
  var pluralRules = require('../../utils/plural-rules.js');
10
+ var nesting = require('../../utils/nesting.js');
11
+ var contextVariants = require('../../utils/context-variants.js');
10
12
 
11
13
  // used for natural language check
12
14
  const chars = [' ', ',', '?', '!', ';'];
@@ -21,58 +23,6 @@ function globToRegex(glob) {
21
23
  const regexString = `^${escaped.replace(/\*/g, '.*')}$`;
22
24
  return new RegExp(regexString);
23
25
  }
24
- /**
25
- * Checks if an existing key is a context variant of a base key that accepts context.
26
- * This function handles complex cases where:
27
- * - The key might have plural suffixes (_one, _other, etc.)
28
- * - The context value itself might contain the separator (e.g., mc_laren)
29
- *
30
- * @param existingKey - The key from the translation file to check
31
- * @param keysAcceptingContext - Set of base keys that were used with context in source code
32
- * @param pluralSeparator - The separator used for plural forms (default: '_')
33
- * @param contextSeparator - The separator used for context variants (default: '_')
34
- * @returns true if the existing key is a context variant of a key accepting context
35
- */
36
- function isContextVariantOfAcceptingKey(existingKey, keysAcceptingContext, pluralSeparator, contextSeparator) {
37
- if (keysAcceptingContext.size === 0) {
38
- return false;
39
- }
40
- // Try to extract the base key from this existing key by removing context and/or plural suffixes
41
- let potentialBaseKey = existingKey;
42
- // First, try removing plural suffixes if present
43
- for (const form of pluralForms) {
44
- if (potentialBaseKey.endsWith(`${pluralSeparator}${form}`)) {
45
- potentialBaseKey = potentialBaseKey.slice(0, -(pluralSeparator.length + form.length));
46
- break;
47
- }
48
- if (potentialBaseKey.endsWith(`${pluralSeparator}ordinal${pluralSeparator}${form}`)) {
49
- potentialBaseKey = potentialBaseKey.slice(0, -(pluralSeparator.length + 'ordinal'.length + pluralSeparator.length + form.length));
50
- break;
51
- }
52
- }
53
- // Then, try removing the context suffix to get the base key
54
- // We need to check all possible base keys since the context value itself might contain separators
55
- // For example: 'formula_one_mc_laren' could be:
56
- // - base: 'formula_one_mc', context: 'laren'
57
- // - base: 'formula_one', context: 'mc_laren' ← correct
58
- // - base: 'formula', context: 'one_mc_laren'
59
- const parts = potentialBaseKey.split(contextSeparator);
60
- if (parts.length > 1) {
61
- // Try removing 1, 2, 3... parts from the end to find a matching base key
62
- for (let i = 1; i < parts.length; i++) {
63
- const baseWithoutContext = parts.slice(0, -i).join(contextSeparator);
64
- if (keysAcceptingContext.has(baseWithoutContext)) {
65
- return true;
66
- }
67
- }
68
- }
69
- // Also check if the key itself (after removing plural suffix) accepts context
70
- // This handles cases like 'friend_other' where 'friend' accepts context
71
- if (keysAcceptingContext.has(potentialBaseKey)) {
72
- return true;
73
- }
74
- return false;
75
- }
76
26
  /**
77
27
  * Checks if a key looks like an object path or natural language.
78
28
  * (like in i18next)
@@ -285,6 +235,100 @@ function buildNewTranslationsForNs(nsKeys, existingTranslations, config, locale,
285
235
  return [...union];
286
236
  })()
287
237
  : null;
238
+ // Discover keys that are only referenced through `$t(...)` nested references
239
+ // inside existing translation values (see issue #241). These keys are
240
+ // invisible to the AST-based extractor, so without this step they would be
241
+ // deleted when `removeUnusedKeys` is true and never expanded into the plural
242
+ // forms a secondary locale needs.
243
+ //
244
+ // We inject synthetic ExtractedKey entries for each discovered reference so
245
+ // the normal filter / plural-expansion pipeline picks them up — for the
246
+ // primary language this preserves the existing variants, and for secondary
247
+ // languages this generates the correct per-locale plural skeleton.
248
+ const syntheticNestedKeys = [];
249
+ const namespaceMatches = (refNs) => {
250
+ if (namespace === undefined)
251
+ return true;
252
+ // Nested references arrive from parseNestedReferences with `ns` either set
253
+ // from an explicit `ns:key` prefix or defaulted to config.extract.defaultNS.
254
+ // Normalise to the same bucket keys used in `keysByNS`.
255
+ const normalizedRef = refNs === undefined || refNs === null
256
+ ? config.extract.defaultNS ?? 'translation'
257
+ : refNs;
258
+ return normalizedRef === namespace;
259
+ };
260
+ // All cardinal plural categories we should expand to for a context+count
261
+ // nested reference, covering every configured locale so the per-locale
262
+ // filter can then keep only the relevant ones.
263
+ const nestedContextCountCategories = (() => {
264
+ const union = new Set();
265
+ for (const loc of config.locales) {
266
+ pluralRules.safePluralRules(loc, { type: 'cardinal' }).resolvedOptions().pluralCategories.forEach(c => union.add(c));
267
+ }
268
+ return [...union];
269
+ })();
270
+ const seenNestedValues = new Set();
271
+ const collectFromValue = (value) => {
272
+ if (typeof value === 'string') {
273
+ if (seenNestedValues.has(value))
274
+ return;
275
+ seenNestedValues.add(value);
276
+ const refs = nesting.parseNestedReferences(value, {
277
+ nestingPrefix: config.extract.nestingPrefix,
278
+ nestingSuffix: config.extract.nestingSuffix,
279
+ nestingOptionsSeparator: config.extract.nestingOptionsSeparator,
280
+ nsSeparator: config.extract.nsSeparator,
281
+ defaultNS: config.extract.defaultNS
282
+ });
283
+ for (const ref of refs) {
284
+ if (!namespaceMatches(ref.ns))
285
+ continue;
286
+ const effectiveHasCount = ref.hasCount && !config.extract.disablePlurals;
287
+ if (ref.context !== undefined) {
288
+ const ctxKey = `${ref.key}${contextSeparator}${ref.context}`;
289
+ if (effectiveHasCount) {
290
+ // `ctxKey` contains `contextSeparator` (which equals pluralSeparator
291
+ // by default) so we cannot hand it to the base plural expansion
292
+ // pass. Instead, push fully-expanded variants and rely on the
293
+ // per-locale filter to keep the relevant ones.
294
+ for (const category of nestedContextCountCategories) {
295
+ syntheticNestedKeys.push({
296
+ key: `${ctxKey}${pluralSeparator}${category}`,
297
+ hasCount: true,
298
+ isExpandedPlural: true
299
+ });
300
+ }
301
+ }
302
+ else {
303
+ syntheticNestedKeys.push({ key: ref.key });
304
+ syntheticNestedKeys.push({ key: ctxKey });
305
+ }
306
+ }
307
+ else if (effectiveHasCount) {
308
+ // Plain plural reference — push the base plural key and let the
309
+ // normal expansion in the main loop emit per-locale variants.
310
+ syntheticNestedKeys.push({ key: ref.key, hasCount: true });
311
+ }
312
+ else {
313
+ syntheticNestedKeys.push({ key: ref.key });
314
+ }
315
+ }
316
+ }
317
+ else if (value && typeof value === 'object' && !Array.isArray(value)) {
318
+ for (const v of Object.values(value)) {
319
+ collectFromValue(v);
320
+ }
321
+ }
322
+ };
323
+ // Scan both the locale being built and the primary locale so that newly
324
+ // introduced references are propagated to every locale on the first run.
325
+ collectFromValue(existingTranslations);
326
+ if (primaryExistingTranslations && primaryExistingTranslations !== existingTranslations) {
327
+ collectFromValue(primaryExistingTranslations);
328
+ }
329
+ const nsKeysWithNested = syntheticNestedKeys.length > 0
330
+ ? [...nsKeys, ...syntheticNestedKeys]
331
+ : nsKeys;
288
332
  // Prepare namespace pattern checking helpers
289
333
  const rawPreserve = config.extract.preservePatterns || [];
290
334
  // Helper to check if a key should be filtered out during extraction
@@ -340,7 +384,7 @@ function buildNewTranslationsForNs(nsKeys, existingTranslations, config, locale,
340
384
  return false;
341
385
  };
342
386
  // Filter nsKeys to only include keys relevant to this language
343
- const filteredKeys = nsKeys.filter(({ key, hasCount, isOrdinal, explicitDefault }) => {
387
+ const filteredKeys = nsKeysWithNested.filter(({ key, hasCount, isOrdinal, explicitDefault }) => {
344
388
  // FIRST: Check if key matches preservePatterns and should be excluded
345
389
  if (shouldFilterKey(key)) {
346
390
  return false;
@@ -415,12 +459,41 @@ function buildNewTranslationsForNs(nsKeys, existingTranslations, config, locale,
415
459
  const existingKeys = nestedObject.getNestedKeys(existingTranslations, keySeparator ?? '.');
416
460
  for (const existingKey of existingKeys) {
417
461
  const shouldPreserve = shouldPreserveExistingKey(existingKey);
418
- const isContextVariant = !shouldPreserve && isContextVariantOfAcceptingKey(existingKey, keysAcceptingContext, pluralSeparator, contextSeparator);
462
+ const isContextVariant = !shouldPreserve && contextVariants.isContextVariantOfAcceptingKey(existingKey, keysAcceptingContext, pluralSeparator, contextSeparator);
419
463
  if (shouldPreserve || (preserveContextVariants && isContextVariant)) {
420
464
  const value = nestedObject.getNestedValue(existingTranslations, existingKey, keySeparator ?? '.');
421
465
  nestedObject.setNestedValue(newTranslations, existingKey, value, keySeparator ?? '.');
422
466
  }
423
467
  }
468
+ // PROPAGATE CONTEXT VARIANTS FROM PRIMARY TO SECONDARY (issue #242):
469
+ // When `preserveContextVariants` is enabled and the source code uses a
470
+ // dynamic context value (e.g. `t('exportType', { context: type })`), the
471
+ // extractor tags the base key as "accepting context" but the actual context
472
+ // values (e.g. `gas`, `water`) are only known from the primary translation
473
+ // file. Propagate those variants from primary to secondary locales so every
474
+ // locale ends up with the same key skeleton — translators and downstream
475
+ // `sync` can then fill in real values.
476
+ if (preserveContextVariants && locale !== primaryLanguage && primaryExistingTranslations) {
477
+ const primaryKeys = nestedObject.getNestedKeys(primaryExistingTranslations, keySeparator ?? '.');
478
+ for (const primaryKey of primaryKeys) {
479
+ if (shouldFilterKey(primaryKey))
480
+ continue;
481
+ const isContextVariant = contextVariants.isContextVariantOfAcceptingKey(primaryKey, keysAcceptingContext, pluralSeparator, contextSeparator);
482
+ if (!isContextVariant)
483
+ continue;
484
+ const separator = primaryKey.startsWith('<') ? false : (keySeparator ?? '.');
485
+ const alreadySet = nestedObject.getNestedValue(newTranslations, primaryKey, separator);
486
+ if (alreadySet !== undefined)
487
+ continue;
488
+ // Prefer an existing secondary value if present, otherwise fall back to
489
+ // the configured defaultValue (empty string for secondaries by default).
490
+ const existingSecondaryValue = nestedObject.getNestedValue(existingTranslations, primaryKey, separator);
491
+ const valueToSet = existingSecondaryValue !== undefined
492
+ ? existingSecondaryValue
493
+ : defaultValue.resolveDefaultValue(emptyDefaultValue, primaryKey, namespace || config?.extract?.defaultNS || 'translation', locale);
494
+ nestedObject.setNestedValue(newTranslations, primaryKey, valueToSet, separator);
495
+ }
496
+ }
424
497
  // PRESERVE LOCALE-SPECIFIC PLURAL FORMS: When dealing with plural keys in non-primary locales,
425
498
  // preserve any existing plural forms that are NOT being explicitly generated.
426
499
  // This ensures that locale-specific forms (like _few, _many) added by translators are preserved.
@@ -1,6 +1,7 @@
1
1
  'use strict';
2
2
 
3
3
  var pluralRules = require('../../utils/plural-rules.js');
4
+ var nesting = require('../../utils/nesting.js');
4
5
  var astUtils = require('./ast-utils.js');
5
6
 
6
7
  // Helper to escape regex characters
@@ -454,103 +455,26 @@ class CallExpressionHandler {
454
455
  }
455
456
  }
456
457
  /**
457
- * Scans a string for nested translations like $t(key, options) and extracts them.
458
+ * Scans a string for nested translations like $t(key, options) and registers
459
+ * the referenced keys (plus their plural / context variants) on the current
460
+ * plugin context.
458
461
  */
459
- extractNestedKeys(text, ns) {
460
- if (!text || typeof text !== 'string')
461
- return;
462
- const prefix = this.config.extract.nestingPrefix ?? '$t(';
463
- const suffix = this.config.extract.nestingSuffix ?? ')';
464
- const escapedPrefix = escapeRegex(prefix);
465
- const escapedSuffix = escapeRegex(suffix);
466
- // Regex adapted from i18next Interpolator.js
467
- // Matches nested calls like $t(key) or $t(key, { options })
468
- // It handles balanced parentheses to some extent and quoted strings
469
- const nestingRegexp = new RegExp(`${escapedPrefix}((?:[^()"']+|"[^"]*"|'[^']*'|\\((?:[^()]|"[^"]*"|'[^']*')*\\))*?)${escapedSuffix}`, 'g');
470
- let match;
471
- while ((match = nestingRegexp.exec(text)) !== null) {
472
- if (match[1]) {
473
- // Do NOT trust the outer `ns` blindly — compute namespace from the nested key itself
474
- // inside processNestedContent. Pass `undefined` so processNestedContent resolves ns
475
- // deterministically (either from key "ns:key" or from defaultNS).
476
- this.processNestedContent(match[1], undefined);
477
- }
478
- }
479
- }
480
- processNestedContent(content, ns) {
481
- let key = content;
482
- let optionsString = '';
483
- const separator = this.config.extract.nestingOptionsSeparator ?? ',';
484
- // Logic adapted from i18next Interpolator.js handleHasOptions
485
- if (content.indexOf(separator) < 0) {
486
- key = content.trim();
487
- }
488
- else {
489
- // Split by separator, but be careful about objects
490
- // i18next does: const c = key.split(new RegExp(`${sep}[ ]*{`));
491
- // This assumes options start with {
492
- const sepRegex = new RegExp(`${escapeRegex(separator)}[ ]*{`);
493
- const parts = content.split(sepRegex);
494
- if (parts.length > 1) {
495
- key = parts[0].trim();
496
- // Reconstruct the options part: add back the '{' that was consumed by split
497
- optionsString = `{${parts.slice(1).join(separator + ' {')}`;
462
+ extractNestedKeys(text, _ns) {
463
+ const references = nesting.parseNestedReferences(text, {
464
+ nestingPrefix: this.config.extract.nestingPrefix,
465
+ nestingSuffix: this.config.extract.nestingSuffix,
466
+ nestingOptionsSeparator: this.config.extract.nestingOptionsSeparator,
467
+ nsSeparator: this.config.extract.nsSeparator,
468
+ defaultNS: this.config.extract.defaultNS
469
+ });
470
+ for (const { key, ns: nestedNs, hasCount, context } of references) {
471
+ const effectiveHasCount = hasCount && !this.config.extract.disablePlurals;
472
+ if (effectiveHasCount || context !== undefined) {
473
+ this.generateNestedPluralKeys(key, nestedNs, effectiveHasCount, context);
498
474
  }
499
475
  else {
500
- // Fallback for simple split if no object pattern found
501
- const sepIdx = content.indexOf(separator);
502
- key = content.substring(0, sepIdx).trim();
503
- optionsString = content.substring(sepIdx + 1).trim();
504
- }
505
- }
506
- // Remove quotes from key if present
507
- if ((key.startsWith("'") && key.endsWith("'")) || (key.startsWith('"') && key.endsWith('"'))) {
508
- key = key.slice(1, -1);
509
- }
510
- if (!key)
511
- return;
512
- // Resolve namespace for the nested key:
513
- // If nested key contains nsSeparator (e.g. "ns:key"), extract namespace,
514
- // otherwise use configured defaultNS.
515
- let nestedNs;
516
- const nsSeparator = this.config.extract.nsSeparator ?? ':';
517
- if (nsSeparator && key.includes(nsSeparator)) {
518
- const parts = key.split(nsSeparator);
519
- const candidateNs = parts[0];
520
- if (!looksLikeNaturalLanguage(candidateNs)) {
521
- nestedNs = parts.shift();
522
- key = parts.join(nsSeparator);
523
- if (!key || key.trim() === '')
524
- return;
476
+ this.pluginContext.addKey({ key, ns: nestedNs });
525
477
  }
526
- else {
527
- nestedNs = this.config.extract.defaultNS;
528
- }
529
- }
530
- else {
531
- nestedNs = this.config.extract.defaultNS;
532
- }
533
- let hasCount = false;
534
- let context;
535
- if (optionsString) {
536
- // Simple regex check for count and context in the options string
537
- // This is an approximation since we don't have a full JSON parser here that handles JS objects perfectly
538
- // but it should cover most static cases.
539
- // Check for count: ...
540
- if (/['"]?count['"]?\s*:/.test(optionsString)) {
541
- hasCount = true;
542
- }
543
- // Check for context: ...
544
- const contextMatch = /['"]?context['"]?\s*:\s*(['"])(.*?)\1/.exec(optionsString);
545
- if (contextMatch) {
546
- context = contextMatch[2];
547
- }
548
- }
549
- if ((hasCount && !this.config.extract.disablePlurals) || context !== undefined) {
550
- this.generateNestedPluralKeys(key, nestedNs, hasCount && !this.config.extract.disablePlurals, context);
551
- }
552
- else {
553
- this.pluginContext.addKey({ key, ns: nestedNs });
554
478
  }
555
479
  }
556
480
  generateNestedPluralKeys(key, ns, hasCount, context) {
@@ -10,6 +10,8 @@ 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 nesting = require('./utils/nesting.js');
14
+ var contextVariants = require('./utils/context-variants.js');
13
15
  var funnelMsgTracker = require('./utils/funnel-msg-tracker.js');
14
16
  require('./extractor/parsers/jsx-parser.js');
15
17
 
@@ -82,7 +84,8 @@ async function generateStatusReport(config) {
82
84
  config.extract.primaryLanguage ||= config.locales[0] || 'en';
83
85
  config.extract.secondaryLanguages ||= config.locales.filter((l) => l !== config?.extract?.primaryLanguage);
84
86
  const { allKeys: allExtractedKeys } = await keyFinder.findKeys(config);
85
- const { secondaryLanguages, keySeparator = '.', defaultNS = 'translation', mergeNamespaces = false, pluralSeparator = '_', fallbackNS } = config.extract;
87
+ const { secondaryLanguages, keySeparator = '.', defaultNS = 'translation', mergeNamespaces = false, pluralSeparator = '_', contextSeparator = '_', fallbackNS } = config.extract;
88
+ const primaryLanguage = config.extract.primaryLanguage || config.locales[0] || 'en';
86
89
  const keysByNs = new Map();
87
90
  for (const key of allExtractedKeys.values()) {
88
91
  const ns = key.ns || defaultNS || 'translation';
@@ -105,6 +108,101 @@ async function generateStatusReport(config) {
105
108
  keysByNs,
106
109
  locales: new Map(),
107
110
  };
111
+ // Build per-namespace "virtual" key lists for translation entries that the
112
+ // AST-based extractor cannot see on its own. Both inputs come from the
113
+ // primary translation file:
114
+ //
115
+ // 1. Context variants of an accepting-context key (see issue #243).
116
+ // `t('exportType', { context: dynamic })` only registers the base key;
117
+ // the concrete `exportType_gas` / `exportType_water` variants live in
118
+ // the primary file.
119
+ //
120
+ // 2. Keys reachable only via `$t(...)` nested references from inside an
121
+ // existing translation value (see follow-up to issue #241).
122
+ // `"girlsAndBoys": "... $t(boys, {\"count\": x}) ..."` doesn't appear
123
+ // in source code, yet the referenced keys (`boys`, plus per-locale
124
+ // plural forms) must be checked in every secondary locale.
125
+ //
126
+ // Both scans need the primary translation file per namespace, so the load
127
+ // is shared.
128
+ const keysAcceptingContext = new Set();
129
+ for (const keys of keysByNs.values()) {
130
+ for (const k of keys) {
131
+ if (k.keyAcceptingContext)
132
+ keysAcceptingContext.add(k.keyAcceptingContext);
133
+ }
134
+ }
135
+ const contextVariantsByNs = new Map();
136
+ const nestedReferenceKeysByNs = new Map();
137
+ const primaryMergedForScan = mergeNamespaces
138
+ ? ((await fileUtils.loadTranslationFile(node_path.resolve(process.cwd(), fileUtils.getOutputPath(config.extract.output, primaryLanguage, (defaultNS === false ? 'translation' : (defaultNS || 'translation')))))) || {})
139
+ : null;
140
+ const collectNestedRefsFromValue = (value, refNs, bucket, seen) => {
141
+ if (typeof value === 'string') {
142
+ if (seen.has(value))
143
+ return;
144
+ seen.add(value);
145
+ const refs = nesting.parseNestedReferences(value, {
146
+ nestingPrefix: config.extract.nestingPrefix,
147
+ nestingSuffix: config.extract.nestingSuffix,
148
+ nestingOptionsSeparator: config.extract.nestingOptionsSeparator,
149
+ nsSeparator: config.extract.nsSeparator,
150
+ defaultNS: config.extract.defaultNS
151
+ });
152
+ for (const ref of refs) {
153
+ // References with an explicit namespace that differs from the current
154
+ // bucket are ignored — they belong to another namespace's scan.
155
+ const normalizedRefNs = ref.ns === undefined || ref.ns === null
156
+ ? (config.extract.defaultNS ?? 'translation')
157
+ : ref.ns;
158
+ if (normalizedRefNs !== refNs)
159
+ continue;
160
+ if (ref.context !== undefined) {
161
+ const ctxKey = `${ref.key}${contextSeparator}${ref.context}`;
162
+ if (ref.hasCount) {
163
+ // Treat `key_ctx` as a base plural key; the per-locale loop
164
+ // expands it into the correct CLDR forms for each target locale.
165
+ bucket.push({ key: ctxKey, hasCount: true });
166
+ }
167
+ else {
168
+ bucket.push({ key: ref.key });
169
+ bucket.push({ key: ctxKey });
170
+ }
171
+ }
172
+ else if (ref.hasCount) {
173
+ bucket.push({ key: ref.key, hasCount: true });
174
+ }
175
+ else {
176
+ bucket.push({ key: ref.key });
177
+ }
178
+ }
179
+ }
180
+ else if (value && typeof value === 'object' && !Array.isArray(value)) {
181
+ for (const v of Object.values(value)) {
182
+ collectNestedRefsFromValue(v, refNs, bucket, seen);
183
+ }
184
+ }
185
+ };
186
+ for (const ns of keysByNs.keys()) {
187
+ const primaryNsTranslations = mergeNamespaces
188
+ ? (primaryMergedForScan?.[ns] ?? primaryMergedForScan ?? {})
189
+ : ((await fileUtils.loadTranslationFile(node_path.resolve(process.cwd(), fileUtils.getOutputPath(config.extract.output, primaryLanguage, ns)))) || {});
190
+ if (keysAcceptingContext.size > 0) {
191
+ const primaryKeys = nestedObject.getNestedKeys(primaryNsTranslations, keySeparator ?? '.');
192
+ const variants = [];
193
+ for (const primaryKey of primaryKeys) {
194
+ if (contextVariants.isContextVariantOfAcceptingKey(primaryKey, keysAcceptingContext, pluralSeparator, contextSeparator)) {
195
+ variants.push(primaryKey);
196
+ }
197
+ }
198
+ if (variants.length > 0)
199
+ contextVariantsByNs.set(ns, variants);
200
+ }
201
+ const nestedRefKeys = [];
202
+ collectNestedRefsFromValue(primaryNsTranslations, ns, nestedRefKeys, new Set());
203
+ if (nestedRefKeys.length > 0)
204
+ nestedReferenceKeysByNs.set(ns, nestedRefKeys);
205
+ }
108
206
  for (const locale of secondaryLanguages) {
109
207
  let totalTranslatedForLocale = 0;
110
208
  let totalEmptyForLocale = 0;
@@ -172,7 +270,15 @@ async function generateStatusReport(config) {
172
270
  }
173
271
  return primaryState;
174
272
  };
175
- for (const { key: baseKey, hasCount, isOrdinal, isExpandedPlural } of keysInNs) {
273
+ const processedKeys = new Set();
274
+ // Combine AST-extracted keys with nested-reference keys discovered in
275
+ // the primary translation file (see follow-up on issue #241). Both go
276
+ // through the same plural-expansion logic; processedKeys dedupes.
277
+ const nestedRefKeys = nestedReferenceKeysByNs.get(ns) || [];
278
+ const combinedKeysInNs = nestedRefKeys.length > 0
279
+ ? [...keysInNs, ...nestedRefKeys]
280
+ : keysInNs;
281
+ for (const { key: baseKey, hasCount, isOrdinal, isExpandedPlural } of combinedKeysInNs) {
176
282
  if (hasCount) {
177
283
  if (isExpandedPlural) {
178
284
  // This is an already-expanded plural variant key (e.g., key_one, key_other)
@@ -185,7 +291,8 @@ async function generateStatusReport(config) {
185
291
  // Get the plural categories for this locale
186
292
  const localePluralCategories = getLocalePluralCategories(locale, isOrdinalVariant);
187
293
  // Only count this key if it's a plural form used by this locale
188
- if (localePluralCategories.includes(category)) {
294
+ if (localePluralCategories.includes(category) && !processedKeys.has(baseKey)) {
295
+ processedKeys.add(baseKey);
189
296
  totalInNs++;
190
297
  const state = resolveAndClassify(baseKey);
191
298
  if (state === 'translated')
@@ -202,10 +309,13 @@ async function generateStatusReport(config) {
202
309
  // Expand it according to THIS locale's plural rules
203
310
  const localePluralCategories = getLocalePluralCategories(locale, isOrdinal || false);
204
311
  for (const category of localePluralCategories) {
205
- totalInNs++;
206
312
  const pluralKey = isOrdinal
207
313
  ? `${baseKey}${pluralSeparator}ordinal${pluralSeparator}${category}`
208
314
  : `${baseKey}${pluralSeparator}${category}`;
315
+ if (processedKeys.has(pluralKey))
316
+ continue;
317
+ processedKeys.add(pluralKey);
318
+ totalInNs++;
209
319
  const state = resolveAndClassify(pluralKey);
210
320
  if (state === 'translated')
211
321
  translatedInNs++;
@@ -218,17 +328,37 @@ async function generateStatusReport(config) {
218
328
  }
219
329
  }
220
330
  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 });
331
+ if (!processedKeys.has(baseKey)) {
332
+ processedKeys.add(baseKey);
333
+ totalInNs++;
334
+ const state = resolveAndClassify(baseKey);
335
+ if (state === 'translated')
336
+ translatedInNs++;
337
+ else if (state === 'empty')
338
+ emptyInNs++;
339
+ else
340
+ absentInNs++;
341
+ keyDetails.push({ key: baseKey, state });
342
+ }
230
343
  }
231
344
  }
345
+ // Additionally check context variants discovered in the primary file
346
+ // (see issue #243). Skip variants already counted via extracted keys.
347
+ const contextVariants = contextVariantsByNs.get(ns) || [];
348
+ for (const variantKey of contextVariants) {
349
+ if (processedKeys.has(variantKey))
350
+ continue;
351
+ processedKeys.add(variantKey);
352
+ totalInNs++;
353
+ const state = resolveAndClassify(variantKey);
354
+ if (state === 'translated')
355
+ translatedInNs++;
356
+ else if (state === 'empty')
357
+ emptyInNs++;
358
+ else
359
+ absentInNs++;
360
+ keyDetails.push({ key: variantKey, state });
361
+ }
232
362
  namespaces.set(ns, { totalKeys: totalInNs, translatedKeys: translatedInNs, emptyKeys: emptyInNs, absentKeys: absentInNs, keyDetails });
233
363
  totalTranslatedForLocale += translatedInNs;
234
364
  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;