i18next-cli 1.55.0 → 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.
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.0'); // 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,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;