i18next-cli 1.64.0 → 1.64.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/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.64.0'); // This string is replaced with the actual version at build time by rollup
40
+ .version('1.64.1'); // 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
@@ -394,8 +394,11 @@ function buildNewTranslationsForNs(nsKeys, existingTranslations, config, locale,
394
394
  if (shouldFilterKey(key)) {
395
395
  return false;
396
396
  }
397
- if (!hasCount) {
398
- // Non-plural keys are always included
397
+ if (!hasCount || config.extract.disablePlurals) {
398
+ // Non-plural keys are always included. Under `disablePlurals`, count keys
399
+ // are emitted as plain keys (no plural expansion) but still carry
400
+ // `hasCount` so `status` can recognise them — they must bypass the
401
+ // plural-form filtering below and always be included, exactly as before.
399
402
  return true;
400
403
  }
401
404
  // For plural keys, check if this specific plural form is needed for the target language
@@ -337,12 +337,17 @@ class CallExpressionHandler {
337
337
  // Check if plurals are disabled FIRST, before any plural optimization paths
338
338
  if (this.config.extract.disablePlurals) {
339
339
  // When plurals are disabled, treat count as a regular option (for interpolation only)
340
- // Still handle context normally
340
+ // Still handle context normally. We keep `hasCount`/`isOrdinal` on the
341
+ // emitted key so `status` knows it is count-driven and can accept the
342
+ // file's plural variants (or the bare key) instead of demanding the
343
+ // literal base key. File generation ignores these flags under
344
+ // disablePlurals (see translation-manager), so output is unchanged.
345
+ const countMeta = hasCount ? { hasCount: true, isOrdinal: isOrdinalByKey } : {};
341
346
  if (keysWithContext.length > 0) {
342
- keysWithContext.forEach(this.pluginContext.addKey);
347
+ keysWithContext.forEach(k => this.pluginContext.addKey({ ...k, ...countMeta }));
343
348
  }
344
349
  else {
345
- this.pluginContext.addKey({ key: finalKey, ns, defaultValue: dv, explicitDefault: explicitDefaultForBase });
350
+ this.pluginContext.addKey({ key: finalKey, ns, defaultValue: dv, explicitDefault: explicitDefaultForBase, ...countMeta });
346
351
  }
347
352
  continue; // This key is fully handled
348
353
  }
@@ -124,14 +124,18 @@ function extractKeysFromComments(code, pluginContext, config, scopeResolver) {
124
124
  ns = config.extract.defaultNS;
125
125
  // 5. Handle context and count combinations based on disablePlurals setting
126
126
  if (config.extract.disablePlurals) {
127
- // When plurals are disabled, ignore count for key generation
127
+ // When plurals are disabled, ignore count for key generation. We still
128
+ // tag the key with `hasCount` (when a count was present) so `status` can
129
+ // recognise it as count-driven; file generation ignores the flag under
130
+ // disablePlurals (see translation-manager).
131
+ const countMeta = count ? { hasCount: true, isOrdinal } : {};
128
132
  if (context) {
129
133
  // Only generate context variants (no base key when context is static)
130
- pluginContext.addKey({ key: `${key}_${context}`, ns, defaultValue: defaultValue ?? key });
134
+ pluginContext.addKey({ key: `${key}_${context}`, ns, defaultValue: defaultValue ?? key, ...countMeta });
131
135
  }
132
136
  else {
133
137
  // Simple key (ignore count)
134
- pluginContext.addKey({ key, ns, defaultValue: defaultValue ?? key });
138
+ pluginContext.addKey({ key, ns, defaultValue: defaultValue ?? key, ...countMeta });
135
139
  }
136
140
  }
137
141
  else {
@@ -369,12 +369,16 @@ class JSXHandler {
369
369
  else if (hasCount) {
370
370
  // Check if plurals are disabled
371
371
  if (this.config.extract.disablePlurals) {
372
- // When plurals are disabled, just add the base keys (no plural forms)
372
+ // When plurals are disabled, just add the base keys (no plural forms).
373
+ // We keep `hasCount` so `status` recognises the key as count-driven and
374
+ // accepts the file's plural variants (or bare key); file generation
375
+ // ignores it under disablePlurals (see translation-manager).
373
376
  extractedKeys.forEach(extractedKey => {
374
377
  this.pluginContext.addKey({
375
378
  key: extractedKey.key,
376
379
  ns: extractedKey.ns,
377
380
  defaultValue: extractedKey.defaultValue,
381
+ hasCount: true,
378
382
  locations: extractedKey.locations
379
383
  });
380
384
  });
@@ -26,6 +26,53 @@ function classifyValue(value) {
26
26
  return 'empty';
27
27
  return 'translated';
28
28
  }
29
+ /**
30
+ * Representative counts used to decide which CLDR plural categories a locale can
31
+ * actually reach in normal usage: small integers (the common case), a handful
32
+ * of larger integers, and common decimals (so categories that only fire for
33
+ * fractional display values — e.g. Polish/Russian `other` — stay required).
34
+ *
35
+ * Any category NOT produced by these counts is treated as "optional". The prime
36
+ * example is French `many`, which `Intl.PluralRules` only selects for values
37
+ * ≥ 1,000,000 — the i18next runtime can technically request it, but real apps
38
+ * almost never hit those values (and the runtime falls back to the base key
39
+ * when the variant is missing), so a missing `_many` should be a soft note
40
+ * rather than a hard "missing key" failure.
41
+ */
42
+ const REPRESENTATIVE_COUNTS = (() => {
43
+ const counts = [];
44
+ for (let n = 0; n <= 20; n++)
45
+ counts.push(n);
46
+ counts.push(100, 101, 1000, 1001, 10000);
47
+ counts.push(0.5, 1.1, 1.5, 2.5, 3.5);
48
+ return counts;
49
+ })();
50
+ const optionalCategoriesCache = new Map();
51
+ /**
52
+ * Returns the CLDR plural categories for a locale that are NOT reachable by any
53
+ * representative count (see {@link REPRESENTATIVE_COUNTS}). These are reported
54
+ * by `status` as optional: a missing variant is a soft note instead of a hard
55
+ * absence, mirroring how the i18next runtime resolves such forms.
56
+ */
57
+ function getOptionalPluralCategories(locale, isOrdinal) {
58
+ const type = isOrdinal ? 'ordinal' : 'cardinal';
59
+ const cacheKey = `${locale}|${type}`;
60
+ const cached = optionalCategoriesCache.get(cacheKey);
61
+ if (cached)
62
+ return cached;
63
+ let optional;
64
+ try {
65
+ const rules = pluralRules.safePluralRules(locale, { type });
66
+ const all = rules.resolvedOptions().pluralCategories;
67
+ const reachable = new Set(REPRESENTATIVE_COUNTS.map(n => rules.select(n)));
68
+ optional = new Set(all.filter(c => !reachable.has(c)));
69
+ }
70
+ catch {
71
+ optional = new Set();
72
+ }
73
+ optionalCategoriesCache.set(cacheKey, optional);
74
+ return optional;
75
+ }
29
76
  /**
30
77
  * Runs a health check on the project's i18next translations and displays a status report.
31
78
  *
@@ -222,6 +269,7 @@ async function generateStatusReport(config) {
222
269
  let totalTranslatedForLocale = 0;
223
270
  let totalEmptyForLocale = 0;
224
271
  let totalAbsentForLocale = 0;
272
+ let totalOptionalForLocale = 0;
225
273
  let totalKeysForLocale = 0;
226
274
  const namespaces = new Map();
227
275
  const mergedTranslations = mergeNamespaces
@@ -250,6 +298,7 @@ async function generateStatusReport(config) {
250
298
  let translatedInNs = 0;
251
299
  let emptyInNs = 0;
252
300
  let absentInNs = 0;
301
+ let optionalInNs = 0;
253
302
  let totalInNs = 0;
254
303
  const keyDetails = [];
255
304
  // Get the plural categories for THIS specific locale
@@ -314,37 +363,85 @@ async function generateStatusReport(config) {
314
363
  // Only count this key if it's a plural form used by this locale
315
364
  if (localePluralCategories.includes(category) && !processedKeys.has(baseKey)) {
316
365
  processedKeys.add(baseKey);
317
- totalInNs++;
318
366
  const state = resolveAndClassify(baseKey);
319
- if (state === 'translated')
320
- translatedInNs++;
321
- else if (state === 'empty')
322
- emptyInNs++;
323
- else
324
- absentInNs++;
325
- keyDetails.push({ key: baseKey, state });
367
+ const optionalCategories = getOptionalPluralCategories(locale, isOrdinalVariant);
368
+ // An optional category (e.g. French `_many`) is a soft note whenever
369
+ // it isn't translated — whether absent or an empty placeholder that
370
+ // `extract` wrote. It is never a hard failure. See #270 and
371
+ // getOptionalPluralCategories.
372
+ if (state !== 'translated' && optionalCategories.has(category)) {
373
+ optionalInNs++;
374
+ keyDetails.push({ key: baseKey, state: 'optional' });
375
+ }
376
+ else {
377
+ totalInNs++;
378
+ if (state === 'translated')
379
+ translatedInNs++;
380
+ else if (state === 'empty')
381
+ emptyInNs++;
382
+ else
383
+ absentInNs++;
384
+ keyDetails.push({ key: baseKey, state });
385
+ }
326
386
  }
327
387
  }
328
388
  else {
329
- // This is a base plural key without expanded variants
330
- // Expand it according to THIS locale's plural rules
389
+ // This is a base plural key without expanded variants. Mirror the
390
+ // i18next runtime, where t(key, { count }) resolves `key + suffix`
391
+ // and falls back to the bare `key`. A family is therefore satisfied
392
+ // either by its plural variants OR by a bare key (the convention
393
+ // used when `disablePlurals` is enabled and no variants are written).
331
394
  const localePluralCategories = getLocalePluralCategories(locale, isOrdinal || false);
332
- for (const category of localePluralCategories) {
333
- const pluralKey = isOrdinal
395
+ const optionalCategories = getOptionalPluralCategories(locale, isOrdinal || false);
396
+ const variants = localePluralCategories.map(category => ({
397
+ category,
398
+ pluralKey: isOrdinal
334
399
  ? `${baseKey}${pluralSeparator}ordinal${pluralSeparator}${category}`
335
- : `${baseKey}${pluralSeparator}${category}`;
336
- if (processedKeys.has(pluralKey))
337
- continue;
338
- processedKeys.add(pluralKey);
400
+ : `${baseKey}${pluralSeparator}${category}`,
401
+ }));
402
+ const anyVariantPresent = variants.some(({ pluralKey }) => resolveAndClassify(pluralKey) !== 'absent');
403
+ const bareState = resolveAndClassify(baseKey);
404
+ if (!anyVariantPresent && bareState !== 'absent' && !processedKeys.has(baseKey)) {
405
+ // Convention (a): only the bare key exists (typical under
406
+ // disablePlurals, or single-"other" languages). The runtime
407
+ // resolves count via the bare key, so it satisfies the family on
408
+ // its own — don't demand plural variants that were never written.
409
+ processedKeys.add(baseKey);
339
410
  totalInNs++;
340
- const state = resolveAndClassify(pluralKey);
341
- if (state === 'translated')
411
+ if (bareState === 'translated')
342
412
  translatedInNs++;
343
- else if (state === 'empty')
413
+ else if (bareState === 'empty')
344
414
  emptyInNs++;
345
415
  else
346
416
  absentInNs++;
347
- keyDetails.push({ key: pluralKey, state });
417
+ keyDetails.push({ key: baseKey, state: bareState });
418
+ }
419
+ else {
420
+ // Convention (b): plural variants exist (or the family is missing
421
+ // entirely). Evaluate each CLDR category — a missing variant is a
422
+ // hard absence only when the category is required for this locale;
423
+ // optional categories (e.g. French `_many`) downgrade to a soft note.
424
+ for (const { category, pluralKey } of variants) {
425
+ if (processedKeys.has(pluralKey))
426
+ continue;
427
+ processedKeys.add(pluralKey);
428
+ const state = resolveAndClassify(pluralKey);
429
+ // An optional category (e.g. French `_many`) is a soft note
430
+ // whenever it isn't translated — never a hard failure. See #270.
431
+ if (state !== 'translated' && optionalCategories.has(category)) {
432
+ optionalInNs++;
433
+ keyDetails.push({ key: pluralKey, state: 'optional' });
434
+ continue;
435
+ }
436
+ totalInNs++;
437
+ if (state === 'translated')
438
+ translatedInNs++;
439
+ else if (state === 'empty')
440
+ emptyInNs++;
441
+ else
442
+ absentInNs++;
443
+ keyDetails.push({ key: pluralKey, state });
444
+ }
348
445
  }
349
446
  }
350
447
  }
@@ -380,10 +477,11 @@ async function generateStatusReport(config) {
380
477
  absentInNs++;
381
478
  keyDetails.push({ key: variantKey, state });
382
479
  }
383
- namespaces.set(ns, { totalKeys: totalInNs, translatedKeys: translatedInNs, emptyKeys: emptyInNs, absentKeys: absentInNs, keyDetails });
480
+ namespaces.set(ns, { totalKeys: totalInNs, translatedKeys: translatedInNs, emptyKeys: emptyInNs, absentKeys: absentInNs, optionalKeys: optionalInNs, keyDetails });
384
481
  totalTranslatedForLocale += translatedInNs;
385
482
  totalEmptyForLocale += emptyInNs;
386
483
  totalAbsentForLocale += absentInNs;
484
+ totalOptionalForLocale += optionalInNs;
387
485
  totalKeysForLocale += totalInNs;
388
486
  }
389
487
  const localeStatus = {
@@ -391,6 +489,7 @@ async function generateStatusReport(config) {
391
489
  totalTranslated: totalTranslatedForLocale,
392
490
  totalEmpty: totalEmptyForLocale,
393
491
  totalAbsent: totalAbsentForLocale,
492
+ totalOptional: totalOptionalForLocale,
394
493
  namespaces,
395
494
  };
396
495
  if (isPrimary) {
@@ -482,6 +581,9 @@ async function displayDetailedLocaleReport(report, config, locale, namespaceFilt
482
581
  else if (state === 'empty') {
483
582
  console.log(` ${node_util.styleText('yellow', '~')} ${key} ${node_util.styleText('yellow', '(untranslated)')}`);
484
583
  }
584
+ else if (state === 'optional') {
585
+ console.log(` ${node_util.styleText('gray', '○')} ${key} ${node_util.styleText('gray', '(optional plural form)')}`);
586
+ }
485
587
  else {
486
588
  console.log(` ${node_util.styleText('red', '✗')} ${key} ${node_util.styleText('red', '(absent)')}`);
487
589
  }
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.64.0'); // This string is replaced with the actual version at build time by rollup
34
+ .version('1.64.1'); // 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
@@ -392,8 +392,11 @@ function buildNewTranslationsForNs(nsKeys, existingTranslations, config, locale,
392
392
  if (shouldFilterKey(key)) {
393
393
  return false;
394
394
  }
395
- if (!hasCount) {
396
- // Non-plural keys are always included
395
+ if (!hasCount || config.extract.disablePlurals) {
396
+ // Non-plural keys are always included. Under `disablePlurals`, count keys
397
+ // are emitted as plain keys (no plural expansion) but still carry
398
+ // `hasCount` so `status` can recognise them — they must bypass the
399
+ // plural-form filtering below and always be included, exactly as before.
397
400
  return true;
398
401
  }
399
402
  // For plural keys, check if this specific plural form is needed for the target language
@@ -335,12 +335,17 @@ class CallExpressionHandler {
335
335
  // Check if plurals are disabled FIRST, before any plural optimization paths
336
336
  if (this.config.extract.disablePlurals) {
337
337
  // When plurals are disabled, treat count as a regular option (for interpolation only)
338
- // Still handle context normally
338
+ // Still handle context normally. We keep `hasCount`/`isOrdinal` on the
339
+ // emitted key so `status` knows it is count-driven and can accept the
340
+ // file's plural variants (or the bare key) instead of demanding the
341
+ // literal base key. File generation ignores these flags under
342
+ // disablePlurals (see translation-manager), so output is unchanged.
343
+ const countMeta = hasCount ? { hasCount: true, isOrdinal: isOrdinalByKey } : {};
339
344
  if (keysWithContext.length > 0) {
340
- keysWithContext.forEach(this.pluginContext.addKey);
345
+ keysWithContext.forEach(k => this.pluginContext.addKey({ ...k, ...countMeta }));
341
346
  }
342
347
  else {
343
- this.pluginContext.addKey({ key: finalKey, ns, defaultValue: dv, explicitDefault: explicitDefaultForBase });
348
+ this.pluginContext.addKey({ key: finalKey, ns, defaultValue: dv, explicitDefault: explicitDefaultForBase, ...countMeta });
344
349
  }
345
350
  continue; // This key is fully handled
346
351
  }
@@ -122,14 +122,18 @@ function extractKeysFromComments(code, pluginContext, config, scopeResolver) {
122
122
  ns = config.extract.defaultNS;
123
123
  // 5. Handle context and count combinations based on disablePlurals setting
124
124
  if (config.extract.disablePlurals) {
125
- // When plurals are disabled, ignore count for key generation
125
+ // When plurals are disabled, ignore count for key generation. We still
126
+ // tag the key with `hasCount` (when a count was present) so `status` can
127
+ // recognise it as count-driven; file generation ignores the flag under
128
+ // disablePlurals (see translation-manager).
129
+ const countMeta = count ? { hasCount: true, isOrdinal } : {};
126
130
  if (context) {
127
131
  // Only generate context variants (no base key when context is static)
128
- pluginContext.addKey({ key: `${key}_${context}`, ns, defaultValue: defaultValue ?? key });
132
+ pluginContext.addKey({ key: `${key}_${context}`, ns, defaultValue: defaultValue ?? key, ...countMeta });
129
133
  }
130
134
  else {
131
135
  // Simple key (ignore count)
132
- pluginContext.addKey({ key, ns, defaultValue: defaultValue ?? key });
136
+ pluginContext.addKey({ key, ns, defaultValue: defaultValue ?? key, ...countMeta });
133
137
  }
134
138
  }
135
139
  else {
@@ -367,12 +367,16 @@ class JSXHandler {
367
367
  else if (hasCount) {
368
368
  // Check if plurals are disabled
369
369
  if (this.config.extract.disablePlurals) {
370
- // When plurals are disabled, just add the base keys (no plural forms)
370
+ // When plurals are disabled, just add the base keys (no plural forms).
371
+ // We keep `hasCount` so `status` recognises the key as count-driven and
372
+ // accepts the file's plural variants (or bare key); file generation
373
+ // ignores it under disablePlurals (see translation-manager).
371
374
  extractedKeys.forEach(extractedKey => {
372
375
  this.pluginContext.addKey({
373
376
  key: extractedKey.key,
374
377
  ns: extractedKey.ns,
375
378
  defaultValue: extractedKey.defaultValue,
379
+ hasCount: true,
376
380
  locations: extractedKey.locations
377
381
  });
378
382
  });
@@ -20,6 +20,53 @@ function classifyValue(value) {
20
20
  return 'empty';
21
21
  return 'translated';
22
22
  }
23
+ /**
24
+ * Representative counts used to decide which CLDR plural categories a locale can
25
+ * actually reach in normal usage: small integers (the common case), a handful
26
+ * of larger integers, and common decimals (so categories that only fire for
27
+ * fractional display values — e.g. Polish/Russian `other` — stay required).
28
+ *
29
+ * Any category NOT produced by these counts is treated as "optional". The prime
30
+ * example is French `many`, which `Intl.PluralRules` only selects for values
31
+ * ≥ 1,000,000 — the i18next runtime can technically request it, but real apps
32
+ * almost never hit those values (and the runtime falls back to the base key
33
+ * when the variant is missing), so a missing `_many` should be a soft note
34
+ * rather than a hard "missing key" failure.
35
+ */
36
+ const REPRESENTATIVE_COUNTS = (() => {
37
+ const counts = [];
38
+ for (let n = 0; n <= 20; n++)
39
+ counts.push(n);
40
+ counts.push(100, 101, 1000, 1001, 10000);
41
+ counts.push(0.5, 1.1, 1.5, 2.5, 3.5);
42
+ return counts;
43
+ })();
44
+ const optionalCategoriesCache = new Map();
45
+ /**
46
+ * Returns the CLDR plural categories for a locale that are NOT reachable by any
47
+ * representative count (see {@link REPRESENTATIVE_COUNTS}). These are reported
48
+ * by `status` as optional: a missing variant is a soft note instead of a hard
49
+ * absence, mirroring how the i18next runtime resolves such forms.
50
+ */
51
+ function getOptionalPluralCategories(locale, isOrdinal) {
52
+ const type = isOrdinal ? 'ordinal' : 'cardinal';
53
+ const cacheKey = `${locale}|${type}`;
54
+ const cached = optionalCategoriesCache.get(cacheKey);
55
+ if (cached)
56
+ return cached;
57
+ let optional;
58
+ try {
59
+ const rules = safePluralRules(locale, { type });
60
+ const all = rules.resolvedOptions().pluralCategories;
61
+ const reachable = new Set(REPRESENTATIVE_COUNTS.map(n => rules.select(n)));
62
+ optional = new Set(all.filter(c => !reachable.has(c)));
63
+ }
64
+ catch {
65
+ optional = new Set();
66
+ }
67
+ optionalCategoriesCache.set(cacheKey, optional);
68
+ return optional;
69
+ }
23
70
  /**
24
71
  * Runs a health check on the project's i18next translations and displays a status report.
25
72
  *
@@ -216,6 +263,7 @@ async function generateStatusReport(config) {
216
263
  let totalTranslatedForLocale = 0;
217
264
  let totalEmptyForLocale = 0;
218
265
  let totalAbsentForLocale = 0;
266
+ let totalOptionalForLocale = 0;
219
267
  let totalKeysForLocale = 0;
220
268
  const namespaces = new Map();
221
269
  const mergedTranslations = mergeNamespaces
@@ -244,6 +292,7 @@ async function generateStatusReport(config) {
244
292
  let translatedInNs = 0;
245
293
  let emptyInNs = 0;
246
294
  let absentInNs = 0;
295
+ let optionalInNs = 0;
247
296
  let totalInNs = 0;
248
297
  const keyDetails = [];
249
298
  // Get the plural categories for THIS specific locale
@@ -308,37 +357,85 @@ async function generateStatusReport(config) {
308
357
  // Only count this key if it's a plural form used by this locale
309
358
  if (localePluralCategories.includes(category) && !processedKeys.has(baseKey)) {
310
359
  processedKeys.add(baseKey);
311
- totalInNs++;
312
360
  const state = resolveAndClassify(baseKey);
313
- if (state === 'translated')
314
- translatedInNs++;
315
- else if (state === 'empty')
316
- emptyInNs++;
317
- else
318
- absentInNs++;
319
- keyDetails.push({ key: baseKey, state });
361
+ const optionalCategories = getOptionalPluralCategories(locale, isOrdinalVariant);
362
+ // An optional category (e.g. French `_many`) is a soft note whenever
363
+ // it isn't translated — whether absent or an empty placeholder that
364
+ // `extract` wrote. It is never a hard failure. See #270 and
365
+ // getOptionalPluralCategories.
366
+ if (state !== 'translated' && optionalCategories.has(category)) {
367
+ optionalInNs++;
368
+ keyDetails.push({ key: baseKey, state: 'optional' });
369
+ }
370
+ else {
371
+ totalInNs++;
372
+ if (state === 'translated')
373
+ translatedInNs++;
374
+ else if (state === 'empty')
375
+ emptyInNs++;
376
+ else
377
+ absentInNs++;
378
+ keyDetails.push({ key: baseKey, state });
379
+ }
320
380
  }
321
381
  }
322
382
  else {
323
- // This is a base plural key without expanded variants
324
- // Expand it according to THIS locale's plural rules
383
+ // This is a base plural key without expanded variants. Mirror the
384
+ // i18next runtime, where t(key, { count }) resolves `key + suffix`
385
+ // and falls back to the bare `key`. A family is therefore satisfied
386
+ // either by its plural variants OR by a bare key (the convention
387
+ // used when `disablePlurals` is enabled and no variants are written).
325
388
  const localePluralCategories = getLocalePluralCategories(locale, isOrdinal || false);
326
- for (const category of localePluralCategories) {
327
- const pluralKey = isOrdinal
389
+ const optionalCategories = getOptionalPluralCategories(locale, isOrdinal || false);
390
+ const variants = localePluralCategories.map(category => ({
391
+ category,
392
+ pluralKey: isOrdinal
328
393
  ? `${baseKey}${pluralSeparator}ordinal${pluralSeparator}${category}`
329
- : `${baseKey}${pluralSeparator}${category}`;
330
- if (processedKeys.has(pluralKey))
331
- continue;
332
- processedKeys.add(pluralKey);
394
+ : `${baseKey}${pluralSeparator}${category}`,
395
+ }));
396
+ const anyVariantPresent = variants.some(({ pluralKey }) => resolveAndClassify(pluralKey) !== 'absent');
397
+ const bareState = resolveAndClassify(baseKey);
398
+ if (!anyVariantPresent && bareState !== 'absent' && !processedKeys.has(baseKey)) {
399
+ // Convention (a): only the bare key exists (typical under
400
+ // disablePlurals, or single-"other" languages). The runtime
401
+ // resolves count via the bare key, so it satisfies the family on
402
+ // its own — don't demand plural variants that were never written.
403
+ processedKeys.add(baseKey);
333
404
  totalInNs++;
334
- const state = resolveAndClassify(pluralKey);
335
- if (state === 'translated')
405
+ if (bareState === 'translated')
336
406
  translatedInNs++;
337
- else if (state === 'empty')
407
+ else if (bareState === 'empty')
338
408
  emptyInNs++;
339
409
  else
340
410
  absentInNs++;
341
- keyDetails.push({ key: pluralKey, state });
411
+ keyDetails.push({ key: baseKey, state: bareState });
412
+ }
413
+ else {
414
+ // Convention (b): plural variants exist (or the family is missing
415
+ // entirely). Evaluate each CLDR category — a missing variant is a
416
+ // hard absence only when the category is required for this locale;
417
+ // optional categories (e.g. French `_many`) downgrade to a soft note.
418
+ for (const { category, pluralKey } of variants) {
419
+ if (processedKeys.has(pluralKey))
420
+ continue;
421
+ processedKeys.add(pluralKey);
422
+ const state = resolveAndClassify(pluralKey);
423
+ // An optional category (e.g. French `_many`) is a soft note
424
+ // whenever it isn't translated — never a hard failure. See #270.
425
+ if (state !== 'translated' && optionalCategories.has(category)) {
426
+ optionalInNs++;
427
+ keyDetails.push({ key: pluralKey, state: 'optional' });
428
+ continue;
429
+ }
430
+ totalInNs++;
431
+ if (state === 'translated')
432
+ translatedInNs++;
433
+ else if (state === 'empty')
434
+ emptyInNs++;
435
+ else
436
+ absentInNs++;
437
+ keyDetails.push({ key: pluralKey, state });
438
+ }
342
439
  }
343
440
  }
344
441
  }
@@ -374,10 +471,11 @@ async function generateStatusReport(config) {
374
471
  absentInNs++;
375
472
  keyDetails.push({ key: variantKey, state });
376
473
  }
377
- namespaces.set(ns, { totalKeys: totalInNs, translatedKeys: translatedInNs, emptyKeys: emptyInNs, absentKeys: absentInNs, keyDetails });
474
+ namespaces.set(ns, { totalKeys: totalInNs, translatedKeys: translatedInNs, emptyKeys: emptyInNs, absentKeys: absentInNs, optionalKeys: optionalInNs, keyDetails });
378
475
  totalTranslatedForLocale += translatedInNs;
379
476
  totalEmptyForLocale += emptyInNs;
380
477
  totalAbsentForLocale += absentInNs;
478
+ totalOptionalForLocale += optionalInNs;
381
479
  totalKeysForLocale += totalInNs;
382
480
  }
383
481
  const localeStatus = {
@@ -385,6 +483,7 @@ async function generateStatusReport(config) {
385
483
  totalTranslated: totalTranslatedForLocale,
386
484
  totalEmpty: totalEmptyForLocale,
387
485
  totalAbsent: totalAbsentForLocale,
486
+ totalOptional: totalOptionalForLocale,
388
487
  namespaces,
389
488
  };
390
489
  if (isPrimary) {
@@ -476,6 +575,9 @@ async function displayDetailedLocaleReport(report, config, locale, namespaceFilt
476
575
  else if (state === 'empty') {
477
576
  console.log(` ${styleText('yellow', '~')} ${key} ${styleText('yellow', '(untranslated)')}`);
478
577
  }
578
+ else if (state === 'optional') {
579
+ console.log(` ${styleText('gray', '○')} ${key} ${styleText('gray', '(optional plural form)')}`);
580
+ }
479
581
  else {
480
582
  console.log(` ${styleText('red', '✗')} ${key} ${styleText('red', '(absent)')}`);
481
583
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "i18next-cli",
3
- "version": "1.64.0",
3
+ "version": "1.64.1",
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;AAujC9F;;;;;;;;;;;;;;;;;;;;;;;;;;;;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,CAiK9B"}
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;AA0jC9F;;;;;;;;;;;;;;;;;;;;;;;;;;;;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,CAiK9B"}
@@ -1 +1 @@
1
- {"version":3,"file":"call-expression-handler.d.ts","sourceRoot":"","sources":["../../../src/extractor/parsers/call-expression-handler.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,cAAc,EAA6C,MAAM,WAAW,CAAA;AAC1F,OAAO,KAAK,EAAE,aAAa,EAAE,oBAAoB,EAAE,MAAM,EAAgB,SAAS,EAAE,MAAM,gBAAgB,CAAA;AAC1G,OAAO,EAAE,kBAAkB,EAAE,MAAM,0BAA0B,CAAA;AAc7D,qBAAa,qBAAqB;IAChC,OAAO,CAAC,aAAa,CAAe;IACpC,OAAO,CAAC,MAAM,CAAuC;IACrD,OAAO,CAAC,MAAM,CAAQ;IACtB,OAAO,CAAC,kBAAkB,CAAoB;IACvC,UAAU,cAAoB;IACrC,OAAO,CAAC,cAAc,CAAc;IACpC,OAAO,CAAC,cAAc,CAAc;IACpC,OAAO,CAAC,iBAAiB,CAAsC;gBAG7D,MAAM,EAAE,IAAI,CAAC,oBAAoB,EAAE,SAAS,CAAC,EAC7C,aAAa,EAAE,aAAa,EAC5B,MAAM,EAAE,MAAM,EACd,kBAAkB,EAAE,kBAAkB,EACtC,cAAc,EAAE,MAAM,MAAM,EAC5B,cAAc,EAAE,MAAM,MAAM,EAC5B,iBAAiB,GAAE,CAAC,IAAI,EAAE,MAAM,KAAK,MAAM,GAAG,SAA2B;IAW3E;;;;;OAKG;IACH,OAAO,CAAC,mBAAmB;IAiB3B;;;;;;;;;;;;;;OAcG;IACH,oBAAoB,CAAE,IAAI,EAAE,cAAc,EAAE,YAAY,EAAE,CAAC,IAAI,EAAE,MAAM,KAAK,SAAS,GAAG,SAAS,GAAG,IAAI;IAyZxG;;;;OAIG;IACH,OAAO,CAAC,iBAAiB;IAmBzB,OAAO,CAAC,wBAAwB;IAyEhC;;;;;;OAMG;IACH,OAAO,CAAC,4BAA4B;IAsDpC;;;;;;;;;;;;;;;;;;;;;;;OAuBG;IACH,OAAO,CAAC,sBAAsB;IA2B9B;;;;;;;;;;;;;;;;OAgBG;IACH,OAAO,CAAC,uBAAuB;IAgB/B;;;;;;;;OAQG;IACH,OAAO,CAAC,iCAAiC;IAwFzC;;;;;;;;;;;OAWG;IACH,OAAO,CAAC,gBAAgB;IAyMxB;;;;;;;;;OASG;IACH,OAAO,CAAC,eAAe;CA2BxB"}
1
+ {"version":3,"file":"call-expression-handler.d.ts","sourceRoot":"","sources":["../../../src/extractor/parsers/call-expression-handler.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,cAAc,EAA6C,MAAM,WAAW,CAAA;AAC1F,OAAO,KAAK,EAAE,aAAa,EAAE,oBAAoB,EAAE,MAAM,EAAgB,SAAS,EAAE,MAAM,gBAAgB,CAAA;AAC1G,OAAO,EAAE,kBAAkB,EAAE,MAAM,0BAA0B,CAAA;AAc7D,qBAAa,qBAAqB;IAChC,OAAO,CAAC,aAAa,CAAe;IACpC,OAAO,CAAC,MAAM,CAAuC;IACrD,OAAO,CAAC,MAAM,CAAQ;IACtB,OAAO,CAAC,kBAAkB,CAAoB;IACvC,UAAU,cAAoB;IACrC,OAAO,CAAC,cAAc,CAAc;IACpC,OAAO,CAAC,cAAc,CAAc;IACpC,OAAO,CAAC,iBAAiB,CAAsC;gBAG7D,MAAM,EAAE,IAAI,CAAC,oBAAoB,EAAE,SAAS,CAAC,EAC7C,aAAa,EAAE,aAAa,EAC5B,MAAM,EAAE,MAAM,EACd,kBAAkB,EAAE,kBAAkB,EACtC,cAAc,EAAE,MAAM,MAAM,EAC5B,cAAc,EAAE,MAAM,MAAM,EAC5B,iBAAiB,GAAE,CAAC,IAAI,EAAE,MAAM,KAAK,MAAM,GAAG,SAA2B;IAW3E;;;;;OAKG;IACH,OAAO,CAAC,mBAAmB;IAiB3B;;;;;;;;;;;;;;OAcG;IACH,oBAAoB,CAAE,IAAI,EAAE,cAAc,EAAE,YAAY,EAAE,CAAC,IAAI,EAAE,MAAM,KAAK,SAAS,GAAG,SAAS,GAAG,IAAI;IA8ZxG;;;;OAIG;IACH,OAAO,CAAC,iBAAiB;IAmBzB,OAAO,CAAC,wBAAwB;IAyEhC;;;;;;OAMG;IACH,OAAO,CAAC,4BAA4B;IAsDpC;;;;;;;;;;;;;;;;;;;;;;;OAuBG;IACH,OAAO,CAAC,sBAAsB;IA2B9B;;;;;;;;;;;;;;;;OAgBG;IACH,OAAO,CAAC,uBAAuB;IAgB/B;;;;;;;;OAQG;IACH,OAAO,CAAC,iCAAiC;IAwFzC;;;;;;;;;;;OAWG;IACH,OAAO,CAAC,gBAAgB;IAyMxB;;;;;;;;;OASG;IACH,OAAO,CAAC,eAAe;CA2BxB"}
@@ -1 +1 @@
1
- {"version":3,"file":"comment-parser.d.ts","sourceRoot":"","sources":["../../../src/extractor/parsers/comment-parser.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,aAAa,EAAE,oBAAoB,EAAE,MAAM,gBAAgB,CAAA;AAOzE;;;;;;;;;;;;;;;;;;;;GAoBG;AACH,wBAAgB,uBAAuB,CACrC,IAAI,EAAE,MAAM,EACZ,aAAa,EAAE,aAAa,EAC5B,MAAM,EAAE,oBAAoB,EAC5B,aAAa,CAAC,EAAE,CAAC,OAAO,EAAE,MAAM,KAAK;IAAE,SAAS,CAAC,EAAE,MAAM,CAAC;IAAC,SAAS,CAAC,EAAE,MAAM,CAAA;CAAE,GAAG,SAAS,GAC1F,IAAI,CAqJN"}
1
+ {"version":3,"file":"comment-parser.d.ts","sourceRoot":"","sources":["../../../src/extractor/parsers/comment-parser.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,aAAa,EAAE,oBAAoB,EAAE,MAAM,gBAAgB,CAAA;AAOzE;;;;;;;;;;;;;;;;;;;;GAoBG;AACH,wBAAgB,uBAAuB,CACrC,IAAI,EAAE,MAAM,EACZ,aAAa,EAAE,aAAa,EAC5B,MAAM,EAAE,oBAAoB,EAC5B,aAAa,CAAC,EAAE,CAAC,OAAO,EAAE,MAAM,KAAK;IAAE,SAAS,CAAC,EAAE,MAAM,CAAC;IAAC,SAAS,CAAC,EAAE,MAAM,CAAA;CAAE,GAAG,SAAS,GAC1F,IAAI,CAyJN"}
@@ -1 +1 @@
1
- {"version":3,"file":"jsx-handler.d.ts","sourceRoot":"","sources":["../../../src/extractor/parsers/jsx-handler.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAc,UAAU,EAAqC,MAAM,WAAW,CAAA;AAC1F,OAAO,KAAK,EAAE,aAAa,EAAE,oBAAoB,EAAgB,MAAM,gBAAgB,CAAA;AACvF,OAAO,EAAE,kBAAkB,EAAE,MAAM,0BAA0B,CAAA;AAS7D,qBAAa,UAAU;IACrB,OAAO,CAAC,MAAM,CAAuC;IACrD,OAAO,CAAC,aAAa,CAAe;IACpC,OAAO,CAAC,kBAAkB,CAAoB;IAC9C,OAAO,CAAC,cAAc,CAAc;IACpC,OAAO,CAAC,cAAc,CAAc;gBAGlC,MAAM,EAAE,IAAI,CAAC,oBAAoB,EAAE,SAAS,CAAC,EAC7C,aAAa,EAAE,aAAa,EAC5B,kBAAkB,EAAE,kBAAkB,EACtC,cAAc,EAAE,MAAM,MAAM,EAC5B,cAAc,EAAE,MAAM,MAAM;IAS9B;;;;OAIG;IACH,OAAO,CAAC,mBAAmB;IAK3B;;;;;;;;;;;;;;OAcG;IACH,OAAO,CAAC,iCAAiC;IAgCzC;;;OAGG;IACH,OAAO,CAAC,gBAAgB;IAWxB;;;;;;;;OAQG;IACH,gBAAgB,CAAE,IAAI,EAAE,UAAU,EAAE,YAAY,EAAE,CAAC,IAAI,EAAE,MAAM,KAAK;QAAE,SAAS,CAAC,EAAE,MAAM,CAAC;QAAC,SAAS,CAAC,EAAE,MAAM,CAAA;KAAE,GAAG,SAAS,GAAG,IAAI;IA2UjI;;;;;;;;;;;OAWG;IACH,OAAO,CAAC,0BAA0B;IAkIlC;;;;;;;;;OASG;IACH,OAAO,CAAC,cAAc;CAevB"}
1
+ {"version":3,"file":"jsx-handler.d.ts","sourceRoot":"","sources":["../../../src/extractor/parsers/jsx-handler.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAc,UAAU,EAAqC,MAAM,WAAW,CAAA;AAC1F,OAAO,KAAK,EAAE,aAAa,EAAE,oBAAoB,EAAgB,MAAM,gBAAgB,CAAA;AACvF,OAAO,EAAE,kBAAkB,EAAE,MAAM,0BAA0B,CAAA;AAS7D,qBAAa,UAAU;IACrB,OAAO,CAAC,MAAM,CAAuC;IACrD,OAAO,CAAC,aAAa,CAAe;IACpC,OAAO,CAAC,kBAAkB,CAAoB;IAC9C,OAAO,CAAC,cAAc,CAAc;IACpC,OAAO,CAAC,cAAc,CAAc;gBAGlC,MAAM,EAAE,IAAI,CAAC,oBAAoB,EAAE,SAAS,CAAC,EAC7C,aAAa,EAAE,aAAa,EAC5B,kBAAkB,EAAE,kBAAkB,EACtC,cAAc,EAAE,MAAM,MAAM,EAC5B,cAAc,EAAE,MAAM,MAAM;IAS9B;;;;OAIG;IACH,OAAO,CAAC,mBAAmB;IAK3B;;;;;;;;;;;;;;OAcG;IACH,OAAO,CAAC,iCAAiC;IAgCzC;;;OAGG;IACH,OAAO,CAAC,gBAAgB;IAWxB;;;;;;;;OAQG;IACH,gBAAgB,CAAE,IAAI,EAAE,UAAU,EAAE,YAAY,EAAE,CAAC,IAAI,EAAE,MAAM,KAAK;QAAE,SAAS,CAAC,EAAE,MAAM,CAAC;QAAC,SAAS,CAAC,EAAE,MAAM,CAAA;KAAE,GAAG,SAAS,GAAG,IAAI;IA+UjI;;;;;;;;;;;OAWG;IACH,OAAO,CAAC,0BAA0B;IAkIlC;;;;;;;;;OASG;IACH,OAAO,CAAC,cAAc;CAevB"}
@@ -1 +1 @@
1
- {"version":3,"file":"status.d.ts","sourceRoot":"","sources":["../src/status.ts"],"names":[],"mappings":"AAKA,OAAO,KAAK,EAAE,oBAAoB,EAAgB,MAAM,YAAY,CAAA;AAOpE;;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;AAiED;;;;;;;;;;;;;;;;;;GAkBG;AACH,wBAAsB,SAAS,CAAE,MAAM,EAAE,oBAAoB,EAAE,OAAO,GAAE,aAAkB,iBA4BzF"}
1
+ {"version":3,"file":"status.d.ts","sourceRoot":"","sources":["../src/status.ts"],"names":[],"mappings":"AAKA,OAAO,KAAK,EAAE,oBAAoB,EAAgB,MAAM,YAAY,CAAA;AAOpE;;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,iBA4BzF"}