i18next-cli 1.41.1 → 1.41.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/dist/cjs/cli.js CHANGED
@@ -28,7 +28,7 @@ const program = new commander.Command();
28
28
  program
29
29
  .name('i18next-cli')
30
30
  .description('A unified, high-performance i18next CLI.')
31
- .version('1.41.1'); // This string is replaced with the actual version at build time by rollup
31
+ .version('1.41.3'); // This string is replaced with the actual version at build time by rollup
32
32
  // new: global config override option
33
33
  program.option('-c, --config <path>', 'Path to i18next-cli config file (overrides detection)');
34
34
  program
@@ -8,6 +8,7 @@ var defaultValue = require('../../utils/default-value.js');
8
8
 
9
9
  // used for natural language check
10
10
  const chars = [' ', ',', '?', '!', ';'];
11
+ const pluralForms = ['zero', 'one', 'two', 'few', 'many', 'other'];
11
12
  /**
12
13
  * Converts a glob pattern to a regular expression for matching keys
13
14
  * @param glob - The glob pattern to convert
@@ -37,7 +38,6 @@ function isContextVariantOfAcceptingKey(existingKey, keysAcceptingContext, plura
37
38
  // Try to extract the base key from this existing key by removing context and/or plural suffixes
38
39
  let potentialBaseKey = existingKey;
39
40
  // First, try removing plural suffixes if present
40
- const pluralForms = ['zero', 'one', 'two', 'few', 'many', 'other'];
41
41
  for (const form of pluralForms) {
42
42
  if (potentialBaseKey.endsWith(`${pluralSeparator}${form}`)) {
43
43
  potentialBaseKey = potentialBaseKey.slice(0, -(pluralSeparator.length + form.length));
@@ -97,8 +97,7 @@ function sortObject(obj, config, customSort) {
97
97
  const sortedObj = {};
98
98
  const pluralSeparator = config?.extract?.pluralSeparator ?? '_';
99
99
  // Define the canonical order for plural forms
100
- const pluralOrder = ['zero', 'one', 'two', 'few', 'many', 'other'];
101
- const ordinalPluralOrder = pluralOrder.map(form => `ordinal${pluralSeparator}${form}`);
100
+ const ordinalPluralOrder = pluralForms.map(form => `ordinal${pluralSeparator}${form}`);
102
101
  const keys = Object.keys(obj).sort((a, b) => {
103
102
  // Helper function to extract base key and form info
104
103
  const getKeyInfo = (key) => {
@@ -110,7 +109,7 @@ function sortObject(obj, config, customSort) {
110
109
  }
111
110
  }
112
111
  // Handle cardinal plurals: key_form or key_context_form
113
- for (const form of pluralOrder) {
112
+ for (const form of pluralForms) {
114
113
  if (key.endsWith(`${pluralSeparator}${form}`)) {
115
114
  const base = key.slice(0, -(pluralSeparator.length + form.length));
116
115
  return { base, form, isOrdinal: false, isPlural: true, fullKey: key };
@@ -135,7 +134,7 @@ function sortObject(obj, config, customSort) {
135
134
  return aInfo.isOrdinal ? 1 : -1;
136
135
  }
137
136
  // Both same type (cardinal or ordinal), sort by canonical order
138
- const orderArray = aInfo.isOrdinal ? ordinalPluralOrder : pluralOrder;
137
+ const orderArray = aInfo.isOrdinal ? ordinalPluralOrder : pluralForms;
139
138
  const aIndex = orderArray.indexOf(aInfo.form);
140
139
  const bIndex = orderArray.indexOf(bInfo.form);
141
140
  if (aIndex !== -1 && bIndex !== -1) {
@@ -218,16 +217,14 @@ function buildNewTranslationsForNs(nsKeys, existingTranslations, config, locale,
218
217
  }
219
218
  }
220
219
  }
221
- // Get the plural categories for the target language
220
+ // Get the plural categories for the target language (only used for filtering extracted keys)
222
221
  const targetLanguagePluralCategories = new Set();
223
222
  // Track cardinal plural categories separately so we can special-case single-"other" languages
224
223
  let cardinalCategories = [];
225
- let ordinalCategories = [];
226
224
  try {
227
225
  const cardinalRules = new Intl.PluralRules(locale, { type: 'cardinal' });
228
226
  const ordinalRules = new Intl.PluralRules(locale, { type: 'ordinal' });
229
227
  cardinalCategories = cardinalRules.resolvedOptions().pluralCategories;
230
- ordinalCategories = ordinalRules.resolvedOptions().pluralCategories;
231
228
  cardinalCategories.forEach(cat => targetLanguagePluralCategories.add(cat));
232
229
  ordinalRules.resolvedOptions().pluralCategories.forEach(cat => targetLanguagePluralCategories.add(`ordinal_${cat}`));
233
230
  }
@@ -237,7 +234,6 @@ function buildNewTranslationsForNs(nsKeys, existingTranslations, config, locale,
237
234
  const cardinalRules = new Intl.PluralRules(fallbackLang, { type: 'cardinal' });
238
235
  const ordinalRules = new Intl.PluralRules(fallbackLang, { type: 'ordinal' });
239
236
  cardinalCategories = cardinalRules.resolvedOptions().pluralCategories;
240
- ordinalCategories = ordinalRules.resolvedOptions().pluralCategories;
241
237
  cardinalCategories.forEach(cat => targetLanguagePluralCategories.add(cat));
242
238
  ordinalRules.resolvedOptions().pluralCategories.forEach(cat => targetLanguagePluralCategories.add(`ordinal_${cat}`));
243
239
  }
@@ -366,6 +362,49 @@ function buildNewTranslationsForNs(nsKeys, existingTranslations, config, locale,
366
362
  nestedObject.setNestedValue(newTranslations, existingKey, value, keySeparator ?? '.');
367
363
  }
368
364
  }
365
+ // PRESERVE LOCALE-SPECIFIC PLURAL FORMS: When dealing with plural keys in non-primary locales,
366
+ // preserve any existing plural forms that are NOT being explicitly generated.
367
+ // This ensures that locale-specific forms (like _few, _many) added by translators are preserved.
368
+ if (locale !== primaryLanguage && removeUnusedKeys) {
369
+ const existingKeys = nestedObject.getNestedKeys(existingTranslations, keySeparator ?? '.');
370
+ for (const existingKey of existingKeys) {
371
+ // Check if this is a plural form variant (ends with _form)
372
+ let isPluralForm = false;
373
+ let baseKey = existingKey;
374
+ let foundForm = '';
375
+ for (const form of pluralForms) {
376
+ if (existingKey.endsWith(`${pluralSeparator}${form}`)) {
377
+ baseKey = existingKey.slice(0, -(pluralSeparator.length + form.length));
378
+ foundForm = form;
379
+ isPluralForm = true;
380
+ break;
381
+ }
382
+ }
383
+ if (isPluralForm && foundForm) {
384
+ // Check if the base key is in our filtered keys (meaning it's a plural key we're handling)
385
+ const isBaseInExtracted = filteredKeys.some(({ key }) => {
386
+ let extractedBase = key;
387
+ for (const form of pluralForms) {
388
+ if (extractedBase.endsWith(`${pluralSeparator}${form}`)) {
389
+ extractedBase = extractedBase.slice(0, -(pluralSeparator.length + form.length));
390
+ break;
391
+ }
392
+ }
393
+ return extractedBase === baseKey;
394
+ });
395
+ if (isBaseInExtracted) {
396
+ // This is a plural form for a key we're handling.
397
+ // Check if it's already in newTranslations (will be set by the normal flow)
398
+ const isAlreadySet = nestedObject.getNestedValue(newTranslations, existingKey, keySeparator ?? '.') !== undefined;
399
+ if (!isAlreadySet) {
400
+ // This plural form is NOT being generated by our code, so preserve it
401
+ const value = nestedObject.getNestedValue(existingTranslations, existingKey, keySeparator ?? '.');
402
+ nestedObject.setNestedValue(newTranslations, existingKey, value, keySeparator ?? '.');
403
+ }
404
+ }
405
+ }
406
+ }
407
+ }
369
408
  // SPECIAL HANDLING: Preserve existing _zero forms even if not in extracted keys
370
409
  // This ensures that optional _zero forms are not removed when they exist
371
410
  if (removeUnusedKeys) {
@@ -408,49 +447,104 @@ function buildNewTranslationsForNs(nsKeys, existingTranslations, config, locale,
408
447
  continue;
409
448
  }
410
449
  }
411
- // If this is a base plural key (no explicit suffix) and the locale is NOT the primary,
412
- // expand it into locale-specific plural variants (e.g. key_one, key_other).
413
- // Use the extracted defaultValue (fallback to base) for variant values.
450
+ // If this is a base plural key (no explicit suffix), expand it into locale-specific plural variants.
451
+ // For non-primary locales, we generate forms for that specific locale from CLDR.
452
+ // Additionally, we generate empty placeholders for ALL other CLDR forms not in the target locale
453
+ // (so translators can add them manually if needed).
414
454
  if (hasCount && !isExpandedPlural) {
415
455
  const parts = String(key).split(pluralSeparator);
416
456
  const isBaseKey = parts.length === 1;
417
- if (isBaseKey && locale !== primaryLanguage) {
457
+ if (isBaseKey) {
418
458
  // If explicit expanded variants exist, do not expand the base.
419
459
  const base = key;
420
460
  if (expandedBases.has(base)) ;
421
461
  else {
422
- // choose categories based on ordinal flag
423
- const categories = isOrdinal ? ordinalCategories : cardinalCategories;
424
- for (const category of categories) {
425
- const finalKey = isOrdinal
426
- ? `${base}${pluralSeparator}ordinal${pluralSeparator}${category}`
427
- : `${base}${pluralSeparator}${category}`;
428
- // If the key looks like a serialized Trans component (starts with <), treat it as a flat key
429
- // to prevent splitting on dots that appear within the content.
430
- const separator = finalKey.startsWith('<') ? false : (keySeparator ?? '.');
431
- // Preserve existing translation if present; otherwise set a sensible default
432
- const existingVariantValue = nestedObject.getNestedValue(existingTranslations, finalKey, separator);
433
- if (existingVariantValue === undefined) {
434
- // Prefer explicit defaultValue extracted for this key; fall back to configured defaultValue
435
- // (resolved via resolveDefaultValue which handles functions or strings and accepts the full parameter set).
436
- let resolvedValue;
437
- if (typeof defaultValue$1 === 'string') {
438
- resolvedValue = defaultValue$1;
462
+ // Determine which plural forms to generate
463
+ let formsToGenerate;
464
+ if (locale !== primaryLanguage) {
465
+ // For non-primary locales:
466
+ // 1. Generate the forms that locale actually needs
467
+ formsToGenerate = cardinalCategories;
468
+ // 2. Also prepare empty placeholders for all OTHER CLDR forms not in this locale
469
+ // so translators can add them manually without --sync-primary removing them
470
+ const otherForms = pluralForms.filter(f => !cardinalCategories.includes(f));
471
+ // Process the locale-specific forms normally
472
+ for (const form of formsToGenerate) {
473
+ const finalKey = isOrdinal
474
+ ? `${base}${pluralSeparator}${form}`
475
+ : `${base}${pluralSeparator}${form}`;
476
+ const separator = finalKey.startsWith('<') ? false : (keySeparator ?? '.');
477
+ const existingVariantValue = nestedObject.getNestedValue(existingTranslations, finalKey, separator);
478
+ if (existingVariantValue === undefined) {
479
+ // Use the default value for secondary locale forms
480
+ let resolvedValue;
481
+ if (typeof defaultValue$1 === 'string') {
482
+ resolvedValue = defaultValue$1;
483
+ }
484
+ else {
485
+ resolvedValue = defaultValue.resolveDefaultValue(emptyDefaultValue, String(base), namespace || config?.extract?.defaultNS || 'translation', locale, defaultValue$1);
486
+ }
487
+ nestedObject.setNestedValue(newTranslations, finalKey, resolvedValue, separator);
439
488
  }
440
489
  else {
441
- // Use resolveDefaultValue to compute a sensible default, providing namespace and locale context.
442
- resolvedValue = defaultValue.resolveDefaultValue(emptyDefaultValue, String(base), namespace || config?.extract?.defaultNS || 'translation', locale, defaultValue$1);
490
+ nestedObject.setNestedValue(newTranslations, finalKey, existingVariantValue, separator);
443
491
  }
444
- nestedObject.setNestedValue(newTranslations, finalKey, resolvedValue, separator);
492
+ }
493
+ // Now process other CLDR forms: set empty placeholders for forms this locale doesn't use
494
+ // but preserve any that were manually added by translators
495
+ for (const form of otherForms) {
496
+ const finalKey = isOrdinal
497
+ ? `${base}${pluralSeparator}${form}`
498
+ : `${base}${pluralSeparator}${form}`;
499
+ const separator = finalKey.startsWith('<') ? false : (keySeparator ?? '.');
500
+ const existingVariantValue = nestedObject.getNestedValue(existingTranslations, finalKey, separator);
501
+ if (existingVariantValue !== undefined) {
502
+ // Preserve manually-added forms
503
+ nestedObject.setNestedValue(newTranslations, finalKey, existingVariantValue, separator);
504
+ }
505
+ // Don't generate empty placeholders - only generate what the locale needs and preserve what's manual
506
+ }
507
+ }
508
+ else {
509
+ // For primary language, only expand if it has multiple plural forms
510
+ // Single-"other" languages (ja, zh, ko) should NOT expand the base key
511
+ if (cardinalCategories.length === 1 && cardinalCategories[0] === 'other') {
512
+ // Single-"other" language - don't expand, keep just the base key
513
+ formsToGenerate = [];
445
514
  }
446
515
  else {
447
- // Keep existing translation
448
- nestedObject.setNestedValue(newTranslations, finalKey, existingVariantValue, separator);
516
+ // Multi-form language - expand to its plural forms
517
+ formsToGenerate = cardinalCategories;
518
+ for (const form of formsToGenerate) {
519
+ const finalKey = isOrdinal
520
+ ? `${base}${pluralSeparator}${form}`
521
+ : `${base}${pluralSeparator}${form}`;
522
+ const separator = finalKey.startsWith('<') ? false : (keySeparator ?? '.');
523
+ const existingVariantValue = nestedObject.getNestedValue(existingTranslations, finalKey, separator);
524
+ if (existingVariantValue === undefined) {
525
+ // Prefer explicit defaultValue extracted for this key; fall back to configured defaultValue
526
+ let resolvedValue;
527
+ if (typeof defaultValue$1 === 'string') {
528
+ resolvedValue = defaultValue$1;
529
+ }
530
+ else {
531
+ resolvedValue = defaultValue.resolveDefaultValue(emptyDefaultValue, String(base), namespace || config?.extract?.defaultNS || 'translation', locale, defaultValue$1);
532
+ }
533
+ nestedObject.setNestedValue(newTranslations, finalKey, resolvedValue, separator);
534
+ }
535
+ else {
536
+ nestedObject.setNestedValue(newTranslations, finalKey, existingVariantValue, separator);
537
+ }
538
+ }
449
539
  }
450
540
  }
541
+ if (formsToGenerate && formsToGenerate.length > 0) {
542
+ // We've handled expansion for this base key; skip the normal single-key handling.
543
+ continue;
544
+ }
545
+ // else: formsToGenerate is empty (single-"other" primary language)
546
+ // Fall through to normal key handling below
451
547
  }
452
- // We've expanded variants for this base key; skip the normal single-key handling.
453
- continue;
454
548
  }
455
549
  }
456
550
  // If the key looks like a serialized Trans component (starts with <), treat it as a flat key
package/dist/esm/cli.js CHANGED
@@ -26,7 +26,7 @@ const program = new Command();
26
26
  program
27
27
  .name('i18next-cli')
28
28
  .description('A unified, high-performance i18next CLI.')
29
- .version('1.41.1'); // This string is replaced with the actual version at build time by rollup
29
+ .version('1.41.3'); // This string is replaced with the actual version at build time by rollup
30
30
  // new: global config override option
31
31
  program.option('-c, --config <path>', 'Path to i18next-cli config file (overrides detection)');
32
32
  program
@@ -6,6 +6,7 @@ import { resolveDefaultValue } from '../../utils/default-value.js';
6
6
 
7
7
  // used for natural language check
8
8
  const chars = [' ', ',', '?', '!', ';'];
9
+ const pluralForms = ['zero', 'one', 'two', 'few', 'many', 'other'];
9
10
  /**
10
11
  * Converts a glob pattern to a regular expression for matching keys
11
12
  * @param glob - The glob pattern to convert
@@ -35,7 +36,6 @@ function isContextVariantOfAcceptingKey(existingKey, keysAcceptingContext, plura
35
36
  // Try to extract the base key from this existing key by removing context and/or plural suffixes
36
37
  let potentialBaseKey = existingKey;
37
38
  // First, try removing plural suffixes if present
38
- const pluralForms = ['zero', 'one', 'two', 'few', 'many', 'other'];
39
39
  for (const form of pluralForms) {
40
40
  if (potentialBaseKey.endsWith(`${pluralSeparator}${form}`)) {
41
41
  potentialBaseKey = potentialBaseKey.slice(0, -(pluralSeparator.length + form.length));
@@ -95,8 +95,7 @@ function sortObject(obj, config, customSort) {
95
95
  const sortedObj = {};
96
96
  const pluralSeparator = config?.extract?.pluralSeparator ?? '_';
97
97
  // Define the canonical order for plural forms
98
- const pluralOrder = ['zero', 'one', 'two', 'few', 'many', 'other'];
99
- const ordinalPluralOrder = pluralOrder.map(form => `ordinal${pluralSeparator}${form}`);
98
+ const ordinalPluralOrder = pluralForms.map(form => `ordinal${pluralSeparator}${form}`);
100
99
  const keys = Object.keys(obj).sort((a, b) => {
101
100
  // Helper function to extract base key and form info
102
101
  const getKeyInfo = (key) => {
@@ -108,7 +107,7 @@ function sortObject(obj, config, customSort) {
108
107
  }
109
108
  }
110
109
  // Handle cardinal plurals: key_form or key_context_form
111
- for (const form of pluralOrder) {
110
+ for (const form of pluralForms) {
112
111
  if (key.endsWith(`${pluralSeparator}${form}`)) {
113
112
  const base = key.slice(0, -(pluralSeparator.length + form.length));
114
113
  return { base, form, isOrdinal: false, isPlural: true, fullKey: key };
@@ -133,7 +132,7 @@ function sortObject(obj, config, customSort) {
133
132
  return aInfo.isOrdinal ? 1 : -1;
134
133
  }
135
134
  // Both same type (cardinal or ordinal), sort by canonical order
136
- const orderArray = aInfo.isOrdinal ? ordinalPluralOrder : pluralOrder;
135
+ const orderArray = aInfo.isOrdinal ? ordinalPluralOrder : pluralForms;
137
136
  const aIndex = orderArray.indexOf(aInfo.form);
138
137
  const bIndex = orderArray.indexOf(bInfo.form);
139
138
  if (aIndex !== -1 && bIndex !== -1) {
@@ -216,16 +215,14 @@ function buildNewTranslationsForNs(nsKeys, existingTranslations, config, locale,
216
215
  }
217
216
  }
218
217
  }
219
- // Get the plural categories for the target language
218
+ // Get the plural categories for the target language (only used for filtering extracted keys)
220
219
  const targetLanguagePluralCategories = new Set();
221
220
  // Track cardinal plural categories separately so we can special-case single-"other" languages
222
221
  let cardinalCategories = [];
223
- let ordinalCategories = [];
224
222
  try {
225
223
  const cardinalRules = new Intl.PluralRules(locale, { type: 'cardinal' });
226
224
  const ordinalRules = new Intl.PluralRules(locale, { type: 'ordinal' });
227
225
  cardinalCategories = cardinalRules.resolvedOptions().pluralCategories;
228
- ordinalCategories = ordinalRules.resolvedOptions().pluralCategories;
229
226
  cardinalCategories.forEach(cat => targetLanguagePluralCategories.add(cat));
230
227
  ordinalRules.resolvedOptions().pluralCategories.forEach(cat => targetLanguagePluralCategories.add(`ordinal_${cat}`));
231
228
  }
@@ -235,7 +232,6 @@ function buildNewTranslationsForNs(nsKeys, existingTranslations, config, locale,
235
232
  const cardinalRules = new Intl.PluralRules(fallbackLang, { type: 'cardinal' });
236
233
  const ordinalRules = new Intl.PluralRules(fallbackLang, { type: 'ordinal' });
237
234
  cardinalCategories = cardinalRules.resolvedOptions().pluralCategories;
238
- ordinalCategories = ordinalRules.resolvedOptions().pluralCategories;
239
235
  cardinalCategories.forEach(cat => targetLanguagePluralCategories.add(cat));
240
236
  ordinalRules.resolvedOptions().pluralCategories.forEach(cat => targetLanguagePluralCategories.add(`ordinal_${cat}`));
241
237
  }
@@ -364,6 +360,49 @@ function buildNewTranslationsForNs(nsKeys, existingTranslations, config, locale,
364
360
  setNestedValue(newTranslations, existingKey, value, keySeparator ?? '.');
365
361
  }
366
362
  }
363
+ // PRESERVE LOCALE-SPECIFIC PLURAL FORMS: When dealing with plural keys in non-primary locales,
364
+ // preserve any existing plural forms that are NOT being explicitly generated.
365
+ // This ensures that locale-specific forms (like _few, _many) added by translators are preserved.
366
+ if (locale !== primaryLanguage && removeUnusedKeys) {
367
+ const existingKeys = getNestedKeys(existingTranslations, keySeparator ?? '.');
368
+ for (const existingKey of existingKeys) {
369
+ // Check if this is a plural form variant (ends with _form)
370
+ let isPluralForm = false;
371
+ let baseKey = existingKey;
372
+ let foundForm = '';
373
+ for (const form of pluralForms) {
374
+ if (existingKey.endsWith(`${pluralSeparator}${form}`)) {
375
+ baseKey = existingKey.slice(0, -(pluralSeparator.length + form.length));
376
+ foundForm = form;
377
+ isPluralForm = true;
378
+ break;
379
+ }
380
+ }
381
+ if (isPluralForm && foundForm) {
382
+ // Check if the base key is in our filtered keys (meaning it's a plural key we're handling)
383
+ const isBaseInExtracted = filteredKeys.some(({ key }) => {
384
+ let extractedBase = key;
385
+ for (const form of pluralForms) {
386
+ if (extractedBase.endsWith(`${pluralSeparator}${form}`)) {
387
+ extractedBase = extractedBase.slice(0, -(pluralSeparator.length + form.length));
388
+ break;
389
+ }
390
+ }
391
+ return extractedBase === baseKey;
392
+ });
393
+ if (isBaseInExtracted) {
394
+ // This is a plural form for a key we're handling.
395
+ // Check if it's already in newTranslations (will be set by the normal flow)
396
+ const isAlreadySet = getNestedValue(newTranslations, existingKey, keySeparator ?? '.') !== undefined;
397
+ if (!isAlreadySet) {
398
+ // This plural form is NOT being generated by our code, so preserve it
399
+ const value = getNestedValue(existingTranslations, existingKey, keySeparator ?? '.');
400
+ setNestedValue(newTranslations, existingKey, value, keySeparator ?? '.');
401
+ }
402
+ }
403
+ }
404
+ }
405
+ }
367
406
  // SPECIAL HANDLING: Preserve existing _zero forms even if not in extracted keys
368
407
  // This ensures that optional _zero forms are not removed when they exist
369
408
  if (removeUnusedKeys) {
@@ -406,49 +445,104 @@ function buildNewTranslationsForNs(nsKeys, existingTranslations, config, locale,
406
445
  continue;
407
446
  }
408
447
  }
409
- // If this is a base plural key (no explicit suffix) and the locale is NOT the primary,
410
- // expand it into locale-specific plural variants (e.g. key_one, key_other).
411
- // Use the extracted defaultValue (fallback to base) for variant values.
448
+ // If this is a base plural key (no explicit suffix), expand it into locale-specific plural variants.
449
+ // For non-primary locales, we generate forms for that specific locale from CLDR.
450
+ // Additionally, we generate empty placeholders for ALL other CLDR forms not in the target locale
451
+ // (so translators can add them manually if needed).
412
452
  if (hasCount && !isExpandedPlural) {
413
453
  const parts = String(key).split(pluralSeparator);
414
454
  const isBaseKey = parts.length === 1;
415
- if (isBaseKey && locale !== primaryLanguage) {
455
+ if (isBaseKey) {
416
456
  // If explicit expanded variants exist, do not expand the base.
417
457
  const base = key;
418
458
  if (expandedBases.has(base)) ;
419
459
  else {
420
- // choose categories based on ordinal flag
421
- const categories = isOrdinal ? ordinalCategories : cardinalCategories;
422
- for (const category of categories) {
423
- const finalKey = isOrdinal
424
- ? `${base}${pluralSeparator}ordinal${pluralSeparator}${category}`
425
- : `${base}${pluralSeparator}${category}`;
426
- // If the key looks like a serialized Trans component (starts with <), treat it as a flat key
427
- // to prevent splitting on dots that appear within the content.
428
- const separator = finalKey.startsWith('<') ? false : (keySeparator ?? '.');
429
- // Preserve existing translation if present; otherwise set a sensible default
430
- const existingVariantValue = getNestedValue(existingTranslations, finalKey, separator);
431
- if (existingVariantValue === undefined) {
432
- // Prefer explicit defaultValue extracted for this key; fall back to configured defaultValue
433
- // (resolved via resolveDefaultValue which handles functions or strings and accepts the full parameter set).
434
- let resolvedValue;
435
- if (typeof defaultValue === 'string') {
436
- resolvedValue = defaultValue;
460
+ // Determine which plural forms to generate
461
+ let formsToGenerate;
462
+ if (locale !== primaryLanguage) {
463
+ // For non-primary locales:
464
+ // 1. Generate the forms that locale actually needs
465
+ formsToGenerate = cardinalCategories;
466
+ // 2. Also prepare empty placeholders for all OTHER CLDR forms not in this locale
467
+ // so translators can add them manually without --sync-primary removing them
468
+ const otherForms = pluralForms.filter(f => !cardinalCategories.includes(f));
469
+ // Process the locale-specific forms normally
470
+ for (const form of formsToGenerate) {
471
+ const finalKey = isOrdinal
472
+ ? `${base}${pluralSeparator}${form}`
473
+ : `${base}${pluralSeparator}${form}`;
474
+ const separator = finalKey.startsWith('<') ? false : (keySeparator ?? '.');
475
+ const existingVariantValue = getNestedValue(existingTranslations, finalKey, separator);
476
+ if (existingVariantValue === undefined) {
477
+ // Use the default value for secondary locale forms
478
+ let resolvedValue;
479
+ if (typeof defaultValue === 'string') {
480
+ resolvedValue = defaultValue;
481
+ }
482
+ else {
483
+ resolvedValue = resolveDefaultValue(emptyDefaultValue, String(base), namespace || config?.extract?.defaultNS || 'translation', locale, defaultValue);
484
+ }
485
+ setNestedValue(newTranslations, finalKey, resolvedValue, separator);
437
486
  }
438
487
  else {
439
- // Use resolveDefaultValue to compute a sensible default, providing namespace and locale context.
440
- resolvedValue = resolveDefaultValue(emptyDefaultValue, String(base), namespace || config?.extract?.defaultNS || 'translation', locale, defaultValue);
488
+ setNestedValue(newTranslations, finalKey, existingVariantValue, separator);
441
489
  }
442
- setNestedValue(newTranslations, finalKey, resolvedValue, separator);
490
+ }
491
+ // Now process other CLDR forms: set empty placeholders for forms this locale doesn't use
492
+ // but preserve any that were manually added by translators
493
+ for (const form of otherForms) {
494
+ const finalKey = isOrdinal
495
+ ? `${base}${pluralSeparator}${form}`
496
+ : `${base}${pluralSeparator}${form}`;
497
+ const separator = finalKey.startsWith('<') ? false : (keySeparator ?? '.');
498
+ const existingVariantValue = getNestedValue(existingTranslations, finalKey, separator);
499
+ if (existingVariantValue !== undefined) {
500
+ // Preserve manually-added forms
501
+ setNestedValue(newTranslations, finalKey, existingVariantValue, separator);
502
+ }
503
+ // Don't generate empty placeholders - only generate what the locale needs and preserve what's manual
504
+ }
505
+ }
506
+ else {
507
+ // For primary language, only expand if it has multiple plural forms
508
+ // Single-"other" languages (ja, zh, ko) should NOT expand the base key
509
+ if (cardinalCategories.length === 1 && cardinalCategories[0] === 'other') {
510
+ // Single-"other" language - don't expand, keep just the base key
511
+ formsToGenerate = [];
443
512
  }
444
513
  else {
445
- // Keep existing translation
446
- setNestedValue(newTranslations, finalKey, existingVariantValue, separator);
514
+ // Multi-form language - expand to its plural forms
515
+ formsToGenerate = cardinalCategories;
516
+ for (const form of formsToGenerate) {
517
+ const finalKey = isOrdinal
518
+ ? `${base}${pluralSeparator}${form}`
519
+ : `${base}${pluralSeparator}${form}`;
520
+ const separator = finalKey.startsWith('<') ? false : (keySeparator ?? '.');
521
+ const existingVariantValue = getNestedValue(existingTranslations, finalKey, separator);
522
+ if (existingVariantValue === undefined) {
523
+ // Prefer explicit defaultValue extracted for this key; fall back to configured defaultValue
524
+ let resolvedValue;
525
+ if (typeof defaultValue === 'string') {
526
+ resolvedValue = defaultValue;
527
+ }
528
+ else {
529
+ resolvedValue = resolveDefaultValue(emptyDefaultValue, String(base), namespace || config?.extract?.defaultNS || 'translation', locale, defaultValue);
530
+ }
531
+ setNestedValue(newTranslations, finalKey, resolvedValue, separator);
532
+ }
533
+ else {
534
+ setNestedValue(newTranslations, finalKey, existingVariantValue, separator);
535
+ }
536
+ }
447
537
  }
448
538
  }
539
+ if (formsToGenerate && formsToGenerate.length > 0) {
540
+ // We've handled expansion for this base key; skip the normal single-key handling.
541
+ continue;
542
+ }
543
+ // else: formsToGenerate is empty (single-"other" primary language)
544
+ // Fall through to normal key handling below
449
545
  }
450
- // We've expanded variants for this base key; skip the normal single-key handling.
451
- continue;
452
546
  }
453
547
  }
454
548
  // If the key looks like a serialized Trans component (starts with <), treat it as a flat key
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "i18next-cli",
3
- "version": "1.41.1",
3
+ "version": "1.41.3",
4
4
  "description": "A unified, high-performance i18next CLI.",
5
5
  "type": "module",
6
6
  "bin": {
@@ -55,8 +55,8 @@
55
55
  "@rollup/plugin-replace": "6.0.3",
56
56
  "@rollup/plugin-terser": "0.4.4",
57
57
  "@types/inquirer": "9.0.9",
58
- "@types/node": "25.0.10",
59
- "@types/react": "19.2.9",
58
+ "@types/node": "25.2.0",
59
+ "@types/react": "19.2.10",
60
60
  "@vitest/coverage-v8": "4.0.18",
61
61
  "eslint": "9.39.2",
62
62
  "eslint-plugin-import": "2.32.0",
@@ -70,20 +70,20 @@
70
70
  },
71
71
  "dependencies": {
72
72
  "@croct/json5-parser": "0.2.2",
73
- "@swc/core": "1.15.10",
73
+ "@swc/core": "1.15.11",
74
74
  "chalk": "5.6.2",
75
75
  "chokidar": "5.0.0",
76
- "commander": "14.0.2",
76
+ "commander": "14.0.3",
77
77
  "execa": "9.6.1",
78
78
  "glob": "13.0.0",
79
79
  "i18next-resources-for-ts": "2.0.0",
80
- "inquirer": "13.2.1",
80
+ "inquirer": "13.2.2",
81
81
  "jiti": "2.6.1",
82
82
  "jsonc-parser": "3.3.1",
83
83
  "minimatch": "10.1.1",
84
84
  "ora": "9.1.0",
85
- "react": "^19.2.3",
86
- "react-i18next": "^16.5.3",
85
+ "react": "^19.2.4",
86
+ "react-i18next": "^16.5.4",
87
87
  "swc-walk": "1.0.1"
88
88
  }
89
89
  }
@@ -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,aAAa,CAAA;AA0rBnF;;;;;;;;;;;;;;;;;;;;;;;;;;;;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,EAChB,GAAE;IACD,uBAAuB,CAAC,EAAE,OAAO,CAAC;IAClC,OAAO,CAAC,EAAE,OAAO,CAAA;CACb,GACL,OAAO,CAAC,iBAAiB,EAAE,CAAC,CA0I9B"}
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,aAAa,CAAA;AAgyBnF;;;;;;;;;;;;;;;;;;;;;;;;;;;;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,EAChB,GAAE;IACD,uBAAuB,CAAC,EAAE,OAAO,CAAC;IAClC,OAAO,CAAC,EAAE,OAAO,CAAA;CACb,GACL,OAAO,CAAC,iBAAiB,EAAE,CAAC,CA0I9B"}