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