i18next-cli 1.71.1 → 1.71.3

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
@@ -921,6 +921,11 @@ export default defineConfig({
921
921
  // Namespaces to ignore during extraction, status, and sync operations.
922
922
  // Useful for monorepos where shared namespaces are managed elsewhere.
923
923
  // Keys using these namespaces will be excluded from processing.
924
+ // An ignored namespace can still act as a `fallbackNS` source: its
925
+ // translations are read (never written) for fallback accounting. If it
926
+ // lives in its own file outside a merged output (`mergeNamespaces: true`),
927
+ // use an `output` function that maps that namespace to its path, e.g.
928
+ // output: (lng, ns) => ns === 'shared' ? `locales/${lng}/${ns}.json` : `locales/${lng}.json`
924
929
  ignoreNamespaces: ['shared', 'common'], // Optional
925
930
 
926
931
  // When true (default), the extractor also scans code comments for t(...) / Trans examples and will extract keys found there.
package/dist/cjs/cli.js CHANGED
@@ -37,7 +37,7 @@ const program = new commander.Command();
37
37
  program
38
38
  .name('i18next-cli')
39
39
  .description('A unified, high-performance i18next CLI.')
40
- .version('1.71.1'); // This string is replaced with the actual version at build time by rollup
40
+ .version('1.71.3'); // This string is replaced with the actual version at build time by rollup
41
41
  // new: global config override option
42
42
  program.option('-c, --config <path>', 'Path to i18next-cli config file (overrides detection)');
43
43
  program
@@ -342,13 +342,31 @@ function buildNewTranslationsForNs(nsKeys, existingTranslations, config, locale,
342
342
  : nsKeys;
343
343
  // Prepare namespace pattern checking helpers
344
344
  const rawPreserve = config.extract.preservePatterns || [];
345
+ // Fast equivalent of matching a `${objectKey}.*` glob per object key: instead of
346
+ // testing O(objectKeys) regexes per key, walk the key's '.' boundaries and look
347
+ // each ancestor prefix up in the Set — O(key depth). See issue #286.
348
+ const isUnderObjectKey = (key) => {
349
+ if (objectKeys.size === 0)
350
+ return false;
351
+ let i = key.indexOf('.');
352
+ while (i !== -1) {
353
+ if (objectKeys.has(key.slice(0, i)))
354
+ return true;
355
+ i = key.indexOf('.', i + 1);
356
+ }
357
+ return false;
358
+ };
345
359
  // Helper to check if a key should be filtered out during extraction
346
360
  const shouldFilterKey = (key) => {
347
- // 1) regex based patterns (existing behavior)
361
+ // 1) keys nested under a returnObjects / selector-API base key
362
+ if (isUnderObjectKey(key)) {
363
+ return true;
364
+ }
365
+ // 2) regex based patterns (existing behavior)
348
366
  if (preservePatterns.some(re => re.test(key))) {
349
367
  return true;
350
368
  }
351
- // 2) namespace:* style patterns (respect nsSeparator)
369
+ // 3) namespace:* style patterns (respect nsSeparator)
352
370
  for (const rp of rawPreserve) {
353
371
  if (typeof rp !== 'string')
354
372
  continue;
@@ -365,11 +383,15 @@ function buildNewTranslationsForNs(nsKeys, existingTranslations, config, locale,
365
383
  };
366
384
  // Helper to check if an existing key should be preserved
367
385
  const shouldPreserveExistingKey = (key) => {
368
- // 1) regex-style patterns
386
+ // 1) keys nested under a returnObjects / selector-API base key
387
+ if (isUnderObjectKey(key)) {
388
+ return true;
389
+ }
390
+ // 2) regex-style patterns
369
391
  if (preservePatterns.some(re => re.test(key))) {
370
392
  return true;
371
393
  }
372
- // 2) namespace:key patterns - check if pattern matches this namespace:key combination
394
+ // 3) namespace:key patterns - check if pattern matches this namespace:key combination
373
395
  for (const rp of rawPreserve) {
374
396
  if (typeof rp !== 'string')
375
397
  continue;
@@ -575,6 +597,19 @@ function buildNewTranslationsForNs(nsKeys, existingTranslations, config, locale,
575
597
  }
576
598
  }
577
599
  }
600
+ // Precompute every proper ancestor prefix (up to each keySeparator boundary) of the
601
+ // extracted keys, so the per-key "is this a leaf?" check below is a single Set lookup
602
+ // instead of an O(keys) scan per key. See issue #286.
603
+ const parentPrefixesInNewKeys = new Set();
604
+ if (typeof keySeparator === 'string') {
605
+ for (const { key } of filteredKeys) {
606
+ let i = key.indexOf(keySeparator);
607
+ while (i !== -1 && i < key.length) {
608
+ parentPrefixesInNewKeys.add(key.slice(0, i));
609
+ i = key.indexOf(keySeparator, i + 1);
610
+ }
611
+ }
612
+ }
578
613
  // 1. Build the object first, without any sorting.
579
614
  for (const { key, defaultValue: defaultValue$1, explicitDefault, hasCount, isExpandedPlural, isOrdinal } of filteredKeys) {
580
615
  // If this is a base plural key (hasCount true but not an already-expanded variant)
@@ -717,7 +752,7 @@ function buildNewTranslationsForNs(nsKeys, existingTranslations, config, locale,
717
752
  // For flat keys there cannot be nested children, so treat them as leaves.
718
753
  const isLeafInNewKeys = keySeparator === false
719
754
  ? true
720
- : !filteredKeys.some(otherKey => otherKey.key !== key && otherKey.key.startsWith(`${key}${keySeparator}`));
755
+ : !parentPrefixesInNewKeys.has(key);
721
756
  const isDerivedDefault = isDerivedFromKey(key, defaultValue$1, explicitDefault);
722
757
  // Determine if we should preserve an existing object
723
758
  const shouldPreserveObject = typeof existingValue === 'object' && existingValue !== null && (objectKeys.has(key) || // Explicit returnObjects
@@ -982,8 +1017,9 @@ async function reattributeFallbackNsKeys(keysByNS, noNsToken, config, primaryLan
982
1017
  const pluralSeparator = config.extract.pluralSeparator ?? '_';
983
1018
  // Same merged-output detection as the per-locale loop in getTranslations.
984
1019
  const shouldMerge = config.extract.mergeNamespaces || (typeof config.extract.output === 'string' ? !config.extract.output.includes('{{namespace}}') : false);
1020
+ const mergedPrimaryPath = node_path.resolve(process.cwd(), fileUtils.getOutputPath(config.extract.output, primaryLanguage));
985
1021
  const mergedPrimaryFile = shouldMerge
986
- ? (await fileUtils.loadTranslationFile(node_path.resolve(process.cwd(), fileUtils.getOutputPath(config.extract.output, primaryLanguage))) || {})
1022
+ ? (await fileUtils.loadTranslationFile(mergedPrimaryPath) || {})
987
1023
  : null;
988
1024
  const catalogCache = new Map();
989
1025
  const loadPrimaryCatalog = async (ns, isFallback) => {
@@ -991,9 +1027,19 @@ async function reattributeFallbackNsKeys(keysByNS, noNsToken, config, primaryLan
991
1027
  let catalog = catalogCache.get(cacheKey);
992
1028
  if (!catalog) {
993
1029
  if (mergedPrimaryFile) {
1030
+ catalog = mergedPrimaryFile[ns];
1031
+ if (catalog === undefined) {
1032
+ // The namespace may live in its own file outside the merged one
1033
+ // (split out and hidden via `ignoreNamespaces`, #287). Only
1034
+ // reachable when `output` resolves it to a separate path.
1035
+ const nsPath = node_path.resolve(process.cwd(), fileUtils.getOutputPath(config.extract.output, primaryLanguage, ns));
1036
+ if (nsPath !== mergedPrimaryPath) {
1037
+ catalog = (await fileUtils.loadTranslationFile(nsPath)) ?? undefined;
1038
+ }
1039
+ }
994
1040
  // In merged mode the fallback keys may live at the top level when the
995
1041
  // file is not namespaced (same resolution as the status command).
996
- catalog = mergedPrimaryFile[ns] ?? (isFallback ? mergedPrimaryFile : {});
1042
+ catalog ??= (isFallback ? mergedPrimaryFile : {});
997
1043
  }
998
1044
  else {
999
1045
  catalog = await fileUtils.loadTranslationFile(node_path.resolve(process.cwd(), fileUtils.getOutputPath(config.extract.output, primaryLanguage, ns))) || {};
@@ -1099,8 +1145,12 @@ async function getTranslations(keys, objectKeys, config, { syncPrimaryWithDefaul
1099
1145
  const patternsToPreserve = [...(config.extract.preservePatterns || [])];
1100
1146
  const indentation = config.extract.indentation ?? 2;
1101
1147
  for (const key of objectKeys) {
1102
- // Convert the object key to a glob pattern to preserve all its children
1103
- patternsToPreserve.push(`${key}.*`);
1148
+ // Object keys are matched directly (and cheaply) against the objectKeys Set in
1149
+ // buildNewTranslationsForNs (see isUnderObjectKey). Only a key that itself
1150
+ // contains a wildcard still needs the glob-to-regex path.
1151
+ if (key.includes('*')) {
1152
+ patternsToPreserve.push(`${key}.*`);
1153
+ }
1104
1154
  }
1105
1155
  const preservePatterns = patternsToPreserve.map(globToRegex);
1106
1156
  // Group keys by namespace. If the plugin recorded the namespace as implicit
@@ -238,8 +238,16 @@ async function generateStatusReport(config) {
238
238
  }
239
239
  const contextVariantsByNs = new Map();
240
240
  const nestedReferenceKeysByNs = new Map();
241
+ // Absolute path of the merged translation file for a locale. A function
242
+ // `output` is called WITHOUT a namespace — the same way `extract` resolves
243
+ // the merged file it writes — so hybrid layouts (one merged file plus a few
244
+ // split-out namespace files) resolve correctly (#287). String templates keep
245
+ // the historical defaultNS substitution for the {{namespace}} placeholder.
246
+ const getMergedPath = (locale) => node_path.resolve(process.cwd(), typeof config.extract.output === 'function'
247
+ ? fileUtils.getOutputPath(config.extract.output, locale)
248
+ : fileUtils.getOutputPath(config.extract.output, locale, (defaultNS === false ? 'translation' : (defaultNS || 'translation'))));
241
249
  const primaryMergedForScan = mergeNamespaces
242
- ? ((await fileUtils.loadTranslationFile(node_path.resolve(process.cwd(), fileUtils.getOutputPath(config.extract.output, primaryLanguage, (defaultNS === false ? 'translation' : (defaultNS || 'translation')))))) || {})
250
+ ? ((await fileUtils.loadTranslationFile(getMergedPath(primaryLanguage))) || {})
243
251
  : null;
244
252
  const collectNestedRefsFromValue = (value, refNs, bucket, seen) => {
245
253
  if (typeof value === 'string') {
@@ -322,28 +330,53 @@ async function generateStatusReport(config) {
322
330
  const namespaces = new Map();
323
331
  const mergedTranslations = mergeNamespaces
324
332
  // When merging namespaces we need to load the combined translation file.
325
- // The combined file lives under the regular output pattern and must include a namespace.
326
- // If defaultNS is explicitly false, fall back to the conventional "translation" file name.
327
- ? await fileUtils.loadTranslationFile(node_path.resolve(process.cwd(), fileUtils.getOutputPath(config.extract.output, locale, (defaultNS === false ? 'translation' : (defaultNS || 'translation'))))) || {}
333
+ ? await fileUtils.loadTranslationFile(getMergedPath(locale)) || {}
328
334
  : null;
335
+ // Load fallbackNS catalogs once per locale (looked up in order, like the
336
+ // i18next runtime). The per-namespace loop below skips a namespace's own
337
+ // entry.
338
+ const fallbackCatalogs = new Map();
339
+ for (const fallbackNs of fallbackNamespaces) {
340
+ if (mergeNamespaces) {
341
+ // In merged mode the fallback keys normally live in the merged file
342
+ // under the fallback namespace.
343
+ let catalog = mergedTranslations?.[fallbackNs];
344
+ if (catalog === undefined) {
345
+ // The fallback namespace may live in its own file outside the merged
346
+ // one (split out and hidden via `ignoreNamespaces`, #287). Only
347
+ // reachable when `output` resolves it to a separate path.
348
+ const nsPath = node_path.resolve(process.cwd(), fileUtils.getOutputPath(config.extract.output, locale, fallbackNs));
349
+ if (nsPath !== getMergedPath(locale)) {
350
+ catalog = (await fileUtils.loadTranslationFile(nsPath)) ?? undefined;
351
+ }
352
+ }
353
+ if (catalog === undefined && isPrimary && ignoreNamespaces.has(fallbackNs)) {
354
+ console.warn(`⚠️ fallbackNS "${fallbackNs}" is listed in ignoreNamespaces, but no translations for it were found — keys resolving through it will be reported as absent. If the namespace lives in its own file, use an \`output\` function that maps it to that path.`);
355
+ }
356
+ // If it's still not found, fall back to the top level of the merged
357
+ // file (flat, non-namespaced merged files).
358
+ fallbackCatalogs.set(fallbackNs, catalog ?? mergedTranslations ?? {});
359
+ }
360
+ else {
361
+ const nsPath = node_path.resolve(process.cwd(), fileUtils.getOutputPath(config.extract.output, locale, fallbackNs));
362
+ const catalog = await fileUtils.loadTranslationFile(nsPath);
363
+ if (!catalog && isPrimary && ignoreNamespaces.has(fallbackNs)) {
364
+ console.warn(`⚠️ fallbackNS "${fallbackNs}" is listed in ignoreNamespaces, but no translations for it were found at "${nsPath}" — keys resolving through it will be reported as absent.`);
365
+ }
366
+ fallbackCatalogs.set(fallbackNs, catalog || {});
367
+ }
368
+ }
329
369
  for (const [ns, keysInNs] of keysByNs.entries()) {
330
370
  const translationsForNs = mergeNamespaces
331
371
  // If mergedTranslations is a flat object (no nested namespace) prefer the root object
332
372
  // when mergedTranslations[ns] is missing.
333
373
  ? (mergedTranslations?.[ns] ?? mergedTranslations ?? {})
334
374
  : await fileUtils.loadTranslationFile(node_path.resolve(process.cwd(), fileUtils.getOutputPath(config.extract.output, locale, ns))) || {};
335
- // Load fallbackNS translations if configured (looked up in order, like the i18next runtime)
336
375
  const fallbackTranslationsList = [];
337
376
  for (const fallbackNs of fallbackNamespaces) {
338
377
  if (ns === fallbackNs)
339
378
  continue;
340
- if (mergeNamespaces) {
341
- // In merged mode, fallbackNS keys are in mergedTranslations under the fallback namespace
342
- fallbackTranslationsList.push(mergedTranslations?.[fallbackNs] ?? mergedTranslations ?? {});
343
- }
344
- else {
345
- fallbackTranslationsList.push(await fileUtils.loadTranslationFile(node_path.resolve(process.cwd(), fileUtils.getOutputPath(config.extract.output, locale, fallbackNs))) || {});
346
- }
379
+ fallbackTranslationsList.push(fallbackCatalogs.get(fallbackNs));
347
380
  }
348
381
  let translatedInNs = 0;
349
382
  let emptyInNs = 0;
package/dist/esm/cli.js CHANGED
@@ -31,7 +31,7 @@ const program = new Command();
31
31
  program
32
32
  .name('i18next-cli')
33
33
  .description('A unified, high-performance i18next CLI.')
34
- .version('1.71.1'); // This string is replaced with the actual version at build time by rollup
34
+ .version('1.71.3'); // This string is replaced with the actual version at build time by rollup
35
35
  // new: global config override option
36
36
  program.option('-c, --config <path>', 'Path to i18next-cli config file (overrides detection)');
37
37
  program
@@ -340,13 +340,31 @@ function buildNewTranslationsForNs(nsKeys, existingTranslations, config, locale,
340
340
  : nsKeys;
341
341
  // Prepare namespace pattern checking helpers
342
342
  const rawPreserve = config.extract.preservePatterns || [];
343
+ // Fast equivalent of matching a `${objectKey}.*` glob per object key: instead of
344
+ // testing O(objectKeys) regexes per key, walk the key's '.' boundaries and look
345
+ // each ancestor prefix up in the Set — O(key depth). See issue #286.
346
+ const isUnderObjectKey = (key) => {
347
+ if (objectKeys.size === 0)
348
+ return false;
349
+ let i = key.indexOf('.');
350
+ while (i !== -1) {
351
+ if (objectKeys.has(key.slice(0, i)))
352
+ return true;
353
+ i = key.indexOf('.', i + 1);
354
+ }
355
+ return false;
356
+ };
343
357
  // Helper to check if a key should be filtered out during extraction
344
358
  const shouldFilterKey = (key) => {
345
- // 1) regex based patterns (existing behavior)
359
+ // 1) keys nested under a returnObjects / selector-API base key
360
+ if (isUnderObjectKey(key)) {
361
+ return true;
362
+ }
363
+ // 2) regex based patterns (existing behavior)
346
364
  if (preservePatterns.some(re => re.test(key))) {
347
365
  return true;
348
366
  }
349
- // 2) namespace:* style patterns (respect nsSeparator)
367
+ // 3) namespace:* style patterns (respect nsSeparator)
350
368
  for (const rp of rawPreserve) {
351
369
  if (typeof rp !== 'string')
352
370
  continue;
@@ -363,11 +381,15 @@ function buildNewTranslationsForNs(nsKeys, existingTranslations, config, locale,
363
381
  };
364
382
  // Helper to check if an existing key should be preserved
365
383
  const shouldPreserveExistingKey = (key) => {
366
- // 1) regex-style patterns
384
+ // 1) keys nested under a returnObjects / selector-API base key
385
+ if (isUnderObjectKey(key)) {
386
+ return true;
387
+ }
388
+ // 2) regex-style patterns
367
389
  if (preservePatterns.some(re => re.test(key))) {
368
390
  return true;
369
391
  }
370
- // 2) namespace:key patterns - check if pattern matches this namespace:key combination
392
+ // 3) namespace:key patterns - check if pattern matches this namespace:key combination
371
393
  for (const rp of rawPreserve) {
372
394
  if (typeof rp !== 'string')
373
395
  continue;
@@ -573,6 +595,19 @@ function buildNewTranslationsForNs(nsKeys, existingTranslations, config, locale,
573
595
  }
574
596
  }
575
597
  }
598
+ // Precompute every proper ancestor prefix (up to each keySeparator boundary) of the
599
+ // extracted keys, so the per-key "is this a leaf?" check below is a single Set lookup
600
+ // instead of an O(keys) scan per key. See issue #286.
601
+ const parentPrefixesInNewKeys = new Set();
602
+ if (typeof keySeparator === 'string') {
603
+ for (const { key } of filteredKeys) {
604
+ let i = key.indexOf(keySeparator);
605
+ while (i !== -1 && i < key.length) {
606
+ parentPrefixesInNewKeys.add(key.slice(0, i));
607
+ i = key.indexOf(keySeparator, i + 1);
608
+ }
609
+ }
610
+ }
576
611
  // 1. Build the object first, without any sorting.
577
612
  for (const { key, defaultValue, explicitDefault, hasCount, isExpandedPlural, isOrdinal } of filteredKeys) {
578
613
  // If this is a base plural key (hasCount true but not an already-expanded variant)
@@ -715,7 +750,7 @@ function buildNewTranslationsForNs(nsKeys, existingTranslations, config, locale,
715
750
  // For flat keys there cannot be nested children, so treat them as leaves.
716
751
  const isLeafInNewKeys = keySeparator === false
717
752
  ? true
718
- : !filteredKeys.some(otherKey => otherKey.key !== key && otherKey.key.startsWith(`${key}${keySeparator}`));
753
+ : !parentPrefixesInNewKeys.has(key);
719
754
  const isDerivedDefault = isDerivedFromKey(key, defaultValue, explicitDefault);
720
755
  // Determine if we should preserve an existing object
721
756
  const shouldPreserveObject = typeof existingValue === 'object' && existingValue !== null && (objectKeys.has(key) || // Explicit returnObjects
@@ -980,8 +1015,9 @@ async function reattributeFallbackNsKeys(keysByNS, noNsToken, config, primaryLan
980
1015
  const pluralSeparator = config.extract.pluralSeparator ?? '_';
981
1016
  // Same merged-output detection as the per-locale loop in getTranslations.
982
1017
  const shouldMerge = config.extract.mergeNamespaces || (typeof config.extract.output === 'string' ? !config.extract.output.includes('{{namespace}}') : false);
1018
+ const mergedPrimaryPath = resolve(process.cwd(), getOutputPath(config.extract.output, primaryLanguage));
983
1019
  const mergedPrimaryFile = shouldMerge
984
- ? (await loadTranslationFile(resolve(process.cwd(), getOutputPath(config.extract.output, primaryLanguage))) || {})
1020
+ ? (await loadTranslationFile(mergedPrimaryPath) || {})
985
1021
  : null;
986
1022
  const catalogCache = new Map();
987
1023
  const loadPrimaryCatalog = async (ns, isFallback) => {
@@ -989,9 +1025,19 @@ async function reattributeFallbackNsKeys(keysByNS, noNsToken, config, primaryLan
989
1025
  let catalog = catalogCache.get(cacheKey);
990
1026
  if (!catalog) {
991
1027
  if (mergedPrimaryFile) {
1028
+ catalog = mergedPrimaryFile[ns];
1029
+ if (catalog === undefined) {
1030
+ // The namespace may live in its own file outside the merged one
1031
+ // (split out and hidden via `ignoreNamespaces`, #287). Only
1032
+ // reachable when `output` resolves it to a separate path.
1033
+ const nsPath = resolve(process.cwd(), getOutputPath(config.extract.output, primaryLanguage, ns));
1034
+ if (nsPath !== mergedPrimaryPath) {
1035
+ catalog = (await loadTranslationFile(nsPath)) ?? undefined;
1036
+ }
1037
+ }
992
1038
  // In merged mode the fallback keys may live at the top level when the
993
1039
  // file is not namespaced (same resolution as the status command).
994
- catalog = mergedPrimaryFile[ns] ?? (isFallback ? mergedPrimaryFile : {});
1040
+ catalog ??= (isFallback ? mergedPrimaryFile : {});
995
1041
  }
996
1042
  else {
997
1043
  catalog = await loadTranslationFile(resolve(process.cwd(), getOutputPath(config.extract.output, primaryLanguage, ns))) || {};
@@ -1097,8 +1143,12 @@ async function getTranslations(keys, objectKeys, config, { syncPrimaryWithDefaul
1097
1143
  const patternsToPreserve = [...(config.extract.preservePatterns || [])];
1098
1144
  const indentation = config.extract.indentation ?? 2;
1099
1145
  for (const key of objectKeys) {
1100
- // Convert the object key to a glob pattern to preserve all its children
1101
- patternsToPreserve.push(`${key}.*`);
1146
+ // Object keys are matched directly (and cheaply) against the objectKeys Set in
1147
+ // buildNewTranslationsForNs (see isUnderObjectKey). Only a key that itself
1148
+ // contains a wildcard still needs the glob-to-regex path.
1149
+ if (key.includes('*')) {
1150
+ patternsToPreserve.push(`${key}.*`);
1151
+ }
1102
1152
  }
1103
1153
  const preservePatterns = patternsToPreserve.map(globToRegex);
1104
1154
  // Group keys by namespace. If the plugin recorded the namespace as implicit
@@ -232,8 +232,16 @@ async function generateStatusReport(config) {
232
232
  }
233
233
  const contextVariantsByNs = new Map();
234
234
  const nestedReferenceKeysByNs = new Map();
235
+ // Absolute path of the merged translation file for a locale. A function
236
+ // `output` is called WITHOUT a namespace — the same way `extract` resolves
237
+ // the merged file it writes — so hybrid layouts (one merged file plus a few
238
+ // split-out namespace files) resolve correctly (#287). String templates keep
239
+ // the historical defaultNS substitution for the {{namespace}} placeholder.
240
+ const getMergedPath = (locale) => resolve(process.cwd(), typeof config.extract.output === 'function'
241
+ ? getOutputPath(config.extract.output, locale)
242
+ : getOutputPath(config.extract.output, locale, (defaultNS === false ? 'translation' : (defaultNS || 'translation'))));
235
243
  const primaryMergedForScan = mergeNamespaces
236
- ? ((await loadTranslationFile(resolve(process.cwd(), getOutputPath(config.extract.output, primaryLanguage, (defaultNS === false ? 'translation' : (defaultNS || 'translation')))))) || {})
244
+ ? ((await loadTranslationFile(getMergedPath(primaryLanguage))) || {})
237
245
  : null;
238
246
  const collectNestedRefsFromValue = (value, refNs, bucket, seen) => {
239
247
  if (typeof value === 'string') {
@@ -316,28 +324,53 @@ async function generateStatusReport(config) {
316
324
  const namespaces = new Map();
317
325
  const mergedTranslations = mergeNamespaces
318
326
  // When merging namespaces we need to load the combined translation file.
319
- // The combined file lives under the regular output pattern and must include a namespace.
320
- // If defaultNS is explicitly false, fall back to the conventional "translation" file name.
321
- ? await loadTranslationFile(resolve(process.cwd(), getOutputPath(config.extract.output, locale, (defaultNS === false ? 'translation' : (defaultNS || 'translation'))))) || {}
327
+ ? await loadTranslationFile(getMergedPath(locale)) || {}
322
328
  : null;
329
+ // Load fallbackNS catalogs once per locale (looked up in order, like the
330
+ // i18next runtime). The per-namespace loop below skips a namespace's own
331
+ // entry.
332
+ const fallbackCatalogs = new Map();
333
+ for (const fallbackNs of fallbackNamespaces) {
334
+ if (mergeNamespaces) {
335
+ // In merged mode the fallback keys normally live in the merged file
336
+ // under the fallback namespace.
337
+ let catalog = mergedTranslations?.[fallbackNs];
338
+ if (catalog === undefined) {
339
+ // The fallback namespace may live in its own file outside the merged
340
+ // one (split out and hidden via `ignoreNamespaces`, #287). Only
341
+ // reachable when `output` resolves it to a separate path.
342
+ const nsPath = resolve(process.cwd(), getOutputPath(config.extract.output, locale, fallbackNs));
343
+ if (nsPath !== getMergedPath(locale)) {
344
+ catalog = (await loadTranslationFile(nsPath)) ?? undefined;
345
+ }
346
+ }
347
+ if (catalog === undefined && isPrimary && ignoreNamespaces.has(fallbackNs)) {
348
+ console.warn(`⚠️ fallbackNS "${fallbackNs}" is listed in ignoreNamespaces, but no translations for it were found — keys resolving through it will be reported as absent. If the namespace lives in its own file, use an \`output\` function that maps it to that path.`);
349
+ }
350
+ // If it's still not found, fall back to the top level of the merged
351
+ // file (flat, non-namespaced merged files).
352
+ fallbackCatalogs.set(fallbackNs, catalog ?? mergedTranslations ?? {});
353
+ }
354
+ else {
355
+ const nsPath = resolve(process.cwd(), getOutputPath(config.extract.output, locale, fallbackNs));
356
+ const catalog = await loadTranslationFile(nsPath);
357
+ if (!catalog && isPrimary && ignoreNamespaces.has(fallbackNs)) {
358
+ console.warn(`⚠️ fallbackNS "${fallbackNs}" is listed in ignoreNamespaces, but no translations for it were found at "${nsPath}" — keys resolving through it will be reported as absent.`);
359
+ }
360
+ fallbackCatalogs.set(fallbackNs, catalog || {});
361
+ }
362
+ }
323
363
  for (const [ns, keysInNs] of keysByNs.entries()) {
324
364
  const translationsForNs = mergeNamespaces
325
365
  // If mergedTranslations is a flat object (no nested namespace) prefer the root object
326
366
  // when mergedTranslations[ns] is missing.
327
367
  ? (mergedTranslations?.[ns] ?? mergedTranslations ?? {})
328
368
  : await loadTranslationFile(resolve(process.cwd(), getOutputPath(config.extract.output, locale, ns))) || {};
329
- // Load fallbackNS translations if configured (looked up in order, like the i18next runtime)
330
369
  const fallbackTranslationsList = [];
331
370
  for (const fallbackNs of fallbackNamespaces) {
332
371
  if (ns === fallbackNs)
333
372
  continue;
334
- if (mergeNamespaces) {
335
- // In merged mode, fallbackNS keys are in mergedTranslations under the fallback namespace
336
- fallbackTranslationsList.push(mergedTranslations?.[fallbackNs] ?? mergedTranslations ?? {});
337
- }
338
- else {
339
- fallbackTranslationsList.push(await loadTranslationFile(resolve(process.cwd(), getOutputPath(config.extract.output, locale, fallbackNs))) || {});
340
- }
373
+ fallbackTranslationsList.push(fallbackCatalogs.get(fallbackNs));
341
374
  }
342
375
  let translatedInNs = 0;
343
376
  let emptyInNs = 0;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "i18next-cli",
3
- "version": "1.71.1",
3
+ "version": "1.71.3",
4
4
  "description": "A unified, high-performance i18next CLI.",
5
5
  "type": "module",
6
6
  "bin": {
@@ -1 +1 @@
1
- {"version":3,"file":"translation-manager.d.ts","sourceRoot":"","sources":["../../../src/extractor/core/translation-manager.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,iBAAiB,EAAE,YAAY,EAAE,oBAAoB,EAAE,MAAM,EAAE,MAAM,gBAAgB,CAAA;AA2rC9F;;;;;;;;;;;;;;;;;;;;;;;;;;;;GA4BG;AACH,wBAAsB,eAAe,CACnC,IAAI,EAAE,GAAG,CAAC,MAAM,EAAE,YAAY,CAAC,EAC/B,UAAU,EAAE,GAAG,CAAC,MAAM,CAAC,EACvB,MAAM,EAAE,oBAAoB,EAC5B,EACE,uBAA+B,EAC/B,OAAe,EACf,oBAA4B,EAC5B,MAA4B,EAC7B,GAAE;IACD,uBAAuB,CAAC,EAAE,OAAO,CAAC;IAClC,OAAO,CAAC,EAAE,OAAO,CAAC;IAClB,oBAAoB,CAAC,EAAE,OAAO,CAAC;IAC/B,MAAM,CAAC,EAAE,MAAM,CAAA;CACX,GACL,OAAO,CAAC,iBAAiB,EAAE,CAAC,CAyK9B"}
1
+ {"version":3,"file":"translation-manager.d.ts","sourceRoot":"","sources":["../../../src/extractor/core/translation-manager.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,iBAAiB,EAAE,YAAY,EAAE,oBAAoB,EAAE,MAAM,EAAE,MAAM,gBAAgB,CAAA;AAyuC9F;;;;;;;;;;;;;;;;;;;;;;;;;;;;GA4BG;AACH,wBAAsB,eAAe,CACnC,IAAI,EAAE,GAAG,CAAC,MAAM,EAAE,YAAY,CAAC,EAC/B,UAAU,EAAE,GAAG,CAAC,MAAM,CAAC,EACvB,MAAM,EAAE,oBAAoB,EAC5B,EACE,uBAA+B,EAC/B,OAAe,EACf,oBAA4B,EAC5B,MAA4B,EAC7B,GAAE;IACD,uBAAuB,CAAC,EAAE,OAAO,CAAC;IAClC,OAAO,CAAC,EAAE,OAAO,CAAC;IAClB,oBAAoB,CAAC,EAAE,OAAO,CAAC;IAC/B,MAAM,CAAC,EAAE,MAAM,CAAA;CACX,GACL,OAAO,CAAC,iBAAiB,EAAE,CAAC,CA6K9B"}
@@ -1 +1 @@
1
- {"version":3,"file":"status.d.ts","sourceRoot":"","sources":["../src/status.ts"],"names":[],"mappings":"AAKA,OAAO,KAAK,EAAE,oBAAoB,EAAmC,MAAM,YAAY,CAAA;AAYvF;;GAEG;AACH,UAAU,aAAa;IACrB,0EAA0E;IAC1E,MAAM,CAAC,EAAE,MAAM,CAAC;IAChB,wCAAwC;IACxC,SAAS,CAAC,EAAE,MAAM,CAAC;IACnB,uEAAuE;IACvE,cAAc,CAAC,EAAE,OAAO,CAAC;CAC1B;AAyHD;;;;;;;;;;;;;;;;;;GAkBG;AACH,wBAAsB,SAAS,CAAE,MAAM,EAAE,oBAAoB,EAAE,OAAO,GAAE,aAAkB,iBAoDzF;AAsoBD;;GAEG;AACH,UAAU,aAAa;IACrB,6CAA6C;IAC7C,MAAM,CAAC,EAAE,MAAM,CAAC;IAChB,gDAAgD;IAChD,SAAS,CAAC,EAAE,MAAM,CAAC;CACpB;AAED;;;;;;;;;;;;;GAaG;AACH,wBAAsB,eAAe,CAAE,MAAM,EAAE,oBAAoB,EAAE,OAAO,GAAE,aAAkB,iBAuE/F"}
1
+ {"version":3,"file":"status.d.ts","sourceRoot":"","sources":["../src/status.ts"],"names":[],"mappings":"AAKA,OAAO,KAAK,EAAE,oBAAoB,EAAmC,MAAM,YAAY,CAAA;AAYvF;;GAEG;AACH,UAAU,aAAa;IACrB,0EAA0E;IAC1E,MAAM,CAAC,EAAE,MAAM,CAAC;IAChB,wCAAwC;IACxC,SAAS,CAAC,EAAE,MAAM,CAAC;IACnB,uEAAuE;IACvE,cAAc,CAAC,EAAE,OAAO,CAAC;CAC1B;AAyHD;;;;;;;;;;;;;;;;;;GAkBG;AACH,wBAAsB,SAAS,CAAE,MAAM,EAAE,oBAAoB,EAAE,OAAO,GAAE,aAAkB,iBAoDzF;AA4pBD;;GAEG;AACH,UAAU,aAAa;IACrB,6CAA6C;IAC7C,MAAM,CAAC,EAAE,MAAM,CAAC;IAChB,gDAAgD;IAChD,SAAS,CAAC,EAAE,MAAM,CAAC;CACpB;AAED;;;;;;;;;;;;;GAaG;AACH,wBAAsB,eAAe,CAAE,MAAM,EAAE,oBAAoB,EAAE,OAAO,GAAE,aAAkB,iBAuE/F"}