i18next-cli 1.50.2 → 1.50.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
@@ -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.50.2'); // This string is replaced with the actual version at build time by rollup
34
+ .version('1.50.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
@@ -12,6 +12,13 @@ var fileUtils = require('./utils/file-utils.js');
12
12
  var funnelMsgTracker = require('./utils/funnel-msg-tracker.js');
13
13
  require('./extractor/parsers/jsx-parser.js');
14
14
 
15
+ function classifyValue(value) {
16
+ if (value === undefined || value === null)
17
+ return 'absent';
18
+ if (value === '')
19
+ return 'empty';
20
+ return 'translated';
21
+ }
15
22
  /**
16
23
  * Runs a health check on the project's i18next translations and displays a status report.
17
24
  *
@@ -22,6 +29,11 @@ require('./extractor/parsers/jsx-parser.js');
22
29
  * 4. Displaying a formatted report with key counts, locales, and progress bars.
23
30
  * 5. Serving as a value-driven funnel to introduce the locize commercial service.
24
31
  *
32
+ * Exit behaviour (unchanged): exits 1 when any key is either empty or absent.
33
+ * The output now distinguishes between the two states so developers can tell
34
+ * whether they have a structural problem (absent) or simply pending translation
35
+ * work (empty).
36
+ *
25
37
  * @param config - The i18next toolkit configuration object.
26
38
  * @param options - Options object, may contain a `detail` property with a locale string.
27
39
  * @throws {Error} When unable to extract keys or read translation files
@@ -42,7 +54,7 @@ async function runStatus(config, options = {}) {
42
54
  }
43
55
  }
44
56
  if (hasMissing) {
45
- spinner.fail('Error: Missing translations detected.');
57
+ spinner.fail('Error: Incomplete translations detected.');
46
58
  process.exit(1);
47
59
  }
48
60
  }
@@ -94,6 +106,8 @@ async function generateStatusReport(config) {
94
106
  };
95
107
  for (const locale of secondaryLanguages) {
96
108
  let totalTranslatedForLocale = 0;
109
+ let totalEmptyForLocale = 0;
110
+ let totalAbsentForLocale = 0;
97
111
  let totalKeysForLocale = 0;
98
112
  const namespaces = new Map();
99
113
  const mergedTranslations = mergeNamespaces
@@ -120,6 +134,8 @@ async function generateStatusReport(config) {
120
134
  }
121
135
  }
122
136
  let translatedInNs = 0;
137
+ let emptyInNs = 0;
138
+ let absentInNs = 0;
123
139
  let totalInNs = 0;
124
140
  const keyDetails = [];
125
141
  // Get the plural categories for THIS specific locale
@@ -135,6 +151,26 @@ async function generateStatusReport(config) {
135
151
  return fallbackRules.resolvedOptions().pluralCategories;
136
152
  }
137
153
  };
154
+ /**
155
+ * Resolves the value for a single key, applying the fallback namespace when
156
+ * configured, and classifies it as translated / empty / absent.
157
+ *
158
+ * The fallback is only consulted when the primary value is absent — an empty
159
+ * string is a deliberate placeholder written by `extract` and should not be
160
+ * silently replaced by a fallback value.
161
+ */
162
+ const resolveAndClassify = (key) => {
163
+ const sep = keySeparator ?? '.';
164
+ const primaryValue = nestedObject.getNestedValue(translationsForNs, key, sep);
165
+ const primaryState = classifyValue(primaryValue);
166
+ // Only fall back when the key is genuinely absent from the primary file.
167
+ // An empty string is intentional (placeholder from extract) — don't hide it.
168
+ if (primaryState === 'absent' && fallbackTranslations) {
169
+ const fallbackValue = nestedObject.getNestedValue(fallbackTranslations, key, sep);
170
+ return classifyValue(fallbackValue);
171
+ }
172
+ return primaryState;
173
+ };
138
174
  for (const { key: baseKey, hasCount, isOrdinal, isExpandedPlural } of keysInNs) {
139
175
  if (hasCount) {
140
176
  if (isExpandedPlural) {
@@ -150,15 +186,14 @@ async function generateStatusReport(config) {
150
186
  // Only count this key if it's a plural form used by this locale
151
187
  if (localePluralCategories.includes(category)) {
152
188
  totalInNs++;
153
- let value = nestedObject.getNestedValue(translationsForNs, baseKey, keySeparator ?? '.');
154
- // Fallback lookup
155
- if (!value && fallbackTranslations) {
156
- value = nestedObject.getNestedValue(fallbackTranslations, baseKey, keySeparator ?? '.');
157
- }
158
- const isTranslated = !!value;
159
- if (isTranslated)
189
+ const state = resolveAndClassify(baseKey);
190
+ if (state === 'translated')
160
191
  translatedInNs++;
161
- keyDetails.push({ key: baseKey, isTranslated });
192
+ else if (state === 'empty')
193
+ emptyInNs++;
194
+ else
195
+ absentInNs++;
196
+ keyDetails.push({ key: baseKey, state });
162
197
  }
163
198
  }
164
199
  else {
@@ -170,40 +205,57 @@ async function generateStatusReport(config) {
170
205
  const pluralKey = isOrdinal
171
206
  ? `${baseKey}${pluralSeparator}ordinal${pluralSeparator}${category}`
172
207
  : `${baseKey}${pluralSeparator}${category}`;
173
- let value = nestedObject.getNestedValue(translationsForNs, pluralKey, keySeparator ?? '.');
174
- // Fallback lookup
175
- if (!value && fallbackTranslations) {
176
- value = nestedObject.getNestedValue(fallbackTranslations, pluralKey, keySeparator ?? '.');
177
- }
178
- const isTranslated = !!value;
179
- if (isTranslated)
208
+ const state = resolveAndClassify(pluralKey);
209
+ if (state === 'translated')
180
210
  translatedInNs++;
181
- keyDetails.push({ key: pluralKey, isTranslated });
211
+ else if (state === 'empty')
212
+ emptyInNs++;
213
+ else
214
+ absentInNs++;
215
+ keyDetails.push({ key: pluralKey, state });
182
216
  }
183
217
  }
184
218
  }
185
219
  else {
186
- // It's a simple key
187
220
  totalInNs++;
188
- let value = nestedObject.getNestedValue(translationsForNs, baseKey, keySeparator ?? '.');
189
- // Fallback lookup
190
- if (!value && fallbackTranslations) {
191
- value = nestedObject.getNestedValue(fallbackTranslations, baseKey, keySeparator ?? '.');
192
- }
193
- const isTranslated = !!value;
194
- if (isTranslated)
221
+ const state = resolveAndClassify(baseKey);
222
+ if (state === 'translated')
195
223
  translatedInNs++;
196
- keyDetails.push({ key: baseKey, isTranslated });
224
+ else if (state === 'empty')
225
+ emptyInNs++;
226
+ else
227
+ absentInNs++;
228
+ keyDetails.push({ key: baseKey, state });
197
229
  }
198
230
  }
199
- namespaces.set(ns, { totalKeys: totalInNs, translatedKeys: translatedInNs, keyDetails });
231
+ namespaces.set(ns, { totalKeys: totalInNs, translatedKeys: translatedInNs, emptyKeys: emptyInNs, absentKeys: absentInNs, keyDetails });
200
232
  totalTranslatedForLocale += translatedInNs;
233
+ totalEmptyForLocale += emptyInNs;
234
+ totalAbsentForLocale += absentInNs;
201
235
  totalKeysForLocale += totalInNs;
202
236
  }
203
- report.locales.set(locale, { totalKeys: totalKeysForLocale, totalTranslated: totalTranslatedForLocale, namespaces });
237
+ report.locales.set(locale, {
238
+ totalKeys: totalKeysForLocale,
239
+ totalTranslated: totalTranslatedForLocale,
240
+ totalEmpty: totalEmptyForLocale,
241
+ totalAbsent: totalAbsentForLocale,
242
+ namespaces,
243
+ });
204
244
  }
205
245
  return report;
206
246
  }
247
+ /**
248
+ * Builds a compact breakdown string like "3 untranslated, 2 absent" for use in
249
+ * summary lines. Returns an empty string when there is nothing to report.
250
+ */
251
+ function buildBreakdown(emptyCount, absentCount) {
252
+ const parts = [];
253
+ if (emptyCount > 0)
254
+ parts.push(node_util.styleText('yellow', `${emptyCount} untranslated`));
255
+ if (absentCount > 0)
256
+ parts.push(node_util.styleText('red', `${absentCount} absent`));
257
+ return parts.join(', ');
258
+ }
207
259
  /**
208
260
  * Main display router that calls the appropriate display function based on options.
209
261
  *
@@ -230,17 +282,10 @@ async function displayStatusReport(report, config, options) {
230
282
  /**
231
283
  * Displays the detailed, grouped report for a single locale.
232
284
  *
233
- * Shows:
234
- * - Overall progress for the locale
235
- * - Progress for each namespace (or filtered namespace)
236
- * - Individual key status (translated/missing) with visual indicators
237
- * - Summary message with total missing translations
238
- *
239
- * @param report - The generated status report data
240
- * @param config - The i18next toolkit configuration object
241
- * @param locale - The locale code to display details for
242
- * @param namespaceFilter - Optional namespace to filter the display
243
- * @param hideTranslated - When true, only untranslated keys are shown
285
+ * Key status icons:
286
+ * ✓ green — translated
287
+ * ~ yellow present in file but empty (needs translation)
288
+ * ✗ red — absent from file entirely (structural problem)
244
289
  */
245
290
  async function displayDetailedLocaleReport(report, config, locale, namespaceFilter, hideTranslated) {
246
291
  if (locale === config.extract.primaryLanguage) {
@@ -259,6 +304,9 @@ async function displayDetailedLocaleReport(report, config, locale, namespaceFilt
259
304
  console.log(node_util.styleText('bold', `\nKey Status for "${node_util.styleText('cyan', locale)}":`));
260
305
  const totalKeysForLocale = localeData.totalKeys;
261
306
  printProgressBar('Overall', localeData.totalTranslated, totalKeysForLocale);
307
+ const breakdown = buildBreakdown(localeData.totalEmpty, localeData.totalAbsent);
308
+ if (breakdown)
309
+ console.log(` ${breakdown}`);
262
310
  const namespacesToDisplay = namespaceFilter ? [namespaceFilter] : Array.from(localeData.namespaces.keys()).sort();
263
311
  for (const ns of namespacesToDisplay) {
264
312
  const nsData = localeData.namespaces.get(ns);
@@ -266,15 +314,28 @@ async function displayDetailedLocaleReport(report, config, locale, namespaceFilt
266
314
  continue;
267
315
  console.log(node_util.styleText(['cyan', 'bold'], `\nNamespace: ${ns}`));
268
316
  printProgressBar('Namespace Progress', nsData.translatedKeys, nsData.totalKeys);
269
- const keysToDisplay = hideTranslated ? nsData.keyDetails.filter(({ isTranslated }) => !isTranslated) : nsData.keyDetails;
270
- keysToDisplay.forEach(({ key, isTranslated }) => {
271
- const icon = isTranslated ? node_util.styleText('green', '✓') : node_util.styleText('red', '✗');
272
- console.log(` ${icon} ${key}`);
317
+ const nsBreakdown = buildBreakdown(nsData.emptyKeys, nsData.absentKeys);
318
+ if (nsBreakdown)
319
+ console.log(` ${nsBreakdown}`);
320
+ const keysToDisplay = hideTranslated
321
+ ? nsData.keyDetails.filter(({ state }) => state !== 'translated')
322
+ : nsData.keyDetails;
323
+ keysToDisplay.forEach(({ key, state }) => {
324
+ if (state === 'translated') {
325
+ console.log(` ${node_util.styleText('green', '✓')} ${key}`);
326
+ }
327
+ else if (state === 'empty') {
328
+ console.log(` ${node_util.styleText('yellow', '~')} ${key} ${node_util.styleText('yellow', '(untranslated)')}`);
329
+ }
330
+ else {
331
+ console.log(` ${node_util.styleText('red', '✗')} ${key} ${node_util.styleText('red', '(absent)')}`);
332
+ }
273
333
  });
274
334
  }
275
335
  const missingCount = totalKeysForLocale - localeData.totalTranslated;
276
336
  if (missingCount > 0) {
277
- console.log(node_util.styleText(['yellow', 'bold'], `\nSummary: Found ${missingCount} missing translations for "${locale}".`));
337
+ const summaryBreakdown = buildBreakdown(localeData.totalEmpty, localeData.totalAbsent);
338
+ console.log(node_util.styleText(['yellow', 'bold'], `\nSummary: Found ${missingCount} incomplete translations for "${locale}" — ${summaryBreakdown}.`));
278
339
  }
279
340
  else {
280
341
  console.log(node_util.styleText(['green', 'bold'], `\nSummary: 🎉 All keys are translated for "${locale}".`));
@@ -304,7 +365,9 @@ async function displayNamespaceSummaryReport(report, config, namespace) {
304
365
  if (nsLocaleData) {
305
366
  const percentage = nsLocaleData.totalKeys > 0 ? Math.round((nsLocaleData.translatedKeys / nsLocaleData.totalKeys) * 100) : 100;
306
367
  const bar = generateProgressBarText(percentage);
307
- console.log(`- ${locale}: ${bar} ${percentage}% (${nsLocaleData.translatedKeys}/${nsLocaleData.totalKeys} keys)`);
368
+ const breakdown = buildBreakdown(nsLocaleData.emptyKeys, nsLocaleData.absentKeys);
369
+ const suffix = breakdown ? ` — ${breakdown}` : '';
370
+ console.log(`- ${locale}: ${bar} ${percentage}% (${nsLocaleData.translatedKeys}/${nsLocaleData.totalKeys} keys)${suffix}`);
308
371
  }
309
372
  }
310
373
  await printLocizeFunnel();
@@ -333,7 +396,9 @@ async function displayOverallSummaryReport(report, config) {
333
396
  for (const [locale, localeData] of report.locales.entries()) {
334
397
  const percentage = localeData.totalKeys > 0 ? Math.round((localeData.totalTranslated / localeData.totalKeys) * 100) : 100;
335
398
  const bar = generateProgressBarText(percentage);
336
- console.log(`- ${locale}: ${bar} ${percentage}% (${localeData.totalTranslated}/${localeData.totalKeys} keys)`);
399
+ const breakdown = buildBreakdown(localeData.totalEmpty, localeData.totalAbsent);
400
+ const suffix = breakdown ? ` — ${breakdown}` : '';
401
+ console.log(`- ${locale}: ${bar} ${percentage}% (${localeData.totalTranslated}/${localeData.totalKeys} keys)${suffix}`);
337
402
  }
338
403
  await printLocizeFunnel();
339
404
  }
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.50.2'); // This string is replaced with the actual version at build time by rollup
32
+ .version('1.50.3'); // 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
@@ -10,6 +10,13 @@ import { loadTranslationFile, getOutputPath } from './utils/file-utils.js';
10
10
  import { shouldShowFunnel, recordFunnelShown } from './utils/funnel-msg-tracker.js';
11
11
  import './extractor/parsers/jsx-parser.js';
12
12
 
13
+ function classifyValue(value) {
14
+ if (value === undefined || value === null)
15
+ return 'absent';
16
+ if (value === '')
17
+ return 'empty';
18
+ return 'translated';
19
+ }
13
20
  /**
14
21
  * Runs a health check on the project's i18next translations and displays a status report.
15
22
  *
@@ -20,6 +27,11 @@ import './extractor/parsers/jsx-parser.js';
20
27
  * 4. Displaying a formatted report with key counts, locales, and progress bars.
21
28
  * 5. Serving as a value-driven funnel to introduce the locize commercial service.
22
29
  *
30
+ * Exit behaviour (unchanged): exits 1 when any key is either empty or absent.
31
+ * The output now distinguishes between the two states so developers can tell
32
+ * whether they have a structural problem (absent) or simply pending translation
33
+ * work (empty).
34
+ *
23
35
  * @param config - The i18next toolkit configuration object.
24
36
  * @param options - Options object, may contain a `detail` property with a locale string.
25
37
  * @throws {Error} When unable to extract keys or read translation files
@@ -40,7 +52,7 @@ async function runStatus(config, options = {}) {
40
52
  }
41
53
  }
42
54
  if (hasMissing) {
43
- spinner.fail('Error: Missing translations detected.');
55
+ spinner.fail('Error: Incomplete translations detected.');
44
56
  process.exit(1);
45
57
  }
46
58
  }
@@ -92,6 +104,8 @@ async function generateStatusReport(config) {
92
104
  };
93
105
  for (const locale of secondaryLanguages) {
94
106
  let totalTranslatedForLocale = 0;
107
+ let totalEmptyForLocale = 0;
108
+ let totalAbsentForLocale = 0;
95
109
  let totalKeysForLocale = 0;
96
110
  const namespaces = new Map();
97
111
  const mergedTranslations = mergeNamespaces
@@ -118,6 +132,8 @@ async function generateStatusReport(config) {
118
132
  }
119
133
  }
120
134
  let translatedInNs = 0;
135
+ let emptyInNs = 0;
136
+ let absentInNs = 0;
121
137
  let totalInNs = 0;
122
138
  const keyDetails = [];
123
139
  // Get the plural categories for THIS specific locale
@@ -133,6 +149,26 @@ async function generateStatusReport(config) {
133
149
  return fallbackRules.resolvedOptions().pluralCategories;
134
150
  }
135
151
  };
152
+ /**
153
+ * Resolves the value for a single key, applying the fallback namespace when
154
+ * configured, and classifies it as translated / empty / absent.
155
+ *
156
+ * The fallback is only consulted when the primary value is absent — an empty
157
+ * string is a deliberate placeholder written by `extract` and should not be
158
+ * silently replaced by a fallback value.
159
+ */
160
+ const resolveAndClassify = (key) => {
161
+ const sep = keySeparator ?? '.';
162
+ const primaryValue = getNestedValue(translationsForNs, key, sep);
163
+ const primaryState = classifyValue(primaryValue);
164
+ // Only fall back when the key is genuinely absent from the primary file.
165
+ // An empty string is intentional (placeholder from extract) — don't hide it.
166
+ if (primaryState === 'absent' && fallbackTranslations) {
167
+ const fallbackValue = getNestedValue(fallbackTranslations, key, sep);
168
+ return classifyValue(fallbackValue);
169
+ }
170
+ return primaryState;
171
+ };
136
172
  for (const { key: baseKey, hasCount, isOrdinal, isExpandedPlural } of keysInNs) {
137
173
  if (hasCount) {
138
174
  if (isExpandedPlural) {
@@ -148,15 +184,14 @@ async function generateStatusReport(config) {
148
184
  // Only count this key if it's a plural form used by this locale
149
185
  if (localePluralCategories.includes(category)) {
150
186
  totalInNs++;
151
- let value = getNestedValue(translationsForNs, baseKey, keySeparator ?? '.');
152
- // Fallback lookup
153
- if (!value && fallbackTranslations) {
154
- value = getNestedValue(fallbackTranslations, baseKey, keySeparator ?? '.');
155
- }
156
- const isTranslated = !!value;
157
- if (isTranslated)
187
+ const state = resolveAndClassify(baseKey);
188
+ if (state === 'translated')
158
189
  translatedInNs++;
159
- keyDetails.push({ key: baseKey, isTranslated });
190
+ else if (state === 'empty')
191
+ emptyInNs++;
192
+ else
193
+ absentInNs++;
194
+ keyDetails.push({ key: baseKey, state });
160
195
  }
161
196
  }
162
197
  else {
@@ -168,40 +203,57 @@ async function generateStatusReport(config) {
168
203
  const pluralKey = isOrdinal
169
204
  ? `${baseKey}${pluralSeparator}ordinal${pluralSeparator}${category}`
170
205
  : `${baseKey}${pluralSeparator}${category}`;
171
- let value = getNestedValue(translationsForNs, pluralKey, keySeparator ?? '.');
172
- // Fallback lookup
173
- if (!value && fallbackTranslations) {
174
- value = getNestedValue(fallbackTranslations, pluralKey, keySeparator ?? '.');
175
- }
176
- const isTranslated = !!value;
177
- if (isTranslated)
206
+ const state = resolveAndClassify(pluralKey);
207
+ if (state === 'translated')
178
208
  translatedInNs++;
179
- keyDetails.push({ key: pluralKey, isTranslated });
209
+ else if (state === 'empty')
210
+ emptyInNs++;
211
+ else
212
+ absentInNs++;
213
+ keyDetails.push({ key: pluralKey, state });
180
214
  }
181
215
  }
182
216
  }
183
217
  else {
184
- // It's a simple key
185
218
  totalInNs++;
186
- let value = getNestedValue(translationsForNs, baseKey, keySeparator ?? '.');
187
- // Fallback lookup
188
- if (!value && fallbackTranslations) {
189
- value = getNestedValue(fallbackTranslations, baseKey, keySeparator ?? '.');
190
- }
191
- const isTranslated = !!value;
192
- if (isTranslated)
219
+ const state = resolveAndClassify(baseKey);
220
+ if (state === 'translated')
193
221
  translatedInNs++;
194
- keyDetails.push({ key: baseKey, isTranslated });
222
+ else if (state === 'empty')
223
+ emptyInNs++;
224
+ else
225
+ absentInNs++;
226
+ keyDetails.push({ key: baseKey, state });
195
227
  }
196
228
  }
197
- namespaces.set(ns, { totalKeys: totalInNs, translatedKeys: translatedInNs, keyDetails });
229
+ namespaces.set(ns, { totalKeys: totalInNs, translatedKeys: translatedInNs, emptyKeys: emptyInNs, absentKeys: absentInNs, keyDetails });
198
230
  totalTranslatedForLocale += translatedInNs;
231
+ totalEmptyForLocale += emptyInNs;
232
+ totalAbsentForLocale += absentInNs;
199
233
  totalKeysForLocale += totalInNs;
200
234
  }
201
- report.locales.set(locale, { totalKeys: totalKeysForLocale, totalTranslated: totalTranslatedForLocale, namespaces });
235
+ report.locales.set(locale, {
236
+ totalKeys: totalKeysForLocale,
237
+ totalTranslated: totalTranslatedForLocale,
238
+ totalEmpty: totalEmptyForLocale,
239
+ totalAbsent: totalAbsentForLocale,
240
+ namespaces,
241
+ });
202
242
  }
203
243
  return report;
204
244
  }
245
+ /**
246
+ * Builds a compact breakdown string like "3 untranslated, 2 absent" for use in
247
+ * summary lines. Returns an empty string when there is nothing to report.
248
+ */
249
+ function buildBreakdown(emptyCount, absentCount) {
250
+ const parts = [];
251
+ if (emptyCount > 0)
252
+ parts.push(styleText('yellow', `${emptyCount} untranslated`));
253
+ if (absentCount > 0)
254
+ parts.push(styleText('red', `${absentCount} absent`));
255
+ return parts.join(', ');
256
+ }
205
257
  /**
206
258
  * Main display router that calls the appropriate display function based on options.
207
259
  *
@@ -228,17 +280,10 @@ async function displayStatusReport(report, config, options) {
228
280
  /**
229
281
  * Displays the detailed, grouped report for a single locale.
230
282
  *
231
- * Shows:
232
- * - Overall progress for the locale
233
- * - Progress for each namespace (or filtered namespace)
234
- * - Individual key status (translated/missing) with visual indicators
235
- * - Summary message with total missing translations
236
- *
237
- * @param report - The generated status report data
238
- * @param config - The i18next toolkit configuration object
239
- * @param locale - The locale code to display details for
240
- * @param namespaceFilter - Optional namespace to filter the display
241
- * @param hideTranslated - When true, only untranslated keys are shown
283
+ * Key status icons:
284
+ * ✓ green — translated
285
+ * ~ yellow present in file but empty (needs translation)
286
+ * ✗ red — absent from file entirely (structural problem)
242
287
  */
243
288
  async function displayDetailedLocaleReport(report, config, locale, namespaceFilter, hideTranslated) {
244
289
  if (locale === config.extract.primaryLanguage) {
@@ -257,6 +302,9 @@ async function displayDetailedLocaleReport(report, config, locale, namespaceFilt
257
302
  console.log(styleText('bold', `\nKey Status for "${styleText('cyan', locale)}":`));
258
303
  const totalKeysForLocale = localeData.totalKeys;
259
304
  printProgressBar('Overall', localeData.totalTranslated, totalKeysForLocale);
305
+ const breakdown = buildBreakdown(localeData.totalEmpty, localeData.totalAbsent);
306
+ if (breakdown)
307
+ console.log(` ${breakdown}`);
260
308
  const namespacesToDisplay = namespaceFilter ? [namespaceFilter] : Array.from(localeData.namespaces.keys()).sort();
261
309
  for (const ns of namespacesToDisplay) {
262
310
  const nsData = localeData.namespaces.get(ns);
@@ -264,15 +312,28 @@ async function displayDetailedLocaleReport(report, config, locale, namespaceFilt
264
312
  continue;
265
313
  console.log(styleText(['cyan', 'bold'], `\nNamespace: ${ns}`));
266
314
  printProgressBar('Namespace Progress', nsData.translatedKeys, nsData.totalKeys);
267
- const keysToDisplay = hideTranslated ? nsData.keyDetails.filter(({ isTranslated }) => !isTranslated) : nsData.keyDetails;
268
- keysToDisplay.forEach(({ key, isTranslated }) => {
269
- const icon = isTranslated ? styleText('green', '✓') : styleText('red', '✗');
270
- console.log(` ${icon} ${key}`);
315
+ const nsBreakdown = buildBreakdown(nsData.emptyKeys, nsData.absentKeys);
316
+ if (nsBreakdown)
317
+ console.log(` ${nsBreakdown}`);
318
+ const keysToDisplay = hideTranslated
319
+ ? nsData.keyDetails.filter(({ state }) => state !== 'translated')
320
+ : nsData.keyDetails;
321
+ keysToDisplay.forEach(({ key, state }) => {
322
+ if (state === 'translated') {
323
+ console.log(` ${styleText('green', '✓')} ${key}`);
324
+ }
325
+ else if (state === 'empty') {
326
+ console.log(` ${styleText('yellow', '~')} ${key} ${styleText('yellow', '(untranslated)')}`);
327
+ }
328
+ else {
329
+ console.log(` ${styleText('red', '✗')} ${key} ${styleText('red', '(absent)')}`);
330
+ }
271
331
  });
272
332
  }
273
333
  const missingCount = totalKeysForLocale - localeData.totalTranslated;
274
334
  if (missingCount > 0) {
275
- console.log(styleText(['yellow', 'bold'], `\nSummary: Found ${missingCount} missing translations for "${locale}".`));
335
+ const summaryBreakdown = buildBreakdown(localeData.totalEmpty, localeData.totalAbsent);
336
+ console.log(styleText(['yellow', 'bold'], `\nSummary: Found ${missingCount} incomplete translations for "${locale}" — ${summaryBreakdown}.`));
276
337
  }
277
338
  else {
278
339
  console.log(styleText(['green', 'bold'], `\nSummary: 🎉 All keys are translated for "${locale}".`));
@@ -302,7 +363,9 @@ async function displayNamespaceSummaryReport(report, config, namespace) {
302
363
  if (nsLocaleData) {
303
364
  const percentage = nsLocaleData.totalKeys > 0 ? Math.round((nsLocaleData.translatedKeys / nsLocaleData.totalKeys) * 100) : 100;
304
365
  const bar = generateProgressBarText(percentage);
305
- console.log(`- ${locale}: ${bar} ${percentage}% (${nsLocaleData.translatedKeys}/${nsLocaleData.totalKeys} keys)`);
366
+ const breakdown = buildBreakdown(nsLocaleData.emptyKeys, nsLocaleData.absentKeys);
367
+ const suffix = breakdown ? ` — ${breakdown}` : '';
368
+ console.log(`- ${locale}: ${bar} ${percentage}% (${nsLocaleData.translatedKeys}/${nsLocaleData.totalKeys} keys)${suffix}`);
306
369
  }
307
370
  }
308
371
  await printLocizeFunnel();
@@ -331,7 +394,9 @@ async function displayOverallSummaryReport(report, config) {
331
394
  for (const [locale, localeData] of report.locales.entries()) {
332
395
  const percentage = localeData.totalKeys > 0 ? Math.round((localeData.totalTranslated / localeData.totalKeys) * 100) : 100;
333
396
  const bar = generateProgressBarText(percentage);
334
- console.log(`- ${locale}: ${bar} ${percentage}% (${localeData.totalTranslated}/${localeData.totalKeys} keys)`);
397
+ const breakdown = buildBreakdown(localeData.totalEmpty, localeData.totalAbsent);
398
+ const suffix = breakdown ? ` — ${breakdown}` : '';
399
+ console.log(`- ${locale}: ${bar} ${percentage}% (${localeData.totalTranslated}/${localeData.totalKeys} keys)${suffix}`);
335
400
  }
336
401
  await printLocizeFunnel();
337
402
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "i18next-cli",
3
- "version": "1.50.2",
3
+ "version": "1.50.3",
4
4
  "description": "A unified, high-performance i18next CLI.",
5
5
  "type": "module",
6
6
  "bin": {
package/types/status.d.ts CHANGED
@@ -20,6 +20,11 @@ interface StatusOptions {
20
20
  * 4. Displaying a formatted report with key counts, locales, and progress bars.
21
21
  * 5. Serving as a value-driven funnel to introduce the locize commercial service.
22
22
  *
23
+ * Exit behaviour (unchanged): exits 1 when any key is either empty or absent.
24
+ * The output now distinguishes between the two states so developers can tell
25
+ * whether they have a structural problem (absent) or simply pending translation
26
+ * work (empty).
27
+ *
23
28
  * @param config - The i18next toolkit configuration object.
24
29
  * @param options - Options object, may contain a `detail` property with a locale string.
25
30
  * @throws {Error} When unable to extract keys or read translation files
@@ -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;AAIpE;;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;AA4BD;;;;;;;;;;;;;GAaG;AACH,wBAAsB,SAAS,CAAE,MAAM,EAAE,oBAAoB,EAAE,OAAO,GAAE,aAAkB,iBAuBzF"}
1
+ {"version":3,"file":"status.d.ts","sourceRoot":"","sources":["../src/status.ts"],"names":[],"mappings":"AAKA,OAAO,KAAK,EAAE,oBAAoB,EAAgB,MAAM,YAAY,CAAA;AAIpE;;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;AAqDD;;;;;;;;;;;;;;;;;;GAkBG;AACH,wBAAsB,SAAS,CAAE,MAAM,EAAE,oBAAoB,EAAE,OAAO,GAAE,aAAkB,iBAuBzF"}