i18next-cli 1.49.5 → 1.49.7

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
@@ -31,7 +31,7 @@ const program = new commander.Command();
31
31
  program
32
32
  .name('i18next-cli')
33
33
  .description('A unified, high-performance i18next CLI.')
34
- .version('1.49.5'); // This string is replaced with the actual version at build time by rollup
34
+ .version('1.49.7'); // 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
@@ -79,18 +79,111 @@ async function runSyncer(config, options = {}) {
79
79
  continue;
80
80
  }
81
81
  const primaryKeys = nestedObject.getNestedKeys(primaryTranslations, keySeparator ?? '.');
82
+ const primaryKeySet = new Set(primaryKeys);
82
83
  // 3. For each secondary language, sync the current namespace
83
84
  for (const lang of secondaryLanguages) {
84
85
  const secondaryPath = fileUtils.getOutputPath(output, lang, ns);
85
86
  const fullSecondaryPath = node_path.resolve(process.cwd(), secondaryPath);
86
87
  const existingSecondaryTranslations = await fileUtils.loadTranslationFile(fullSecondaryPath) || {};
87
88
  const newSecondaryTranslations = {};
89
+ // Determine CLDR plural categories for this specific secondary locale so
90
+ // we can recognise locale-specific plural suffixes (e.g. `_many` for fr/es)
91
+ // that are not present in the primary language and must not be discarded.
92
+ const sep = config.extract.pluralSeparator ?? '_';
93
+ const localeCardinalCategories = (() => {
94
+ try {
95
+ return new Set(new Intl.PluralRules(lang, { type: 'cardinal' }).resolvedOptions().pluralCategories);
96
+ }
97
+ catch {
98
+ return new Set(['one', 'other']);
99
+ }
100
+ })();
101
+ const localeOrdinalCategories = (() => {
102
+ try {
103
+ return new Set(new Intl.PluralRules(lang, { type: 'ordinal' }).resolvedOptions().pluralCategories);
104
+ }
105
+ catch {
106
+ return new Set(['one', 'other', 'two', 'few']);
107
+ }
108
+ })();
109
+ /**
110
+ * Returns true when `key` is a plural variant that is:
111
+ * 1. Valid for this locale's CLDR rules (cardinal or ordinal), AND
112
+ * 2. Derived from a base key that exists in the primary locale.
113
+ *
114
+ * This handles both cardinal (`title_many`) and ordinal
115
+ * (`place_ordinal_few`) suffixes so neither gets erased during sync.
116
+ */
117
+ const isLocaleSpecificPluralExtension = (key) => {
118
+ // Cardinal: key ends with `{sep}{category}` and base key is in primary
119
+ for (const cat of localeCardinalCategories) {
120
+ const suffix = `${sep}${cat}`;
121
+ if (key.endsWith(suffix)) {
122
+ const base = key.slice(0, -suffix.length);
123
+ // The base itself, or any primary key that starts with `{base}{sep}`,
124
+ // confirms this is a plural family rooted in the primary locale.
125
+ if (primaryKeySet.has(base) || primaryKeys.some(pk => pk.startsWith(`${base}${sep}`))) {
126
+ return true;
127
+ }
128
+ }
129
+ }
130
+ // Ordinal: key ends with `{sep}ordinal{sep}{category}`
131
+ for (const cat of localeOrdinalCategories) {
132
+ const suffix = `${sep}ordinal${sep}${cat}`;
133
+ if (key.endsWith(suffix)) {
134
+ const base = key.slice(0, -suffix.length);
135
+ if (primaryKeySet.has(base) || primaryKeys.some(pk => pk.startsWith(`${base}${sep}`))) {
136
+ return true;
137
+ }
138
+ }
139
+ }
140
+ return false;
141
+ };
142
+ // Build newSecondaryTranslations in a single, order-preserving pass so
143
+ // that the syncer and the extractor produce byte-identical files after
144
+ // the first extract run (issue #216).
145
+ //
146
+ // Strategy:
147
+ // 1. Walk every key in the *existing* secondary file in its current
148
+ // order. Keep it if it belongs to the primary key set, or if it is
149
+ // a valid locale-specific plural extension with a non-empty value.
150
+ // Obsolete keys (neither primary nor a locale extension) are dropped.
151
+ // 2. Append any primary keys that are genuinely new (not present in the
152
+ // existing secondary file at all) so they get picked up on first sync.
153
+ //
154
+ // This means that once `extract` has written the secondary file in its
155
+ // canonical order, a subsequent `sync` will read that order and reproduce
156
+ // it exactly — the pipeline becomes idempotent.
157
+ const existingSecondaryKeys = nestedObject.getNestedKeys(existingSecondaryTranslations, keySeparator ?? '.');
158
+ const handledKeys = new Set();
159
+ // Pass 1: existing keys in their current order (preserves extract's ordering)
160
+ for (const key of existingSecondaryKeys) {
161
+ if (primaryKeySet.has(key)) {
162
+ const primaryValue = nestedObject.getNestedValue(primaryTranslations, key, keySeparator ?? '.');
163
+ const existingValue = nestedObject.getNestedValue(existingSecondaryTranslations, key, keySeparator ?? '.');
164
+ const valueToSet = existingValue ?? defaultValue.resolveDefaultValue(defaultValue$1, key, ns, lang, primaryValue);
165
+ nestedObject.setNestedValue(newSecondaryTranslations, key, valueToSet, keySeparator ?? '.');
166
+ handledKeys.add(key);
167
+ }
168
+ else if (isLocaleSpecificPluralExtension(key)) {
169
+ const existingValue = nestedObject.getNestedValue(existingSecondaryTranslations, key, keySeparator ?? '.');
170
+ // Only preserve non-empty values; an empty string was likely a
171
+ // placeholder left by a previous (buggy) sync run and should not
172
+ // be perpetuated.
173
+ if (existingValue !== '' && existingValue != null) {
174
+ nestedObject.setNestedValue(newSecondaryTranslations, key, existingValue, keySeparator ?? '.');
175
+ handledKeys.add(key);
176
+ }
177
+ }
178
+ // else: obsolete key — omit it from the output
179
+ }
180
+ // Pass 2: new primary keys not yet in the secondary file
88
181
  for (const key of primaryKeys) {
89
- const primaryValue = nestedObject.getNestedValue(primaryTranslations, key, keySeparator ?? '.');
90
- const existingValue = nestedObject.getNestedValue(existingSecondaryTranslations, key, keySeparator ?? '.');
91
- // Use the resolved default value if no existing value
92
- const valueToSet = existingValue ?? defaultValue.resolveDefaultValue(defaultValue$1, key, ns, lang, primaryValue);
93
- nestedObject.setNestedValue(newSecondaryTranslations, key, valueToSet, keySeparator ?? '.');
182
+ if (!handledKeys.has(key)) {
183
+ const primaryValue = nestedObject.getNestedValue(primaryTranslations, key, keySeparator ?? '.');
184
+ const valueToSet = defaultValue.resolveDefaultValue(defaultValue$1, key, ns, lang, primaryValue);
185
+ nestedObject.setNestedValue(newSecondaryTranslations, key, valueToSet, keySeparator ?? '.');
186
+ }
94
187
  }
95
188
  // Use JSON.stringify for a reliable object comparison, regardless of format
96
189
  const oldContent = JSON.stringify(existingSecondaryTranslations);
package/dist/esm/cli.js CHANGED
@@ -29,7 +29,7 @@ const program = new Command();
29
29
  program
30
30
  .name('i18next-cli')
31
31
  .description('A unified, high-performance i18next CLI.')
32
- .version('1.49.5'); // This string is replaced with the actual version at build time by rollup
32
+ .version('1.49.7'); // This string is replaced with the actual version at build time by rollup
33
33
  // new: global config override option
34
34
  program.option('-c, --config <path>', 'Path to i18next-cli config file (overrides detection)');
35
35
  program
@@ -77,18 +77,111 @@ async function runSyncer(config, options = {}) {
77
77
  continue;
78
78
  }
79
79
  const primaryKeys = getNestedKeys(primaryTranslations, keySeparator ?? '.');
80
+ const primaryKeySet = new Set(primaryKeys);
80
81
  // 3. For each secondary language, sync the current namespace
81
82
  for (const lang of secondaryLanguages) {
82
83
  const secondaryPath = getOutputPath(output, lang, ns);
83
84
  const fullSecondaryPath = resolve(process.cwd(), secondaryPath);
84
85
  const existingSecondaryTranslations = await loadTranslationFile(fullSecondaryPath) || {};
85
86
  const newSecondaryTranslations = {};
87
+ // Determine CLDR plural categories for this specific secondary locale so
88
+ // we can recognise locale-specific plural suffixes (e.g. `_many` for fr/es)
89
+ // that are not present in the primary language and must not be discarded.
90
+ const sep = config.extract.pluralSeparator ?? '_';
91
+ const localeCardinalCategories = (() => {
92
+ try {
93
+ return new Set(new Intl.PluralRules(lang, { type: 'cardinal' }).resolvedOptions().pluralCategories);
94
+ }
95
+ catch {
96
+ return new Set(['one', 'other']);
97
+ }
98
+ })();
99
+ const localeOrdinalCategories = (() => {
100
+ try {
101
+ return new Set(new Intl.PluralRules(lang, { type: 'ordinal' }).resolvedOptions().pluralCategories);
102
+ }
103
+ catch {
104
+ return new Set(['one', 'other', 'two', 'few']);
105
+ }
106
+ })();
107
+ /**
108
+ * Returns true when `key` is a plural variant that is:
109
+ * 1. Valid for this locale's CLDR rules (cardinal or ordinal), AND
110
+ * 2. Derived from a base key that exists in the primary locale.
111
+ *
112
+ * This handles both cardinal (`title_many`) and ordinal
113
+ * (`place_ordinal_few`) suffixes so neither gets erased during sync.
114
+ */
115
+ const isLocaleSpecificPluralExtension = (key) => {
116
+ // Cardinal: key ends with `{sep}{category}` and base key is in primary
117
+ for (const cat of localeCardinalCategories) {
118
+ const suffix = `${sep}${cat}`;
119
+ if (key.endsWith(suffix)) {
120
+ const base = key.slice(0, -suffix.length);
121
+ // The base itself, or any primary key that starts with `{base}{sep}`,
122
+ // confirms this is a plural family rooted in the primary locale.
123
+ if (primaryKeySet.has(base) || primaryKeys.some(pk => pk.startsWith(`${base}${sep}`))) {
124
+ return true;
125
+ }
126
+ }
127
+ }
128
+ // Ordinal: key ends with `{sep}ordinal{sep}{category}`
129
+ for (const cat of localeOrdinalCategories) {
130
+ const suffix = `${sep}ordinal${sep}${cat}`;
131
+ if (key.endsWith(suffix)) {
132
+ const base = key.slice(0, -suffix.length);
133
+ if (primaryKeySet.has(base) || primaryKeys.some(pk => pk.startsWith(`${base}${sep}`))) {
134
+ return true;
135
+ }
136
+ }
137
+ }
138
+ return false;
139
+ };
140
+ // Build newSecondaryTranslations in a single, order-preserving pass so
141
+ // that the syncer and the extractor produce byte-identical files after
142
+ // the first extract run (issue #216).
143
+ //
144
+ // Strategy:
145
+ // 1. Walk every key in the *existing* secondary file in its current
146
+ // order. Keep it if it belongs to the primary key set, or if it is
147
+ // a valid locale-specific plural extension with a non-empty value.
148
+ // Obsolete keys (neither primary nor a locale extension) are dropped.
149
+ // 2. Append any primary keys that are genuinely new (not present in the
150
+ // existing secondary file at all) so they get picked up on first sync.
151
+ //
152
+ // This means that once `extract` has written the secondary file in its
153
+ // canonical order, a subsequent `sync` will read that order and reproduce
154
+ // it exactly — the pipeline becomes idempotent.
155
+ const existingSecondaryKeys = getNestedKeys(existingSecondaryTranslations, keySeparator ?? '.');
156
+ const handledKeys = new Set();
157
+ // Pass 1: existing keys in their current order (preserves extract's ordering)
158
+ for (const key of existingSecondaryKeys) {
159
+ if (primaryKeySet.has(key)) {
160
+ const primaryValue = getNestedValue(primaryTranslations, key, keySeparator ?? '.');
161
+ const existingValue = getNestedValue(existingSecondaryTranslations, key, keySeparator ?? '.');
162
+ const valueToSet = existingValue ?? resolveDefaultValue(defaultValue, key, ns, lang, primaryValue);
163
+ setNestedValue(newSecondaryTranslations, key, valueToSet, keySeparator ?? '.');
164
+ handledKeys.add(key);
165
+ }
166
+ else if (isLocaleSpecificPluralExtension(key)) {
167
+ const existingValue = getNestedValue(existingSecondaryTranslations, key, keySeparator ?? '.');
168
+ // Only preserve non-empty values; an empty string was likely a
169
+ // placeholder left by a previous (buggy) sync run and should not
170
+ // be perpetuated.
171
+ if (existingValue !== '' && existingValue != null) {
172
+ setNestedValue(newSecondaryTranslations, key, existingValue, keySeparator ?? '.');
173
+ handledKeys.add(key);
174
+ }
175
+ }
176
+ // else: obsolete key — omit it from the output
177
+ }
178
+ // Pass 2: new primary keys not yet in the secondary file
86
179
  for (const key of primaryKeys) {
87
- const primaryValue = getNestedValue(primaryTranslations, key, keySeparator ?? '.');
88
- const existingValue = getNestedValue(existingSecondaryTranslations, key, keySeparator ?? '.');
89
- // Use the resolved default value if no existing value
90
- const valueToSet = existingValue ?? resolveDefaultValue(defaultValue, key, ns, lang, primaryValue);
91
- setNestedValue(newSecondaryTranslations, key, valueToSet, keySeparator ?? '.');
180
+ if (!handledKeys.has(key)) {
181
+ const primaryValue = getNestedValue(primaryTranslations, key, keySeparator ?? '.');
182
+ const valueToSet = resolveDefaultValue(defaultValue, key, ns, lang, primaryValue);
183
+ setNestedValue(newSecondaryTranslations, key, valueToSet, keySeparator ?? '.');
184
+ }
92
185
  }
93
186
  // Use JSON.stringify for a reliable object comparison, regardless of format
94
187
  const oldContent = JSON.stringify(existingSecondaryTranslations);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "i18next-cli",
3
- "version": "1.49.5",
3
+ "version": "1.49.7",
4
4
  "description": "A unified, high-performance i18next CLI.",
5
5
  "type": "module",
6
6
  "bin": {
@@ -1 +1 @@
1
- {"version":3,"file":"syncer.d.ts","sourceRoot":"","sources":["../src/syncer.ts"],"names":[],"mappings":"AAMA,OAAO,KAAK,EAAE,oBAAoB,EAAE,MAAM,EAAE,MAAM,YAAY,CAAA;AAM9D;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GA6BG;AACH,wBAAsB,SAAS,CAC7B,MAAM,EAAE,oBAAoB,EAC5B,OAAO,GAAE;IAAE,KAAK,CAAC,EAAE,OAAO,CAAC;IAAC,MAAM,CAAC,EAAE,MAAM,CAAA;CAAO,iBAkGnD"}
1
+ {"version":3,"file":"syncer.d.ts","sourceRoot":"","sources":["../src/syncer.ts"],"names":[],"mappings":"AAMA,OAAO,KAAK,EAAE,oBAAoB,EAAE,MAAM,EAAE,MAAM,YAAY,CAAA;AAM9D;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GA6BG;AACH,wBAAsB,SAAS,CAC7B,MAAM,EAAE,oBAAoB,EAC5B,OAAO,GAAE;IAAE,KAAK,CAAC,EAAE,OAAO,CAAC;IAAC,MAAM,CAAC,EAAE,MAAM,CAAA;CAAO,iBA+LnD"}