i18next-cli 1.59.1 → 1.61.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -204,6 +204,8 @@ npx i18next-cli extract --watch --with-types
204
204
 
205
205
  Displays a health check of your project's translation status. Can run without a config file. Exits with a non-zero status code when translations are missing.
206
206
 
207
+ The primary language is checked too: any key used in your code but absent from the primary language's translation files (a typo, or `extract` was never run) is reported and causes a non-zero exit code. Empty-string placeholders written by `extract` are considered present and do not fail the check. Running `npx i18next-cli status <primaryLanguage>` shows the absent keys in detail.
208
+
207
209
  **Options:**
208
210
  - `--namespace <ns>, -n <ns>`: Filter the report by a specific namespace.
209
211
  - `--hide-translated`: Hide already translated keys in the detailed view, showing only missing translations.
@@ -280,6 +282,8 @@ Analyzes your source code for internationalization issues like hardcoded strings
280
282
  npx i18next-cli lint
281
283
  ```
282
284
 
285
+ To suppress warnings for code you intentionally aren't translating yet, use the [`i18next-instrument-ignore` directive](#suppressing-detection-with-i18next-instrument-ignore) — the same comment recognized by the `instrument` command.
286
+
283
287
  ### `instrument`
284
288
 
285
289
  Scans your source code for hardcoded user-facing strings and instruments them with i18next translation calls. This is useful for adding i18next instrumentation to an existing codebase that wasn't built with internationalization in mind. You can see this in action in [this video](https://youtu.be/aWZnZXwGg34) or in [this blog post](https://www.locize.com/blog/i18next-cli-instrument?utm_source=i18next_cli_readme&utm_medium=github&utm_campaign=readme).
@@ -437,6 +441,33 @@ The intended usage pattern is:
437
441
  5. Manually fix any false positives or false negatives
438
442
  6. Run `extract` to finalize translation files
439
443
 
444
+ #### Suppressing detection with `i18next-instrument-ignore`
445
+
446
+ Both the `lint` and `instrument` commands honor an ignore comment so you can skip placeholder or intentionally-untranslated content. It works as a line or block comment, including the JSX `{/* ... */}` form, and comes in two variants:
447
+
448
+ | Directive | Scope |
449
+ | --- | --- |
450
+ | `i18next-instrument-ignore` | The **entire JSX element** that begins on the next line — its opening tag, all nested children, and its closing tag. Falls back to a single line when the next line isn't a JSX element (e.g. a plain `t()` call). |
451
+ | `i18next-instrument-ignore-next-line` | Only the **single line** immediately after the directive. |
452
+
453
+ ```jsx
454
+ // Suppress a whole element (including multi-line opening tags and nested children)
455
+ {/* i18next-instrument-ignore */}
456
+ <div
457
+ css={css`text-align: center;`}>
458
+ Hi, I'm Bob 👋
459
+ <p>This nested text is ignored too</p>
460
+ </div>
461
+
462
+ // Suppress just one line
463
+ {/* i18next-instrument-ignore-next-line */}
464
+ <p>Only this line is ignored</p>
465
+
466
+ // Also works for t() interpolation warnings in the linter
467
+ // i18next-instrument-ignore
468
+ const msg = t('Hello {{name}}!', { wrong: 'world' })
469
+ ```
470
+
440
471
  ### `migrate-config`
441
472
  Automatically migrates a legacy `i18next-parser.config.js` file to the new `i18next.config.ts` format.
442
473
 
package/dist/cjs/cli.js CHANGED
@@ -32,7 +32,7 @@ const program = new commander.Command();
32
32
  program
33
33
  .name('i18next-cli')
34
34
  .description('A unified, high-performance i18next CLI.')
35
- .version('1.59.1'); // This string is replaced with the actual version at build time by rollup
35
+ .version('1.61.0'); // This string is replaced with the actual version at build time by rollup
36
36
  // new: global config override option
37
37
  program.option('-c, --config <path>', 'Path to i18next-cli config file (overrides detection)');
38
38
  program
@@ -103,6 +103,172 @@ function lineColumnFromOffset(code, offset) {
103
103
  column: lines[lines.length - 1].length
104
104
  };
105
105
  }
106
+ // ─── Byte → char offset helpers ────────────────────────────────────────────
107
+ /**
108
+ * Builds a lookup table from UTF-8 byte offsets to JavaScript string character
109
+ * indices (UTF-16 code-unit positions).
110
+ *
111
+ * SWC internally represents source as UTF-8 and reports AST spans as byte
112
+ * offsets into that representation. MagicString and all JavaScript String
113
+ * methods operate on UTF-16 code-unit indices. For files that contain only
114
+ * ASCII characters the two coincide, so this function returns `null` as a
115
+ * fast path. For files with multi-byte characters (emoji, accented letters,
116
+ * CJK, etc.) the returned array allows O(1) conversion of any byte offset.
117
+ */
118
+ function buildByteToCharMap(content) {
119
+ // Fast path: pure ASCII means byte offset ≡ char index
120
+ // eslint-disable-next-line no-control-regex
121
+ if (!/[^\x00-\x7F]/.test(content))
122
+ return null;
123
+ const map = [];
124
+ let byteIdx = 0;
125
+ for (let charIdx = 0; charIdx < content.length;) {
126
+ const cp = content.codePointAt(charIdx);
127
+ const byteLen = cp <= 0x7F ? 1 : cp <= 0x7FF ? 2 : cp <= 0xFFFF ? 3 : 4;
128
+ const charLen = cp > 0xFFFF ? 2 : 1; // surrogate pair
129
+ // Every byte belonging to this character maps to the same char index
130
+ for (let b = 0; b < byteLen; b++) {
131
+ map[byteIdx + b] = charIdx;
132
+ }
133
+ byteIdx += byteLen;
134
+ charIdx += charLen;
135
+ }
136
+ // Sentinel so that span.end (one-past-the-last-byte) resolves correctly
137
+ map[byteIdx] = content.length;
138
+ return map;
139
+ }
140
+ /**
141
+ * Recursively converts every `span.start` / `span.end` in an SWC AST from
142
+ * UTF-8 byte offsets to JavaScript string character indices using the
143
+ * pre-built lookup table.
144
+ */
145
+ function convertSpansToCharIndices(node, byteToChar) {
146
+ if (!node || typeof node !== 'object')
147
+ return;
148
+ if (node.span && typeof node.span.start === 'number') {
149
+ const charStart = byteToChar[node.span.start];
150
+ const charEnd = byteToChar[node.span.end];
151
+ if (charStart !== undefined && charEnd !== undefined) {
152
+ node.span = { ...node.span, start: charStart, end: charEnd };
153
+ }
154
+ }
155
+ for (const key of Object.keys(node)) {
156
+ if (key === 'span')
157
+ continue;
158
+ const child = node[key];
159
+ if (Array.isArray(child)) {
160
+ for (const item of child) {
161
+ if (item && typeof item === 'object') {
162
+ convertSpansToCharIndices(item, byteToChar);
163
+ }
164
+ }
165
+ }
166
+ else if (child && typeof child === 'object') {
167
+ convertSpansToCharIndices(child, byteToChar);
168
+ }
169
+ }
170
+ }
171
+ // ─── Ignore-comment helpers ──────────────────────────────────────────────────
172
+ /**
173
+ * Matches the shared ignore directive used by both the instrumenter and the
174
+ * linter. The optional `-next-line` suffix is captured in group 1:
175
+ *
176
+ * i18next-instrument-ignore-next-line → suppress only the single next line
177
+ * i18next-instrument-ignore → suppress the whole next JSX element
178
+ *
179
+ * Works in line-comment (`// ...`) and block-comment form, including the JSX
180
+ * `{ /* ... * / }` form.
181
+ */
182
+ const IGNORE_DIRECTIVE_RE = /i18next-instrument-ignore(-next-line)?/;
183
+ /**
184
+ * Scans `code` for ignore-directive comments and returns a Set of 1-based line
185
+ * numbers whose issues/strings should be suppressed.
186
+ *
187
+ * A directive on line `D` targets the following line `D + 1`. The behaviour
188
+ * depends on the directive variant:
189
+ *
190
+ * - `i18next-instrument-ignore-next-line` suppresses only line `D + 1`.
191
+ * - `i18next-instrument-ignore` suppresses the **entire JSX element** that
192
+ * begins on line `D + 1` — i.e. every line from its opening tag through its
193
+ * closing tag, including nested children. This makes a single directive
194
+ * cover multi-line elements (e.g. `<div … css={…}>…</div>`) and elements
195
+ * with nested children, instead of just the one physical line after it.
196
+ *
197
+ * When no AST node begins on the targeted line (e.g. the next line is a plain
198
+ * `t()` call rather than a JSX element), block scope falls back to suppressing
199
+ * the single targeted line, preserving the original line-based behaviour.
200
+ *
201
+ * `ast` spans must already be normalised to file-relative character indices
202
+ * (see {@link normalizeASTSpans} / {@link convertSpansToCharIndices}).
203
+ */
204
+ function collectIgnoredLineRanges(ast, code) {
205
+ const ignored = new Set();
206
+ const lines = code.split('\n');
207
+ // 1-based target lines, split by directive variant.
208
+ const blockTargets = new Set();
209
+ const lineTargets = new Set();
210
+ for (let i = 0; i < lines.length; i++) {
211
+ const match = IGNORE_DIRECTIVE_RE.exec(lines[i]);
212
+ if (!match)
213
+ continue;
214
+ const target = i + 2; // directive on line i+1 (1-based) → target line i+2
215
+ if (match[1])
216
+ lineTargets.add(target); // `-next-line` variant
217
+ else
218
+ blockTargets.add(target);
219
+ }
220
+ if (blockTargets.size === 0 && lineTargets.size === 0)
221
+ return ignored;
222
+ // Always suppress the directly targeted line. For `-next-line` this is the
223
+ // whole effect; for block directives it is the fallback when no element is
224
+ // found, and otherwise the start of the suppressed range.
225
+ for (const target of lineTargets)
226
+ ignored.add(target);
227
+ for (const target of blockTargets)
228
+ ignored.add(target);
229
+ if (blockTargets.size === 0)
230
+ return ignored;
231
+ // For block directives, find the widest AST node that begins on the targeted
232
+ // line and expand suppression to cover its full line span. Multiple nodes can
233
+ // start on the same line (e.g. a JSXElement and its JSXOpeningElement); taking
234
+ // the largest end line picks the outermost element.
235
+ const widestEndLineByStartLine = new Map();
236
+ const visit = (node) => {
237
+ if (!node || typeof node !== 'object')
238
+ return;
239
+ if (node.span && typeof node.span.start === 'number') {
240
+ const start = lineColumnFromOffset(code, node.span.start);
241
+ if (start && blockTargets.has(start.line)) {
242
+ const end = lineColumnFromOffset(code, node.span.end);
243
+ if (end) {
244
+ const prev = widestEndLineByStartLine.get(start.line);
245
+ if (prev === undefined || end.line > prev) {
246
+ widestEndLineByStartLine.set(start.line, end.line);
247
+ }
248
+ }
249
+ }
250
+ }
251
+ for (const key of Object.keys(node)) {
252
+ if (key === 'span')
253
+ continue;
254
+ const child = node[key];
255
+ if (Array.isArray(child)) {
256
+ for (const item of child)
257
+ visit(item);
258
+ }
259
+ else if (child && typeof child === 'object') {
260
+ visit(child);
261
+ }
262
+ }
263
+ };
264
+ visit(ast);
265
+ for (const target of blockTargets) {
266
+ const endLine = widestEndLineByStartLine.get(target) ?? target;
267
+ for (let line = target; line <= endLine; line++)
268
+ ignored.add(line);
269
+ }
270
+ return ignored;
271
+ }
106
272
  /**
107
273
  * Finds and returns the full property node (KeyValueProperty) for the given
108
274
  * property name from an ObjectExpression.
@@ -189,6 +355,9 @@ function getObjectPropValue(object, propName, identifierResolver) {
189
355
  return undefined;
190
356
  }
191
357
 
358
+ exports.buildByteToCharMap = buildByteToCharMap;
359
+ exports.collectIgnoredLineRanges = collectIgnoredLineRanges;
360
+ exports.convertSpansToCharIndices = convertSpansToCharIndices;
192
361
  exports.findFirstTokenIndex = findFirstTokenIndex;
193
362
  exports.getObjectPropValue = getObjectPropValue;
194
363
  exports.getObjectPropValueExpression = getObjectPropValueExpression;
@@ -262,9 +262,9 @@ async function scanFileForCandidates(content, file, config) {
262
262
  // SWC reports spans as UTF-8 byte offsets, but JavaScript strings and
263
263
  // MagicString use UTF-16 code-unit indices. Without this conversion,
264
264
  // every emoji / accented char / CJK char shifts all subsequent offsets.
265
- const byteToChar = buildByteToCharMap(content);
265
+ const byteToChar = astUtils.buildByteToCharMap(content);
266
266
  if (byteToChar) {
267
- convertSpansToCharIndices(ast, byteToChar);
267
+ astUtils.convertSpansToCharIndices(ast, byteToChar);
268
268
  }
269
269
  // Detect React function component boundaries
270
270
  detectComponentBoundaries(ast, content, components);
@@ -296,11 +296,13 @@ async function scanFileForCandidates(content, file, config) {
296
296
  }
297
297
  // Filter out candidates suppressed by ignore-comment directives.
298
298
  // Supported comments (line or block):
299
- // // i18next-instrument-ignore-next-line
300
- // // i18next-instrument-ignore
299
+ // // i18next-instrument-ignore-next-line → suppress the single next line
300
+ // // i18next-instrument-ignore → suppress the whole next JSX element
301
301
  // /* i18next-instrument-ignore-next-line */
302
302
  // /* i18next-instrument-ignore */
303
- const ignoredLines = collectIgnoredLines(content);
303
+ // AST spans were normalised to char indices above, so the shared helper can
304
+ // resolve element line ranges directly.
305
+ const ignoredLines = astUtils.collectIgnoredLineRanges(ast, content);
304
306
  if (ignoredLines.size > 0) {
305
307
  const keep = [];
306
308
  for (const c of candidates) {
@@ -318,32 +320,6 @@ async function scanFileForCandidates(content, file, config) {
318
320
  }
319
321
  return { candidates, components, languageChangeSites };
320
322
  }
321
- // ─── Ignore-comment helpers ──────────────────────────────────────────────────
322
- /**
323
- * Regex that matches a directive comment requesting the instrumenter to skip
324
- * the **next** line. Works with both line comments (`// ...`) and block
325
- * comments. The supported directives are:
326
- *
327
- * i18next-instrument-ignore-next-line
328
- * i18next-instrument-ignore
329
- */
330
- const IGNORE_RE = /i18next-instrument-ignore(?:-next-line)?/;
331
- /**
332
- * Scans `content` for ignore-directive comments and returns a Set of 1-based
333
- * line numbers whose strings should be excluded from instrumentation.
334
- */
335
- function collectIgnoredLines(content) {
336
- const ignored = new Set();
337
- const lines = content.split('\n');
338
- for (let i = 0; i < lines.length; i++) {
339
- if (IGNORE_RE.test(lines[i])) {
340
- // Directive on line i → suppress the *following* line (i + 1)
341
- // We store 1-based line numbers, so the suppressed line is i + 2
342
- ignored.add(i + 2);
343
- }
344
- }
345
- return ignored;
346
- }
347
323
  /**
348
324
  * Returns the 1-based line number for a character offset.
349
325
  */
@@ -1964,71 +1940,6 @@ async function runInstrumentOnResultPipeline(filePath, initialCandidates, plugin
1964
1940
  }
1965
1941
  return candidates;
1966
1942
  }
1967
- // ─── Byte → char offset helpers ────────────────────────────────────────────
1968
- /**
1969
- * Builds a lookup table from UTF-8 byte offsets to JavaScript string character
1970
- * indices (UTF-16 code-unit positions).
1971
- *
1972
- * SWC internally represents source as UTF-8 and reports AST spans as byte
1973
- * offsets into that representation. MagicString and all JavaScript String
1974
- * methods operate on UTF-16 code-unit indices. For files that contain only
1975
- * ASCII characters the two coincide, so this function returns `null` as a
1976
- * fast path. For files with multi-byte characters (emoji, accented letters,
1977
- * CJK, etc.) the returned array allows O(1) conversion of any byte offset.
1978
- */
1979
- function buildByteToCharMap(content) {
1980
- // Fast path: pure ASCII means byte offset ≡ char index
1981
- // eslint-disable-next-line no-control-regex
1982
- if (!/[^\x00-\x7F]/.test(content))
1983
- return null;
1984
- const map = [];
1985
- let byteIdx = 0;
1986
- for (let charIdx = 0; charIdx < content.length;) {
1987
- const cp = content.codePointAt(charIdx);
1988
- const byteLen = cp <= 0x7F ? 1 : cp <= 0x7FF ? 2 : cp <= 0xFFFF ? 3 : 4;
1989
- const charLen = cp > 0xFFFF ? 2 : 1; // surrogate pair
1990
- // Every byte belonging to this character maps to the same char index
1991
- for (let b = 0; b < byteLen; b++) {
1992
- map[byteIdx + b] = charIdx;
1993
- }
1994
- byteIdx += byteLen;
1995
- charIdx += charLen;
1996
- }
1997
- // Sentinel so that span.end (one-past-the-last-byte) resolves correctly
1998
- map[byteIdx] = content.length;
1999
- return map;
2000
- }
2001
- /**
2002
- * Recursively converts every `span.start` / `span.end` in an SWC AST from
2003
- * UTF-8 byte offsets to JavaScript string character indices using the
2004
- * pre-built lookup table.
2005
- */
2006
- function convertSpansToCharIndices(node, byteToChar) {
2007
- if (!node || typeof node !== 'object')
2008
- return;
2009
- if (node.span && typeof node.span.start === 'number') {
2010
- const charStart = byteToChar[node.span.start];
2011
- const charEnd = byteToChar[node.span.end];
2012
- if (charStart !== undefined && charEnd !== undefined) {
2013
- node.span = { ...node.span, start: charStart, end: charEnd };
2014
- }
2015
- }
2016
- for (const key of Object.keys(node)) {
2017
- if (key === 'span')
2018
- continue;
2019
- const child = node[key];
2020
- if (Array.isArray(child)) {
2021
- for (const item of child) {
2022
- if (item && typeof item === 'object') {
2023
- convertSpansToCharIndices(item, byteToChar);
2024
- }
2025
- }
2026
- }
2027
- else if (child && typeof child === 'object') {
2028
- convertSpansToCharIndices(child, byteToChar);
2029
- }
2030
- }
2031
- }
2032
1943
 
2033
1944
  exports.runInstrumenter = runInstrumenter;
2034
1945
  exports.writeExtractedKeys = writeExtractedKeys;
@@ -9,6 +9,7 @@ var node_util = require('node:util');
9
9
  var logger = require('./utils/logger.js');
10
10
  var wrapOra = require('./utils/wrap-ora.js');
11
11
  var jsxAttributes = require('./utils/jsx-attributes.js');
12
+ var astUtils = require('./extractor/parsers/ast-utils.js');
12
13
 
13
14
  /**
14
15
  * Loads all translation values from the primary locale's JSON files and returns
@@ -101,34 +102,6 @@ function isI18nextOptionKey(key) {
101
102
  return true;
102
103
  return false;
103
104
  }
104
- // ─── Ignore-comment helpers ──────────────────────────────────────────────────
105
- /**
106
- * Regex matching the shared ignore directive used by both the instrumenter and
107
- * the linter. Supports both `-next-line` and inline variants, in line or block
108
- * comment form.
109
- *
110
- * // i18next-instrument-ignore-next-line → suppresses the following line
111
- * // i18next-instrument-ignore → suppresses the following line
112
- * { /* i18next-instrument-ignore * / } → same, block-comment form
113
- */
114
- const LINT_IGNORE_RE = /i18next-instrument-ignore(?:-next-line)?/;
115
- /**
116
- * Scans `code` for ignore-directive comments and returns a Set of 1-based
117
- * line numbers whose issues should be suppressed.
118
- *
119
- * The directive always suppresses the **next** line (line N+1), matching the
120
- * behaviour of the instrumenter's `collectIgnoredLines`.
121
- */
122
- function collectLintIgnoredLines(code) {
123
- const ignored = new Set();
124
- const lines = code.split('\n');
125
- for (let i = 0; i < lines.length; i++) {
126
- if (LINT_IGNORE_RE.test(lines[i])) {
127
- ignored.add(i + 2); // 1-based: directive is on line i+1, suppressed line is i+2
128
- }
129
- }
130
- return ignored;
131
- }
132
105
  // Helper to lint interpolation parameter errors in t() calls
133
106
  function lintInterpolationParams(ast, code, config, translationValues) {
134
107
  const issues = [];
@@ -403,14 +376,30 @@ class Linter extends node_events.EventEmitter {
403
376
  continue;
404
377
  }
405
378
  }
379
+ // Normalise AST spans to file-relative character indices so the ignore
380
+ // directive can resolve the line range of the JSX element it precedes.
381
+ // (findHardcodedStrings/lintInterpolationParams locate issues via text
382
+ // search and don't read spans, so this only affects ignore handling.)
383
+ try {
384
+ const spanBase = ast.span.start - astUtils.findFirstTokenIndex(code);
385
+ astUtils.normalizeASTSpans(ast, spanBase);
386
+ const byteToChar = astUtils.buildByteToCharMap(code);
387
+ if (byteToChar)
388
+ astUtils.convertSpansToCharIndices(ast, byteToChar);
389
+ }
390
+ catch {
391
+ // If span normalisation fails for any reason, fall back to text-based
392
+ // issue detection without block-scoped ignore support.
393
+ }
406
394
  // Collect hardcoded string issues
407
395
  const hardcodedStrings = findHardcodedStrings(ast, code, config);
408
396
  // Collect interpolation parameter issues
409
397
  const interpolationIssues = lintInterpolationParams(ast, code, config, translationValues);
410
398
  let allIssues = [...hardcodedStrings, ...interpolationIssues];
411
399
  // Filter issues suppressed by ignore-directive comments.
412
- // The directive on line N suppresses all issues reported on line N+1.
413
- const ignoredLines = collectLintIgnoredLines(code);
400
+ // i18next-instrument-ignore-next-line suppresses the single next line
401
+ // i18next-instrument-ignore → suppresses the whole next JSX element
402
+ const ignoredLines = astUtils.collectIgnoredLineRanges(ast, code);
414
403
  if (ignoredLines.size > 0) {
415
404
  allIssues = allIssues.filter(issue => !ignoredLines.has(issue.line));
416
405
  }
@@ -56,6 +56,11 @@ async function runStatus(config, options = {}) {
56
56
  break;
57
57
  }
58
58
  }
59
+ // The primary language fails the check only on absent keys (used in code but
60
+ // missing from the translation file); empty placeholders are tolerated.
61
+ if (!hasMissing && report.primary && report.primary.totalAbsent > 0) {
62
+ hasMissing = true;
63
+ }
59
64
  if (hasMissing) {
60
65
  spinner.fail('Error: Incomplete translations detected.');
61
66
  process.exit(1);
@@ -203,7 +208,13 @@ async function generateStatusReport(config) {
203
208
  if (nestedRefKeys.length > 0)
204
209
  nestedReferenceKeysByNs.set(ns, nestedRefKeys);
205
210
  }
206
- for (const locale of secondaryLanguages) {
211
+ // The primary language is checked first so that keys used in code but absent
212
+ // from the primary translation file (e.g. a typo, or `extract` never run) are
213
+ // surfaced as well. For the primary, an empty value is a deliberate
214
+ // placeholder and counts as present — only truly absent keys are flagged.
215
+ const localesToCheck = [primaryLanguage, ...secondaryLanguages.filter((l) => l !== primaryLanguage)];
216
+ for (const locale of localesToCheck) {
217
+ const isPrimary = locale === primaryLanguage;
207
218
  let totalTranslatedForLocale = 0;
208
219
  let totalEmptyForLocale = 0;
209
220
  let totalAbsentForLocale = 0;
@@ -264,11 +275,17 @@ async function generateStatusReport(config) {
264
275
  const primaryState = classifyValue(primaryValue);
265
276
  // Only fall back when the key is genuinely absent from the primary file.
266
277
  // An empty string is intentional (placeholder from extract) — don't hide it.
278
+ let state = primaryState;
267
279
  if (primaryState === 'absent' && fallbackTranslations) {
268
280
  const fallbackValue = nestedObject.getNestedValue(fallbackTranslations, key, sep);
269
- return classifyValue(fallbackValue);
281
+ state = classifyValue(fallbackValue);
270
282
  }
271
- return primaryState;
283
+ // For the primary language the file itself is the source of values, so an
284
+ // empty placeholder still means the key is present. Only a truly absent
285
+ // key (used in code, missing from the file) is a problem here.
286
+ if (isPrimary && state === 'empty')
287
+ return 'translated';
288
+ return state;
272
289
  };
273
290
  const processedKeys = new Set();
274
291
  // Combine AST-extracted keys with nested-reference keys discovered in
@@ -365,13 +382,19 @@ async function generateStatusReport(config) {
365
382
  totalAbsentForLocale += absentInNs;
366
383
  totalKeysForLocale += totalInNs;
367
384
  }
368
- report.locales.set(locale, {
385
+ const localeStatus = {
369
386
  totalKeys: totalKeysForLocale,
370
387
  totalTranslated: totalTranslatedForLocale,
371
388
  totalEmpty: totalEmptyForLocale,
372
389
  totalAbsent: totalAbsentForLocale,
373
390
  namespaces,
374
- });
391
+ };
392
+ if (isPrimary) {
393
+ report.primary = localeStatus;
394
+ }
395
+ else {
396
+ report.locales.set(locale, localeStatus);
397
+ }
375
398
  }
376
399
  return report;
377
400
  }
@@ -419,15 +442,12 @@ async function displayStatusReport(report, config, options) {
419
442
  * ✗ red — absent from file entirely (structural problem)
420
443
  */
421
444
  async function displayDetailedLocaleReport(report, config, locale, namespaceFilter, hideTranslated) {
422
- if (locale === config.extract.primaryLanguage) {
423
- console.log(node_util.styleText('yellow', `Locale "${locale}" is the primary language. All keys are considered present.`));
424
- return;
425
- }
426
445
  if (!config.locales.includes(locale)) {
427
446
  console.error(node_util.styleText('red', `Error: Locale "${locale}" is not defined in your configuration.`));
428
447
  return;
429
448
  }
430
- const localeData = report.locales.get(locale);
449
+ const isPrimary = locale === config.extract.primaryLanguage;
450
+ const localeData = isPrimary ? report.primary : report.locales.get(locale);
431
451
  if (!localeData) {
432
452
  console.error(node_util.styleText('red', `Error: Locale "${locale}" is not a valid secondary language.`));
433
453
  return;
@@ -465,8 +485,16 @@ async function displayDetailedLocaleReport(report, config, locale, namespaceFilt
465
485
  }
466
486
  const missingCount = totalKeysForLocale - localeData.totalTranslated;
467
487
  if (missingCount > 0) {
468
- const summaryBreakdown = buildBreakdown(localeData.totalEmpty, localeData.totalAbsent);
469
- console.log(node_util.styleText(['yellow', 'bold'], `\nSummary: Found ${missingCount} incomplete translations for "${locale}" ${summaryBreakdown}.`));
488
+ if (isPrimary) {
489
+ console.log(node_util.styleText(['red', 'bold'], `\nSummary: Found ${missingCount} key(s) used in code but absent from the "${locale}" translation files. Run "i18next-cli extract" to add them.`));
490
+ }
491
+ else {
492
+ const summaryBreakdown = buildBreakdown(localeData.totalEmpty, localeData.totalAbsent);
493
+ console.log(node_util.styleText(['yellow', 'bold'], `\nSummary: Found ${missingCount} incomplete translations for "${locale}" — ${summaryBreakdown}.`));
494
+ }
495
+ }
496
+ else if (isPrimary) {
497
+ console.log(node_util.styleText(['green', 'bold'], `\nSummary: 🎉 All keys used in code are present in the "${locale}" translation files.`));
470
498
  }
471
499
  else {
472
500
  console.log(node_util.styleText(['green', 'bold'], `\nSummary: 🎉 All keys are translated for "${locale}".`));
@@ -501,6 +529,11 @@ async function displayNamespaceSummaryReport(report, config, namespace) {
501
529
  console.log(`- ${locale}: ${bar} ${percentage}% (${nsLocaleData.translatedKeys}/${nsLocaleData.totalKeys} keys)${suffix}`);
502
530
  }
503
531
  }
532
+ const primaryNsData = report.primary?.namespaces.get(namespace);
533
+ if (primaryNsData && primaryNsData.absentKeys > 0) {
534
+ const { primaryLanguage } = config.extract;
535
+ console.log(node_util.styleText(['red', 'bold'], `\n⚠ Primary language "${primaryLanguage}" is missing ${primaryNsData.absentKeys} key(s) that are used in code.`));
536
+ }
504
537
  await printLocizeFunnel();
505
538
  }
506
539
  /**
@@ -531,6 +564,10 @@ async function displayOverallSummaryReport(report, config) {
531
564
  const suffix = breakdown ? ` — ${breakdown}` : '';
532
565
  console.log(`- ${locale}: ${bar} ${percentage}% (${localeData.totalTranslated}/${localeData.totalKeys} keys)${suffix}`);
533
566
  }
567
+ if (report.primary && report.primary.totalAbsent > 0) {
568
+ console.log(node_util.styleText(['red', 'bold'], `\n⚠ Primary language "${primaryLanguage}" is missing ${report.primary.totalAbsent} key(s) that are used in code.`));
569
+ console.log(node_util.styleText('red', ` Run "i18next-cli status ${primaryLanguage}" for details, or "i18next-cli extract" to add them.`));
570
+ }
534
571
  await printLocizeFunnel();
535
572
  }
536
573
  /**
package/dist/esm/cli.js CHANGED
@@ -30,7 +30,7 @@ const program = new Command();
30
30
  program
31
31
  .name('i18next-cli')
32
32
  .description('A unified, high-performance i18next CLI.')
33
- .version('1.59.1'); // This string is replaced with the actual version at build time by rollup
33
+ .version('1.61.0'); // This string is replaced with the actual version at build time by rollup
34
34
  // new: global config override option
35
35
  program.option('-c, --config <path>', 'Path to i18next-cli config file (overrides detection)');
36
36
  program
@@ -101,6 +101,172 @@ function lineColumnFromOffset(code, offset) {
101
101
  column: lines[lines.length - 1].length
102
102
  };
103
103
  }
104
+ // ─── Byte → char offset helpers ────────────────────────────────────────────
105
+ /**
106
+ * Builds a lookup table from UTF-8 byte offsets to JavaScript string character
107
+ * indices (UTF-16 code-unit positions).
108
+ *
109
+ * SWC internally represents source as UTF-8 and reports AST spans as byte
110
+ * offsets into that representation. MagicString and all JavaScript String
111
+ * methods operate on UTF-16 code-unit indices. For files that contain only
112
+ * ASCII characters the two coincide, so this function returns `null` as a
113
+ * fast path. For files with multi-byte characters (emoji, accented letters,
114
+ * CJK, etc.) the returned array allows O(1) conversion of any byte offset.
115
+ */
116
+ function buildByteToCharMap(content) {
117
+ // Fast path: pure ASCII means byte offset ≡ char index
118
+ // eslint-disable-next-line no-control-regex
119
+ if (!/[^\x00-\x7F]/.test(content))
120
+ return null;
121
+ const map = [];
122
+ let byteIdx = 0;
123
+ for (let charIdx = 0; charIdx < content.length;) {
124
+ const cp = content.codePointAt(charIdx);
125
+ const byteLen = cp <= 0x7F ? 1 : cp <= 0x7FF ? 2 : cp <= 0xFFFF ? 3 : 4;
126
+ const charLen = cp > 0xFFFF ? 2 : 1; // surrogate pair
127
+ // Every byte belonging to this character maps to the same char index
128
+ for (let b = 0; b < byteLen; b++) {
129
+ map[byteIdx + b] = charIdx;
130
+ }
131
+ byteIdx += byteLen;
132
+ charIdx += charLen;
133
+ }
134
+ // Sentinel so that span.end (one-past-the-last-byte) resolves correctly
135
+ map[byteIdx] = content.length;
136
+ return map;
137
+ }
138
+ /**
139
+ * Recursively converts every `span.start` / `span.end` in an SWC AST from
140
+ * UTF-8 byte offsets to JavaScript string character indices using the
141
+ * pre-built lookup table.
142
+ */
143
+ function convertSpansToCharIndices(node, byteToChar) {
144
+ if (!node || typeof node !== 'object')
145
+ return;
146
+ if (node.span && typeof node.span.start === 'number') {
147
+ const charStart = byteToChar[node.span.start];
148
+ const charEnd = byteToChar[node.span.end];
149
+ if (charStart !== undefined && charEnd !== undefined) {
150
+ node.span = { ...node.span, start: charStart, end: charEnd };
151
+ }
152
+ }
153
+ for (const key of Object.keys(node)) {
154
+ if (key === 'span')
155
+ continue;
156
+ const child = node[key];
157
+ if (Array.isArray(child)) {
158
+ for (const item of child) {
159
+ if (item && typeof item === 'object') {
160
+ convertSpansToCharIndices(item, byteToChar);
161
+ }
162
+ }
163
+ }
164
+ else if (child && typeof child === 'object') {
165
+ convertSpansToCharIndices(child, byteToChar);
166
+ }
167
+ }
168
+ }
169
+ // ─── Ignore-comment helpers ──────────────────────────────────────────────────
170
+ /**
171
+ * Matches the shared ignore directive used by both the instrumenter and the
172
+ * linter. The optional `-next-line` suffix is captured in group 1:
173
+ *
174
+ * i18next-instrument-ignore-next-line → suppress only the single next line
175
+ * i18next-instrument-ignore → suppress the whole next JSX element
176
+ *
177
+ * Works in line-comment (`// ...`) and block-comment form, including the JSX
178
+ * `{ /* ... * / }` form.
179
+ */
180
+ const IGNORE_DIRECTIVE_RE = /i18next-instrument-ignore(-next-line)?/;
181
+ /**
182
+ * Scans `code` for ignore-directive comments and returns a Set of 1-based line
183
+ * numbers whose issues/strings should be suppressed.
184
+ *
185
+ * A directive on line `D` targets the following line `D + 1`. The behaviour
186
+ * depends on the directive variant:
187
+ *
188
+ * - `i18next-instrument-ignore-next-line` suppresses only line `D + 1`.
189
+ * - `i18next-instrument-ignore` suppresses the **entire JSX element** that
190
+ * begins on line `D + 1` — i.e. every line from its opening tag through its
191
+ * closing tag, including nested children. This makes a single directive
192
+ * cover multi-line elements (e.g. `<div … css={…}>…</div>`) and elements
193
+ * with nested children, instead of just the one physical line after it.
194
+ *
195
+ * When no AST node begins on the targeted line (e.g. the next line is a plain
196
+ * `t()` call rather than a JSX element), block scope falls back to suppressing
197
+ * the single targeted line, preserving the original line-based behaviour.
198
+ *
199
+ * `ast` spans must already be normalised to file-relative character indices
200
+ * (see {@link normalizeASTSpans} / {@link convertSpansToCharIndices}).
201
+ */
202
+ function collectIgnoredLineRanges(ast, code) {
203
+ const ignored = new Set();
204
+ const lines = code.split('\n');
205
+ // 1-based target lines, split by directive variant.
206
+ const blockTargets = new Set();
207
+ const lineTargets = new Set();
208
+ for (let i = 0; i < lines.length; i++) {
209
+ const match = IGNORE_DIRECTIVE_RE.exec(lines[i]);
210
+ if (!match)
211
+ continue;
212
+ const target = i + 2; // directive on line i+1 (1-based) → target line i+2
213
+ if (match[1])
214
+ lineTargets.add(target); // `-next-line` variant
215
+ else
216
+ blockTargets.add(target);
217
+ }
218
+ if (blockTargets.size === 0 && lineTargets.size === 0)
219
+ return ignored;
220
+ // Always suppress the directly targeted line. For `-next-line` this is the
221
+ // whole effect; for block directives it is the fallback when no element is
222
+ // found, and otherwise the start of the suppressed range.
223
+ for (const target of lineTargets)
224
+ ignored.add(target);
225
+ for (const target of blockTargets)
226
+ ignored.add(target);
227
+ if (blockTargets.size === 0)
228
+ return ignored;
229
+ // For block directives, find the widest AST node that begins on the targeted
230
+ // line and expand suppression to cover its full line span. Multiple nodes can
231
+ // start on the same line (e.g. a JSXElement and its JSXOpeningElement); taking
232
+ // the largest end line picks the outermost element.
233
+ const widestEndLineByStartLine = new Map();
234
+ const visit = (node) => {
235
+ if (!node || typeof node !== 'object')
236
+ return;
237
+ if (node.span && typeof node.span.start === 'number') {
238
+ const start = lineColumnFromOffset(code, node.span.start);
239
+ if (start && blockTargets.has(start.line)) {
240
+ const end = lineColumnFromOffset(code, node.span.end);
241
+ if (end) {
242
+ const prev = widestEndLineByStartLine.get(start.line);
243
+ if (prev === undefined || end.line > prev) {
244
+ widestEndLineByStartLine.set(start.line, end.line);
245
+ }
246
+ }
247
+ }
248
+ }
249
+ for (const key of Object.keys(node)) {
250
+ if (key === 'span')
251
+ continue;
252
+ const child = node[key];
253
+ if (Array.isArray(child)) {
254
+ for (const item of child)
255
+ visit(item);
256
+ }
257
+ else if (child && typeof child === 'object') {
258
+ visit(child);
259
+ }
260
+ }
261
+ };
262
+ visit(ast);
263
+ for (const target of blockTargets) {
264
+ const endLine = widestEndLineByStartLine.get(target) ?? target;
265
+ for (let line = target; line <= endLine; line++)
266
+ ignored.add(line);
267
+ }
268
+ return ignored;
269
+ }
104
270
  /**
105
271
  * Finds and returns the full property node (KeyValueProperty) for the given
106
272
  * property name from an ObjectExpression.
@@ -187,4 +353,4 @@ function getObjectPropValue(object, propName, identifierResolver) {
187
353
  return undefined;
188
354
  }
189
355
 
190
- export { findFirstTokenIndex, getObjectPropValue, getObjectPropValueExpression, getObjectProperty, isSimpleTemplateLiteral, lineColumnFromOffset, normalizeASTSpans };
356
+ export { buildByteToCharMap, collectIgnoredLineRanges, convertSpansToCharIndices, findFirstTokenIndex, getObjectPropValue, getObjectPropValueExpression, getObjectProperty, isSimpleTemplateLiteral, lineColumnFromOffset, normalizeASTSpans };
@@ -10,7 +10,7 @@ import { createKeyRegistry, generateKeyFromContent } from './key-generator.js';
10
10
  import { createSpinnerLike } from '../../utils/wrap-ora.js';
11
11
  import { ConsoleLogger } from '../../utils/logger.js';
12
12
  import { ignoredAttributeSet } from '../../utils/jsx-attributes.js';
13
- import { findFirstTokenIndex, normalizeASTSpans } from '../../extractor/parsers/ast-utils.js';
13
+ import { findFirstTokenIndex, normalizeASTSpans, buildByteToCharMap, convertSpansToCharIndices, collectIgnoredLineRanges } from '../../extractor/parsers/ast-utils.js';
14
14
  import { getOutputPath } from '../../utils/file-utils.js';
15
15
 
16
16
  /**
@@ -294,11 +294,13 @@ async function scanFileForCandidates(content, file, config) {
294
294
  }
295
295
  // Filter out candidates suppressed by ignore-comment directives.
296
296
  // Supported comments (line or block):
297
- // // i18next-instrument-ignore-next-line
298
- // // i18next-instrument-ignore
297
+ // // i18next-instrument-ignore-next-line → suppress the single next line
298
+ // // i18next-instrument-ignore → suppress the whole next JSX element
299
299
  // /* i18next-instrument-ignore-next-line */
300
300
  // /* i18next-instrument-ignore */
301
- const ignoredLines = collectIgnoredLines(content);
301
+ // AST spans were normalised to char indices above, so the shared helper can
302
+ // resolve element line ranges directly.
303
+ const ignoredLines = collectIgnoredLineRanges(ast, content);
302
304
  if (ignoredLines.size > 0) {
303
305
  const keep = [];
304
306
  for (const c of candidates) {
@@ -316,32 +318,6 @@ async function scanFileForCandidates(content, file, config) {
316
318
  }
317
319
  return { candidates, components, languageChangeSites };
318
320
  }
319
- // ─── Ignore-comment helpers ──────────────────────────────────────────────────
320
- /**
321
- * Regex that matches a directive comment requesting the instrumenter to skip
322
- * the **next** line. Works with both line comments (`// ...`) and block
323
- * comments. The supported directives are:
324
- *
325
- * i18next-instrument-ignore-next-line
326
- * i18next-instrument-ignore
327
- */
328
- const IGNORE_RE = /i18next-instrument-ignore(?:-next-line)?/;
329
- /**
330
- * Scans `content` for ignore-directive comments and returns a Set of 1-based
331
- * line numbers whose strings should be excluded from instrumentation.
332
- */
333
- function collectIgnoredLines(content) {
334
- const ignored = new Set();
335
- const lines = content.split('\n');
336
- for (let i = 0; i < lines.length; i++) {
337
- if (IGNORE_RE.test(lines[i])) {
338
- // Directive on line i → suppress the *following* line (i + 1)
339
- // We store 1-based line numbers, so the suppressed line is i + 2
340
- ignored.add(i + 2);
341
- }
342
- }
343
- return ignored;
344
- }
345
321
  /**
346
322
  * Returns the 1-based line number for a character offset.
347
323
  */
@@ -1962,70 +1938,5 @@ async function runInstrumentOnResultPipeline(filePath, initialCandidates, plugin
1962
1938
  }
1963
1939
  return candidates;
1964
1940
  }
1965
- // ─── Byte → char offset helpers ────────────────────────────────────────────
1966
- /**
1967
- * Builds a lookup table from UTF-8 byte offsets to JavaScript string character
1968
- * indices (UTF-16 code-unit positions).
1969
- *
1970
- * SWC internally represents source as UTF-8 and reports AST spans as byte
1971
- * offsets into that representation. MagicString and all JavaScript String
1972
- * methods operate on UTF-16 code-unit indices. For files that contain only
1973
- * ASCII characters the two coincide, so this function returns `null` as a
1974
- * fast path. For files with multi-byte characters (emoji, accented letters,
1975
- * CJK, etc.) the returned array allows O(1) conversion of any byte offset.
1976
- */
1977
- function buildByteToCharMap(content) {
1978
- // Fast path: pure ASCII means byte offset ≡ char index
1979
- // eslint-disable-next-line no-control-regex
1980
- if (!/[^\x00-\x7F]/.test(content))
1981
- return null;
1982
- const map = [];
1983
- let byteIdx = 0;
1984
- for (let charIdx = 0; charIdx < content.length;) {
1985
- const cp = content.codePointAt(charIdx);
1986
- const byteLen = cp <= 0x7F ? 1 : cp <= 0x7FF ? 2 : cp <= 0xFFFF ? 3 : 4;
1987
- const charLen = cp > 0xFFFF ? 2 : 1; // surrogate pair
1988
- // Every byte belonging to this character maps to the same char index
1989
- for (let b = 0; b < byteLen; b++) {
1990
- map[byteIdx + b] = charIdx;
1991
- }
1992
- byteIdx += byteLen;
1993
- charIdx += charLen;
1994
- }
1995
- // Sentinel so that span.end (one-past-the-last-byte) resolves correctly
1996
- map[byteIdx] = content.length;
1997
- return map;
1998
- }
1999
- /**
2000
- * Recursively converts every `span.start` / `span.end` in an SWC AST from
2001
- * UTF-8 byte offsets to JavaScript string character indices using the
2002
- * pre-built lookup table.
2003
- */
2004
- function convertSpansToCharIndices(node, byteToChar) {
2005
- if (!node || typeof node !== 'object')
2006
- return;
2007
- if (node.span && typeof node.span.start === 'number') {
2008
- const charStart = byteToChar[node.span.start];
2009
- const charEnd = byteToChar[node.span.end];
2010
- if (charStart !== undefined && charEnd !== undefined) {
2011
- node.span = { ...node.span, start: charStart, end: charEnd };
2012
- }
2013
- }
2014
- for (const key of Object.keys(node)) {
2015
- if (key === 'span')
2016
- continue;
2017
- const child = node[key];
2018
- if (Array.isArray(child)) {
2019
- for (const item of child) {
2020
- if (item && typeof item === 'object') {
2021
- convertSpansToCharIndices(item, byteToChar);
2022
- }
2023
- }
2024
- }
2025
- else if (child && typeof child === 'object') {
2026
- convertSpansToCharIndices(child, byteToChar);
2027
- }
2028
- }
2029
- }
2030
1941
 
2031
1942
  export { runInstrumenter, writeExtractedKeys };
@@ -7,6 +7,7 @@ import { styleText } from 'node:util';
7
7
  import { ConsoleLogger } from './utils/logger.js';
8
8
  import { createSpinnerLike } from './utils/wrap-ora.js';
9
9
  import { acceptedTags, translatableAttributes, ignoredTags, ignoredAttributeLowerSet } from './utils/jsx-attributes.js';
10
+ import { findFirstTokenIndex, normalizeASTSpans, buildByteToCharMap, convertSpansToCharIndices, collectIgnoredLineRanges } from './extractor/parsers/ast-utils.js';
10
11
 
11
12
  /**
12
13
  * Loads all translation values from the primary locale's JSON files and returns
@@ -99,34 +100,6 @@ function isI18nextOptionKey(key) {
99
100
  return true;
100
101
  return false;
101
102
  }
102
- // ─── Ignore-comment helpers ──────────────────────────────────────────────────
103
- /**
104
- * Regex matching the shared ignore directive used by both the instrumenter and
105
- * the linter. Supports both `-next-line` and inline variants, in line or block
106
- * comment form.
107
- *
108
- * // i18next-instrument-ignore-next-line → suppresses the following line
109
- * // i18next-instrument-ignore → suppresses the following line
110
- * { /* i18next-instrument-ignore * / } → same, block-comment form
111
- */
112
- const LINT_IGNORE_RE = /i18next-instrument-ignore(?:-next-line)?/;
113
- /**
114
- * Scans `code` for ignore-directive comments and returns a Set of 1-based
115
- * line numbers whose issues should be suppressed.
116
- *
117
- * The directive always suppresses the **next** line (line N+1), matching the
118
- * behaviour of the instrumenter's `collectIgnoredLines`.
119
- */
120
- function collectLintIgnoredLines(code) {
121
- const ignored = new Set();
122
- const lines = code.split('\n');
123
- for (let i = 0; i < lines.length; i++) {
124
- if (LINT_IGNORE_RE.test(lines[i])) {
125
- ignored.add(i + 2); // 1-based: directive is on line i+1, suppressed line is i+2
126
- }
127
- }
128
- return ignored;
129
- }
130
103
  // Helper to lint interpolation parameter errors in t() calls
131
104
  function lintInterpolationParams(ast, code, config, translationValues) {
132
105
  const issues = [];
@@ -401,14 +374,30 @@ class Linter extends EventEmitter {
401
374
  continue;
402
375
  }
403
376
  }
377
+ // Normalise AST spans to file-relative character indices so the ignore
378
+ // directive can resolve the line range of the JSX element it precedes.
379
+ // (findHardcodedStrings/lintInterpolationParams locate issues via text
380
+ // search and don't read spans, so this only affects ignore handling.)
381
+ try {
382
+ const spanBase = ast.span.start - findFirstTokenIndex(code);
383
+ normalizeASTSpans(ast, spanBase);
384
+ const byteToChar = buildByteToCharMap(code);
385
+ if (byteToChar)
386
+ convertSpansToCharIndices(ast, byteToChar);
387
+ }
388
+ catch {
389
+ // If span normalisation fails for any reason, fall back to text-based
390
+ // issue detection without block-scoped ignore support.
391
+ }
404
392
  // Collect hardcoded string issues
405
393
  const hardcodedStrings = findHardcodedStrings(ast, code, config);
406
394
  // Collect interpolation parameter issues
407
395
  const interpolationIssues = lintInterpolationParams(ast, code, config, translationValues);
408
396
  let allIssues = [...hardcodedStrings, ...interpolationIssues];
409
397
  // Filter issues suppressed by ignore-directive comments.
410
- // The directive on line N suppresses all issues reported on line N+1.
411
- const ignoredLines = collectLintIgnoredLines(code);
398
+ // i18next-instrument-ignore-next-line suppresses the single next line
399
+ // i18next-instrument-ignore → suppresses the whole next JSX element
400
+ const ignoredLines = collectIgnoredLineRanges(ast, code);
412
401
  if (ignoredLines.size > 0) {
413
402
  allIssues = allIssues.filter(issue => !ignoredLines.has(issue.line));
414
403
  }
@@ -54,6 +54,11 @@ async function runStatus(config, options = {}) {
54
54
  break;
55
55
  }
56
56
  }
57
+ // The primary language fails the check only on absent keys (used in code but
58
+ // missing from the translation file); empty placeholders are tolerated.
59
+ if (!hasMissing && report.primary && report.primary.totalAbsent > 0) {
60
+ hasMissing = true;
61
+ }
57
62
  if (hasMissing) {
58
63
  spinner.fail('Error: Incomplete translations detected.');
59
64
  process.exit(1);
@@ -201,7 +206,13 @@ async function generateStatusReport(config) {
201
206
  if (nestedRefKeys.length > 0)
202
207
  nestedReferenceKeysByNs.set(ns, nestedRefKeys);
203
208
  }
204
- for (const locale of secondaryLanguages) {
209
+ // The primary language is checked first so that keys used in code but absent
210
+ // from the primary translation file (e.g. a typo, or `extract` never run) are
211
+ // surfaced as well. For the primary, an empty value is a deliberate
212
+ // placeholder and counts as present — only truly absent keys are flagged.
213
+ const localesToCheck = [primaryLanguage, ...secondaryLanguages.filter((l) => l !== primaryLanguage)];
214
+ for (const locale of localesToCheck) {
215
+ const isPrimary = locale === primaryLanguage;
205
216
  let totalTranslatedForLocale = 0;
206
217
  let totalEmptyForLocale = 0;
207
218
  let totalAbsentForLocale = 0;
@@ -262,11 +273,17 @@ async function generateStatusReport(config) {
262
273
  const primaryState = classifyValue(primaryValue);
263
274
  // Only fall back when the key is genuinely absent from the primary file.
264
275
  // An empty string is intentional (placeholder from extract) — don't hide it.
276
+ let state = primaryState;
265
277
  if (primaryState === 'absent' && fallbackTranslations) {
266
278
  const fallbackValue = getNestedValue(fallbackTranslations, key, sep);
267
- return classifyValue(fallbackValue);
279
+ state = classifyValue(fallbackValue);
268
280
  }
269
- return primaryState;
281
+ // For the primary language the file itself is the source of values, so an
282
+ // empty placeholder still means the key is present. Only a truly absent
283
+ // key (used in code, missing from the file) is a problem here.
284
+ if (isPrimary && state === 'empty')
285
+ return 'translated';
286
+ return state;
270
287
  };
271
288
  const processedKeys = new Set();
272
289
  // Combine AST-extracted keys with nested-reference keys discovered in
@@ -363,13 +380,19 @@ async function generateStatusReport(config) {
363
380
  totalAbsentForLocale += absentInNs;
364
381
  totalKeysForLocale += totalInNs;
365
382
  }
366
- report.locales.set(locale, {
383
+ const localeStatus = {
367
384
  totalKeys: totalKeysForLocale,
368
385
  totalTranslated: totalTranslatedForLocale,
369
386
  totalEmpty: totalEmptyForLocale,
370
387
  totalAbsent: totalAbsentForLocale,
371
388
  namespaces,
372
- });
389
+ };
390
+ if (isPrimary) {
391
+ report.primary = localeStatus;
392
+ }
393
+ else {
394
+ report.locales.set(locale, localeStatus);
395
+ }
373
396
  }
374
397
  return report;
375
398
  }
@@ -417,15 +440,12 @@ async function displayStatusReport(report, config, options) {
417
440
  * ✗ red — absent from file entirely (structural problem)
418
441
  */
419
442
  async function displayDetailedLocaleReport(report, config, locale, namespaceFilter, hideTranslated) {
420
- if (locale === config.extract.primaryLanguage) {
421
- console.log(styleText('yellow', `Locale "${locale}" is the primary language. All keys are considered present.`));
422
- return;
423
- }
424
443
  if (!config.locales.includes(locale)) {
425
444
  console.error(styleText('red', `Error: Locale "${locale}" is not defined in your configuration.`));
426
445
  return;
427
446
  }
428
- const localeData = report.locales.get(locale);
447
+ const isPrimary = locale === config.extract.primaryLanguage;
448
+ const localeData = isPrimary ? report.primary : report.locales.get(locale);
429
449
  if (!localeData) {
430
450
  console.error(styleText('red', `Error: Locale "${locale}" is not a valid secondary language.`));
431
451
  return;
@@ -463,8 +483,16 @@ async function displayDetailedLocaleReport(report, config, locale, namespaceFilt
463
483
  }
464
484
  const missingCount = totalKeysForLocale - localeData.totalTranslated;
465
485
  if (missingCount > 0) {
466
- const summaryBreakdown = buildBreakdown(localeData.totalEmpty, localeData.totalAbsent);
467
- console.log(styleText(['yellow', 'bold'], `\nSummary: Found ${missingCount} incomplete translations for "${locale}" ${summaryBreakdown}.`));
486
+ if (isPrimary) {
487
+ console.log(styleText(['red', 'bold'], `\nSummary: Found ${missingCount} key(s) used in code but absent from the "${locale}" translation files. Run "i18next-cli extract" to add them.`));
488
+ }
489
+ else {
490
+ const summaryBreakdown = buildBreakdown(localeData.totalEmpty, localeData.totalAbsent);
491
+ console.log(styleText(['yellow', 'bold'], `\nSummary: Found ${missingCount} incomplete translations for "${locale}" — ${summaryBreakdown}.`));
492
+ }
493
+ }
494
+ else if (isPrimary) {
495
+ console.log(styleText(['green', 'bold'], `\nSummary: 🎉 All keys used in code are present in the "${locale}" translation files.`));
468
496
  }
469
497
  else {
470
498
  console.log(styleText(['green', 'bold'], `\nSummary: 🎉 All keys are translated for "${locale}".`));
@@ -499,6 +527,11 @@ async function displayNamespaceSummaryReport(report, config, namespace) {
499
527
  console.log(`- ${locale}: ${bar} ${percentage}% (${nsLocaleData.translatedKeys}/${nsLocaleData.totalKeys} keys)${suffix}`);
500
528
  }
501
529
  }
530
+ const primaryNsData = report.primary?.namespaces.get(namespace);
531
+ if (primaryNsData && primaryNsData.absentKeys > 0) {
532
+ const { primaryLanguage } = config.extract;
533
+ console.log(styleText(['red', 'bold'], `\n⚠ Primary language "${primaryLanguage}" is missing ${primaryNsData.absentKeys} key(s) that are used in code.`));
534
+ }
502
535
  await printLocizeFunnel();
503
536
  }
504
537
  /**
@@ -529,6 +562,10 @@ async function displayOverallSummaryReport(report, config) {
529
562
  const suffix = breakdown ? ` — ${breakdown}` : '';
530
563
  console.log(`- ${locale}: ${bar} ${percentage}% (${localeData.totalTranslated}/${localeData.totalKeys} keys)${suffix}`);
531
564
  }
565
+ if (report.primary && report.primary.totalAbsent > 0) {
566
+ console.log(styleText(['red', 'bold'], `\n⚠ Primary language "${primaryLanguage}" is missing ${report.primary.totalAbsent} key(s) that are used in code.`));
567
+ console.log(styleText('red', ` Run "i18next-cli status ${primaryLanguage}" for details, or "i18next-cli extract" to add them.`));
568
+ }
532
569
  await printLocizeFunnel();
533
570
  }
534
571
  /**
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "i18next-cli",
3
- "version": "1.59.1",
3
+ "version": "1.61.0",
4
4
  "description": "A unified, high-performance i18next CLI.",
5
5
  "type": "module",
6
6
  "bin": {
@@ -36,6 +36,46 @@ export declare function lineColumnFromOffset(code: string, offset: number): {
36
36
  line: number;
37
37
  column: number;
38
38
  } | undefined;
39
+ /**
40
+ * Builds a lookup table from UTF-8 byte offsets to JavaScript string character
41
+ * indices (UTF-16 code-unit positions).
42
+ *
43
+ * SWC internally represents source as UTF-8 and reports AST spans as byte
44
+ * offsets into that representation. MagicString and all JavaScript String
45
+ * methods operate on UTF-16 code-unit indices. For files that contain only
46
+ * ASCII characters the two coincide, so this function returns `null` as a
47
+ * fast path. For files with multi-byte characters (emoji, accented letters,
48
+ * CJK, etc.) the returned array allows O(1) conversion of any byte offset.
49
+ */
50
+ export declare function buildByteToCharMap(content: string): number[] | null;
51
+ /**
52
+ * Recursively converts every `span.start` / `span.end` in an SWC AST from
53
+ * UTF-8 byte offsets to JavaScript string character indices using the
54
+ * pre-built lookup table.
55
+ */
56
+ export declare function convertSpansToCharIndices(node: any, byteToChar: number[]): void;
57
+ /**
58
+ * Scans `code` for ignore-directive comments and returns a Set of 1-based line
59
+ * numbers whose issues/strings should be suppressed.
60
+ *
61
+ * A directive on line `D` targets the following line `D + 1`. The behaviour
62
+ * depends on the directive variant:
63
+ *
64
+ * - `i18next-instrument-ignore-next-line` suppresses only line `D + 1`.
65
+ * - `i18next-instrument-ignore` suppresses the **entire JSX element** that
66
+ * begins on line `D + 1` — i.e. every line from its opening tag through its
67
+ * closing tag, including nested children. This makes a single directive
68
+ * cover multi-line elements (e.g. `<div … css={…}>…</div>`) and elements
69
+ * with nested children, instead of just the one physical line after it.
70
+ *
71
+ * When no AST node begins on the targeted line (e.g. the next line is a plain
72
+ * `t()` call rather than a JSX element), block scope falls back to suppressing
73
+ * the single targeted line, preserving the original line-based behaviour.
74
+ *
75
+ * `ast` spans must already be normalised to file-relative character indices
76
+ * (see {@link normalizeASTSpans} / {@link convertSpansToCharIndices}).
77
+ */
78
+ export declare function collectIgnoredLineRanges(ast: any, code: string): Set<number>;
39
79
  /**
40
80
  * Finds and returns the full property node (KeyValueProperty) for the given
41
81
  * property name from an ObjectExpression.
@@ -1 +1 @@
1
- {"version":3,"file":"ast-utils.d.ts","sourceRoot":"","sources":["../../../src/extractor/parsers/ast-utils.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,UAAU,EAAc,gBAAgB,EAAE,eAAe,EAAE,MAAM,WAAW,CAAA;AAE1F;;;;;;;;;GASG;AACH,wBAAgB,mBAAmB,CAAE,IAAI,EAAE,MAAM,GAAG,MAAM,CA2BzD;AAED;;;;;;;;;;;;;GAaG;AACH,wBAAgB,iBAAiB,CAAE,IAAI,EAAE,GAAG,EAAE,IAAI,EAAE,MAAM,GAAG,IAAI,CA0BhE;AAED;;;;;;GAMG;AACH,wBAAgB,oBAAoB,CAAE,IAAI,EAAE,MAAM,EAAE,MAAM,EAAE,MAAM,GAAG;IAAE,IAAI,EAAE,MAAM,CAAC;IAAC,MAAM,EAAE,MAAM,CAAA;CAAE,GAAG,SAAS,CAQhH;AAED;;;;;;;;;;;;;;GAcG;AACH,wBAAgB,iBAAiB,CAAE,MAAM,EAAE,gBAAgB,EAAE,QAAQ,EAAE,MAAM,qDAU5E;AAED;;;;;;;;;;;;;GAaG;AACH,wBAAgB,4BAA4B,CAAE,MAAM,EAAE,gBAAgB,EAAE,QAAQ,EAAE,MAAM,GAAG,UAAU,GAAG,SAAS,CAKhH;AAED;;;;;;;GAOG;AACH,wBAAgB,uBAAuB,CAAE,OAAO,EAAE,eAAe,GAAG,OAAO,CAE1E;AAED,KAAK,kBAAkB,GAAG,CAAC,IAAI,EAAE,MAAM,KAAK,MAAM,GAAG,OAAO,GAAG,MAAM,GAAG,SAAS,CAAA;AAEjF;;;;;;;;;;;;GAYG;AACH,wBAAgB,kBAAkB,CAAE,MAAM,EAAE,gBAAgB,EAAE,QAAQ,EAAE,MAAM,EAAE,kBAAkB,CAAC,EAAE,kBAAkB,GAAG,MAAM,GAAG,OAAO,GAAG,MAAM,GAAG,SAAS,CAkB9J"}
1
+ {"version":3,"file":"ast-utils.d.ts","sourceRoot":"","sources":["../../../src/extractor/parsers/ast-utils.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,UAAU,EAAc,gBAAgB,EAAE,eAAe,EAAE,MAAM,WAAW,CAAA;AAE1F;;;;;;;;;GASG;AACH,wBAAgB,mBAAmB,CAAE,IAAI,EAAE,MAAM,GAAG,MAAM,CA2BzD;AAED;;;;;;;;;;;;;GAaG;AACH,wBAAgB,iBAAiB,CAAE,IAAI,EAAE,GAAG,EAAE,IAAI,EAAE,MAAM,GAAG,IAAI,CA0BhE;AAED;;;;;;GAMG;AACH,wBAAgB,oBAAoB,CAAE,IAAI,EAAE,MAAM,EAAE,MAAM,EAAE,MAAM,GAAG;IAAE,IAAI,EAAE,MAAM,CAAC;IAAC,MAAM,EAAE,MAAM,CAAA;CAAE,GAAG,SAAS,CAQhH;AAID;;;;;;;;;;GAUG;AACH,wBAAgB,kBAAkB,CAAE,OAAO,EAAE,MAAM,GAAG,MAAM,EAAE,GAAG,IAAI,CA0BpE;AAED;;;;GAIG;AACH,wBAAgB,yBAAyB,CAAE,IAAI,EAAE,GAAG,EAAE,UAAU,EAAE,MAAM,EAAE,GAAG,IAAI,CAwBhF;AAgBD;;;;;;;;;;;;;;;;;;;;GAoBG;AACH,wBAAgB,wBAAwB,CAAE,GAAG,EAAE,GAAG,EAAE,IAAI,EAAE,MAAM,GAAG,GAAG,CAAC,MAAM,CAAC,CA4D7E;AAED;;;;;;;;;;;;;;GAcG;AACH,wBAAgB,iBAAiB,CAAE,MAAM,EAAE,gBAAgB,EAAE,QAAQ,EAAE,MAAM,qDAU5E;AAED;;;;;;;;;;;;;GAaG;AACH,wBAAgB,4BAA4B,CAAE,MAAM,EAAE,gBAAgB,EAAE,QAAQ,EAAE,MAAM,GAAG,UAAU,GAAG,SAAS,CAKhH;AAED;;;;;;;GAOG;AACH,wBAAgB,uBAAuB,CAAE,OAAO,EAAE,eAAe,GAAG,OAAO,CAE1E;AAED,KAAK,kBAAkB,GAAG,CAAC,IAAI,EAAE,MAAM,KAAK,MAAM,GAAG,OAAO,GAAG,MAAM,GAAG,SAAS,CAAA;AAEjF;;;;;;;;;;;;GAYG;AACH,wBAAgB,kBAAkB,CAAE,MAAM,EAAE,gBAAgB,EAAE,QAAQ,EAAE,MAAM,EAAE,kBAAkB,CAAC,EAAE,kBAAkB,GAAG,MAAM,GAAG,OAAO,GAAG,MAAM,GAAG,SAAS,CAkB9J"}
@@ -1 +1 @@
1
- {"version":3,"file":"instrumenter.d.ts","sourceRoot":"","sources":["../../../src/instrumenter/core/instrumenter.ts"],"names":[],"mappings":"AAQA,OAAO,KAAK,EAAE,MAAM,EAAE,oBAAoB,EAAE,mBAAmB,EAAE,eAAe,EAA6B,sBAAsB,EAAiE,MAAM,gBAAgB,CAAA;AAU1N;;;;;;;;GAQG;AACH,wBAAsB,eAAe,CACnC,MAAM,EAAE,oBAAoB,EAC5B,OAAO,EAAE,mBAAmB,EAC5B,MAAM,GAAE,MAA4B,GACnC,OAAO,CAAC,sBAAsB,CAAC,CAoNjC;AA4wDD;;GAEG;AACH,wBAAsB,kBAAkB,CACtC,UAAU,EAAE,eAAe,EAAE,EAC7B,MAAM,EAAE,oBAAoB,EAC5B,SAAS,CAAC,EAAE,MAAM,EAClB,MAAM,GAAE,MAA4B,GACnC,OAAO,CAAC,IAAI,CAAC,CAoDf"}
1
+ {"version":3,"file":"instrumenter.d.ts","sourceRoot":"","sources":["../../../src/instrumenter/core/instrumenter.ts"],"names":[],"mappings":"AAQA,OAAO,KAAK,EAAE,MAAM,EAAE,oBAAoB,EAAE,mBAAmB,EAAE,eAAe,EAA6B,sBAAsB,EAAiE,MAAM,gBAAgB,CAAA;AAU1N;;;;;;;;GAQG;AACH,wBAAsB,eAAe,CACnC,MAAM,EAAE,oBAAoB,EAC5B,OAAO,EAAE,mBAAmB,EAC5B,MAAM,GAAE,MAA4B,GACnC,OAAO,CAAC,sBAAsB,CAAC,CAoNjC;AAivDD;;GAEG;AACH,wBAAsB,kBAAkB,CACtC,UAAU,EAAE,eAAe,EAAE,EAC7B,MAAM,EAAE,oBAAoB,EAC5B,SAAS,CAAC,EAAE,MAAM,EAClB,MAAM,GAAE,MAA4B,GACnC,OAAO,CAAC,IAAI,CAAC,CAoDf"}
@@ -1 +1 @@
1
- {"version":3,"file":"linter.d.ts","sourceRoot":"","sources":["../src/linter.ts"],"names":[],"mappings":"AAIA,OAAO,EAAE,YAAY,EAAE,MAAM,aAAa,CAAA;AAK1C,OAAO,KAAK,EAAE,oBAAoB,EAAE,MAAM,EAAE,SAAS,EAA6B,MAAM,YAAY,CAAA;AA8SpG,KAAK,cAAc,GAAG;IACpB,QAAQ,EAAE;QAAC;YACT,OAAO,EAAE,MAAM,CAAC;SACjB;KAAC,CAAC;IACH,IAAI,EAAE;QAAC;YACL,OAAO,EAAE,OAAO,CAAC;YACjB,OAAO,EAAE,MAAM,CAAC;YAChB,KAAK,EAAE,MAAM,CAAC,MAAM,EAAE,SAAS,EAAE,CAAC,CAAC;SACpC;KAAC,CAAC;IACH,KAAK,EAAE,CAAC,KAAK,EAAE,KAAK,CAAC,CAAC;CACvB,CAAA;AAED,eAAO,MAAM,uBAAuB,EAAE,MAAM,EAAiD,CAAA;AAC7F,eAAO,MAAM,6BAA6B,EAAE,MAAM,EAAqD,CAAA;AAKvG,qBAAa,MAAO,SAAQ,YAAY,CAAC,cAAc,CAAC;IACtD,OAAO,CAAC,MAAM,CAAsB;IACpC,OAAO,CAAC,MAAM,CAAQ;gBAET,MAAM,EAAE,oBAAoB,EAAE,MAAM,GAAE,MAA4B;IAM/E,SAAS,CAAE,KAAK,EAAE,OAAO;IAanB,GAAG;;;;;;;IAmHT,OAAO,CAAC,uBAAuB;YAOjB,qBAAqB;IAWnC,OAAO,CAAC,kBAAkB;IAM1B,OAAO,CAAC,0BAA0B;YAUpB,qBAAqB;YAgBrB,uBAAuB;CAgBtC;AAED;;;;;;;;;;;;;;;;;;;;;;;;;;;;GA4BG;AACH,wBAAsB,SAAS,CAAE,MAAM,EAAE,oBAAoB;;;;;;GAE5D;AAED,wBAAsB,YAAY,CAChC,MAAM,EAAE,oBAAoB,EAC5B,OAAO,GAAE;IAAE,KAAK,CAAC,EAAE,OAAO,CAAC;IAAC,MAAM,CAAC,EAAE,MAAM,CAAA;CAAO,iBAkCnD"}
1
+ {"version":3,"file":"linter.d.ts","sourceRoot":"","sources":["../src/linter.ts"],"names":[],"mappings":"AAIA,OAAO,EAAE,YAAY,EAAE,MAAM,aAAa,CAAA;AAM1C,OAAO,KAAK,EAAE,oBAAoB,EAAE,MAAM,EAAE,SAAS,EAA6B,MAAM,YAAY,CAAA;AA+QpG,KAAK,cAAc,GAAG;IACpB,QAAQ,EAAE;QAAC;YACT,OAAO,EAAE,MAAM,CAAC;SACjB;KAAC,CAAC;IACH,IAAI,EAAE;QAAC;YACL,OAAO,EAAE,OAAO,CAAC;YACjB,OAAO,EAAE,MAAM,CAAC;YAChB,KAAK,EAAE,MAAM,CAAC,MAAM,EAAE,SAAS,EAAE,CAAC,CAAC;SACpC;KAAC,CAAC;IACH,KAAK,EAAE,CAAC,KAAK,EAAE,KAAK,CAAC,CAAC;CACvB,CAAA;AAED,eAAO,MAAM,uBAAuB,EAAE,MAAM,EAAiD,CAAA;AAC7F,eAAO,MAAM,6BAA6B,EAAE,MAAM,EAAqD,CAAA;AAKvG,qBAAa,MAAO,SAAQ,YAAY,CAAC,cAAc,CAAC;IACtD,OAAO,CAAC,MAAM,CAAsB;IACpC,OAAO,CAAC,MAAM,CAAQ;gBAET,MAAM,EAAE,oBAAoB,EAAE,MAAM,GAAE,MAA4B;IAM/E,SAAS,CAAE,KAAK,EAAE,OAAO;IAanB,GAAG;;;;;;;IAkIT,OAAO,CAAC,uBAAuB;YAOjB,qBAAqB;IAWnC,OAAO,CAAC,kBAAkB;IAM1B,OAAO,CAAC,0BAA0B;YAUpB,qBAAqB;YAgBrB,uBAAuB;CAgBtC;AAED;;;;;;;;;;;;;;;;;;;;;;;;;;;;GA4BG;AACH,wBAAsB,SAAS,CAAE,MAAM,EAAE,oBAAoB;;;;;;GAE5D;AAED,wBAAsB,YAAY,CAChC,MAAM,EAAE,oBAAoB,EAC5B,OAAO,GAAE;IAAE,KAAK,CAAC,EAAE,OAAO,CAAC;IAAC,MAAM,CAAC,EAAE,MAAM,CAAA;CAAO,iBAkCnD"}
@@ -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;AAqDD;;;;;;;;;;;;;;;;;;GAkBG;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;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"}