i18next-cli 1.47.6 → 1.47.7

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/cjs/cli.js CHANGED
@@ -31,7 +31,7 @@ const program = new commander.Command();
31
31
  program
32
32
  .name('i18next-cli')
33
33
  .description('A unified, high-performance i18next CLI.')
34
- .version('1.47.6'); // This string is replaced with the actual version at build time by rollup
34
+ .version('1.47.7'); // This string is replaced with the actual version at build time by rollup
35
35
  // new: global config override option
36
36
  program.option('-c, --config <path>', 'Path to i18next-cli config file (overrides detection)');
37
37
  program
@@ -645,6 +645,12 @@ function detectJSXInterpolation(node, content, file, config, candidates) {
645
645
  else if (child.type === 'JSXExpressionContainer' && isSimpleJSXExpression(child.expression)) {
646
646
  currentRun.push(child);
647
647
  }
648
+ else if (child.type === 'JSXExpressionContainer' &&
649
+ child.expression?.type === 'ConditionalExpression' &&
650
+ tryParsePluralTernary(child.expression, content)) {
651
+ // Plural ternary expression — include in the run for merged handling
652
+ currentRun.push(child);
653
+ }
648
654
  else {
649
655
  // JSXElement, complex expression, etc. — break the run
650
656
  if (currentRun.length > 0) {
@@ -661,47 +667,150 @@ function detectJSXInterpolation(node, content, file, config, candidates) {
661
667
  const hasExpr = run.some(c => c.type === 'JSXExpressionContainer');
662
668
  if (!hasText || !hasExpr || run.length < 2)
663
669
  continue;
664
- // Build the interpolated text from the run
665
- const usedNames = new Set();
666
- const interpolations = [];
667
- let text = '';
668
- let valid = true;
670
+ // Check if any expression container in this run is a plural ternary
671
+ let pluralChild = null;
672
+ let pluralData = null;
669
673
  for (const child of run) {
670
- if (child.type === 'JSXText') {
671
- text += content.slice(child.span.start, child.span.end);
674
+ if (child.type === 'JSXExpressionContainer' &&
675
+ child.expression?.type === 'ConditionalExpression') {
676
+ const p = tryParsePluralTernary(child.expression, content);
677
+ if (p) {
678
+ pluralChild = child;
679
+ pluralData = p;
680
+ break; // only one plural ternary per run
681
+ }
672
682
  }
673
- else {
674
- const info = resolveExpressionName(child.expression, content, usedNames);
675
- if (!info) {
676
- valid = false;
677
- break;
683
+ }
684
+ if (pluralChild && pluralData) {
685
+ // ── JSX sibling run with embedded plural ternary ──
686
+ const countExpr = pluralData.countExpression;
687
+ // Resolve names for non-count, non-plural expressions
688
+ const usedNames = new Set();
689
+ const extraInterpolations = [];
690
+ const exprNameMap = new Map();
691
+ let valid = true;
692
+ for (const child of run) {
693
+ if (child.type === 'JSXExpressionContainer' && child !== pluralChild) {
694
+ const exprText = content.slice(child.expression.span.start, child.expression.span.end);
695
+ if (exprText === countExpr) {
696
+ exprNameMap.set(child, 'count');
697
+ }
698
+ else {
699
+ const info = resolveExpressionName(child.expression, content, usedNames);
700
+ if (!info) {
701
+ valid = false;
702
+ break;
703
+ }
704
+ exprNameMap.set(child, info.name);
705
+ extraInterpolations.push(info);
706
+ }
707
+ }
708
+ }
709
+ if (!valid)
710
+ continue;
711
+ // Build merged text for each plural form
712
+ const forms = [
713
+ ...(pluralData.zero !== undefined ? ['zero'] : []),
714
+ ...(pluralData.one !== undefined ? ['one'] : []),
715
+ 'other'
716
+ ];
717
+ const formTexts = {};
718
+ for (const form of forms) {
719
+ let text = '';
720
+ for (const child of run) {
721
+ if (child.type === 'JSXText') {
722
+ text += content.slice(child.span.start, child.span.end);
723
+ }
724
+ else if (child === pluralChild) {
725
+ const formText = form === 'zero'
726
+ ? pluralData.zero
727
+ : form === 'one'
728
+ ? pluralData.one
729
+ : pluralData.other;
730
+ text += formText;
731
+ }
732
+ else {
733
+ const name = exprNameMap.get(child);
734
+ text += `{{${name}}}`;
735
+ }
736
+ }
737
+ formTexts[form] = text.trim();
738
+ }
739
+ const spanStart = run[0].span.start;
740
+ const spanEnd = run[run.length - 1].span.end;
741
+ const otherText = formTexts.other;
742
+ if (!otherText)
743
+ continue;
744
+ const candidate = stringDetector.detectCandidate(otherText, spanStart, spanEnd, file, content, config);
745
+ if (candidate) {
746
+ candidate.type = 'jsx-text';
747
+ candidate.content = otherText;
748
+ candidate.pluralForms = {
749
+ countExpression: countExpr,
750
+ zero: formTexts.zero,
751
+ one: formTexts.one,
752
+ other: otherText
753
+ };
754
+ if (extraInterpolations.length > 0) {
755
+ candidate.interpolations = extraInterpolations;
756
+ }
757
+ // Plural + JSX merge is always user-facing
758
+ candidate.confidence = Math.min(1, candidate.confidence + 0.3);
759
+ if (candidate.confidence >= 0.7) {
760
+ // Remove individual candidates that overlap with the merged span
761
+ for (let i = candidates.length - 1; i >= 0; i--) {
762
+ if (candidates[i].offset >= spanStart && candidates[i].endOffset <= spanEnd) {
763
+ candidates.splice(i, 1);
764
+ }
765
+ }
766
+ candidates.push(candidate);
678
767
  }
679
- text += `{{${info.name}}}`;
680
- interpolations.push(info);
681
768
  }
682
769
  }
683
- if (!valid)
684
- continue;
685
- const trimmed = text.trim();
686
- if (!trimmed || interpolations.length === 0)
687
- continue;
688
- const spanStart = run[0].span.start;
689
- const spanEnd = run[run.length - 1].span.end;
690
- const candidate = stringDetector.detectCandidate(trimmed, spanStart, spanEnd, file, content, config);
691
- if (candidate) {
692
- candidate.type = 'jsx-text';
693
- candidate.content = trimmed;
694
- candidate.interpolations = interpolations;
695
- // Mixed text + expressions in JSX is almost always user-facing
696
- candidate.confidence = Math.min(1, candidate.confidence + 0.2);
697
- if (candidate.confidence >= 0.7) {
698
- // Remove individual candidates that overlap with the merged span
699
- for (let i = candidates.length - 1; i >= 0; i--) {
700
- if (candidates[i].offset >= spanStart && candidates[i].endOffset <= spanEnd) {
701
- candidates.splice(i, 1);
770
+ else {
771
+ // ── Original JSX sibling merging (no plural) ──
772
+ // Build the interpolated text from the run
773
+ const usedNames = new Set();
774
+ const interpolations = [];
775
+ let text = '';
776
+ let valid = true;
777
+ for (const child of run) {
778
+ if (child.type === 'JSXText') {
779
+ text += content.slice(child.span.start, child.span.end);
780
+ }
781
+ else {
782
+ const info = resolveExpressionName(child.expression, content, usedNames);
783
+ if (!info) {
784
+ valid = false;
785
+ break;
702
786
  }
787
+ text += `{{${info.name}}}`;
788
+ interpolations.push(info);
789
+ }
790
+ }
791
+ if (!valid)
792
+ continue;
793
+ const trimmed = text.trim();
794
+ if (!trimmed || interpolations.length === 0)
795
+ continue;
796
+ const spanStart = run[0].span.start;
797
+ const spanEnd = run[run.length - 1].span.end;
798
+ const candidate = stringDetector.detectCandidate(trimmed, spanStart, spanEnd, file, content, config);
799
+ if (candidate) {
800
+ candidate.type = 'jsx-text';
801
+ candidate.content = trimmed;
802
+ candidate.interpolations = interpolations;
803
+ // Mixed text + expressions in JSX is almost always user-facing
804
+ candidate.confidence = Math.min(1, candidate.confidence + 0.2);
805
+ if (candidate.confidence >= 0.7) {
806
+ // Remove individual candidates that overlap with the merged span
807
+ for (let i = candidates.length - 1; i >= 0; i--) {
808
+ if (candidates[i].offset >= spanStart && candidates[i].endOffset <= spanEnd) {
809
+ candidates.splice(i, 1);
810
+ }
811
+ }
812
+ candidates.push(candidate);
703
813
  }
704
- candidates.push(candidate);
705
814
  }
706
815
  }
707
816
  }
@@ -838,6 +947,11 @@ function detectPluralPatterns(node, content, file, config, candidates) {
838
947
  // Use the "other" form as the candidate content (with {{count}})
839
948
  const spanStart = node.span.start;
840
949
  const spanEnd = node.span.end;
950
+ // Skip if this ternary is already covered by a wider candidate
951
+ // (e.g. a JSX sibling run that merged surrounding text with this plural)
952
+ const alreadyHandled = candidates.some(c => c.pluralForms && c.offset <= spanStart && c.endOffset >= spanEnd);
953
+ if (alreadyHandled)
954
+ return;
841
955
  const candidate = stringDetector.detectCandidate(plural.other, spanStart, spanEnd, file, content, config);
842
956
  if (candidate) {
843
957
  candidate.type = 'string-literal';
@@ -196,6 +196,12 @@ function buildReplacement(candidate, key, useHookStyle, namespace) {
196
196
  }
197
197
  optionEntries.push(`defaultValue_other: '${escapeString(pf.other)}'`);
198
198
  optionEntries.push(`count: ${pf.countExpression}`);
199
+ // Add extra interpolation variables (e.g. from JSX sibling merging with plurals)
200
+ if (candidate.interpolations?.length) {
201
+ for (const interp of candidate.interpolations) {
202
+ optionEntries.push(interp.name === interp.expression ? interp.name : `${interp.name}: ${interp.expression}`);
203
+ }
204
+ }
199
205
  if (!useHookStyle && namespace) {
200
206
  optionEntries.push(`ns: '${namespace}'`);
201
207
  }
@@ -101,6 +101,34 @@ function isI18nextOptionKey(key) {
101
101
  return true;
102
102
  return false;
103
103
  }
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
+ }
104
132
  // Helper to lint interpolation parameter errors in t() calls
105
133
  function lintInterpolationParams(ast, code, config, translationValues) {
106
134
  const issues = [];
@@ -168,14 +196,27 @@ function lintInterpolationParams(ast, code, config, translationValues) {
168
196
  // Support both .expression and direct node for arguments
169
197
  const arg0raw = node.arguments?.[0];
170
198
  const arg1raw = node.arguments?.[1];
199
+ const arg2raw = node.arguments?.[2];
171
200
  const arg0 = arg0raw?.expression ?? arg0raw;
172
201
  const arg1 = arg1raw?.expression ?? arg1raw;
173
- // Only check interpolation params if the first argument is a string literal (translation key or string)
202
+ const arg2 = arg2raw?.expression ?? arg2raw;
203
+ // Only check interpolation params if the first argument is a string literal
174
204
  if (arg0?.type === 'StringLiteral') {
175
205
  const keyOrStr = arg0.value;
206
+ // Detect 3-argument form: t('key', 'Default string {{foo}}', { foo })
207
+ // In this form arg1 is the default string and arg2 is the params object.
208
+ const isThreeArgForm = arg1?.type === 'StringLiteral' && arg2 !== undefined;
209
+ const translationArg = isThreeArgForm ? arg1 : arg0;
210
+ const paramsArg = isThreeArgForm ? arg2 : arg1;
211
+ // If a params argument exists but is NOT an object literal (e.g. it's a variable),
212
+ // we cannot statically determine its keys — skip the interpolation check to avoid
213
+ // false positives.
214
+ if (paramsArg && paramsArg.type !== 'ObjectExpression') {
215
+ return;
216
+ }
176
217
  let paramKeys = [];
177
- if (arg1?.type === 'ObjectExpression') {
178
- paramKeys = arg1.properties
218
+ if (paramsArg?.type === 'ObjectExpression') {
219
+ paramKeys = paramsArg.properties
179
220
  .map((p) => {
180
221
  // Standard key:value property like { name: "value" }
181
222
  if (p.type === 'KeyValueProperty' && p.key) {
@@ -195,10 +236,17 @@ function lintInterpolationParams(ast, code, config, translationValues) {
195
236
  }
196
237
  if (!Array.isArray(paramKeys))
197
238
  paramKeys = [];
198
- // Resolve the actual translation string to check against:
199
- // fix if the first arg is a lookup key (no interpolation markers of its own)
200
- // and we have a loaded translation map, use the translated value instead.
201
- const resolvedStr = translationValues?.get(keyOrStr) ?? keyOrStr;
239
+ // Resolve the actual translation string:
240
+ // - For 3-arg form: the default string is always arg1 (use it directly)
241
+ // - For 2-arg / 1-arg form: if arg0 is a lookup key (no interpolation markers),
242
+ // try to resolve it from the loaded translation map.
243
+ let resolvedStr;
244
+ if (isThreeArgForm) {
245
+ resolvedStr = translationArg.value;
246
+ }
247
+ else {
248
+ resolvedStr = translationValues?.get(keyOrStr) ?? keyOrStr;
249
+ }
202
250
  const searchText = arg0.raw ?? `"${arg0.value}"`;
203
251
  callSites.push({ translationStr: resolvedStr, searchText, paramKeys });
204
252
  }
@@ -360,6 +408,12 @@ class Linter extends node_events.EventEmitter {
360
408
  // Collect interpolation parameter issues
361
409
  const interpolationIssues = lintInterpolationParams(ast, code, config, translationValues);
362
410
  let allIssues = [...hardcodedStrings, ...interpolationIssues];
411
+ // 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);
414
+ if (ignoredLines.size > 0) {
415
+ allIssues = allIssues.filter(issue => !ignoredLines.has(issue.line));
416
+ }
363
417
  allIssues = await this.runLintOnResultPipeline(file, allIssues, plugins);
364
418
  if (allIssues.length > 0) {
365
419
  totalIssues += allIssues.length;
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.47.6'); // This string is replaced with the actual version at build time by rollup
32
+ .version('1.47.7'); // This string is replaced with the actual version at build time by rollup
33
33
  // new: global config override option
34
34
  program.option('-c, --config <path>', 'Path to i18next-cli config file (overrides detection)');
35
35
  program
@@ -643,6 +643,12 @@ function detectJSXInterpolation(node, content, file, config, candidates) {
643
643
  else if (child.type === 'JSXExpressionContainer' && isSimpleJSXExpression(child.expression)) {
644
644
  currentRun.push(child);
645
645
  }
646
+ else if (child.type === 'JSXExpressionContainer' &&
647
+ child.expression?.type === 'ConditionalExpression' &&
648
+ tryParsePluralTernary(child.expression, content)) {
649
+ // Plural ternary expression — include in the run for merged handling
650
+ currentRun.push(child);
651
+ }
646
652
  else {
647
653
  // JSXElement, complex expression, etc. — break the run
648
654
  if (currentRun.length > 0) {
@@ -659,47 +665,150 @@ function detectJSXInterpolation(node, content, file, config, candidates) {
659
665
  const hasExpr = run.some(c => c.type === 'JSXExpressionContainer');
660
666
  if (!hasText || !hasExpr || run.length < 2)
661
667
  continue;
662
- // Build the interpolated text from the run
663
- const usedNames = new Set();
664
- const interpolations = [];
665
- let text = '';
666
- let valid = true;
668
+ // Check if any expression container in this run is a plural ternary
669
+ let pluralChild = null;
670
+ let pluralData = null;
667
671
  for (const child of run) {
668
- if (child.type === 'JSXText') {
669
- text += content.slice(child.span.start, child.span.end);
672
+ if (child.type === 'JSXExpressionContainer' &&
673
+ child.expression?.type === 'ConditionalExpression') {
674
+ const p = tryParsePluralTernary(child.expression, content);
675
+ if (p) {
676
+ pluralChild = child;
677
+ pluralData = p;
678
+ break; // only one plural ternary per run
679
+ }
670
680
  }
671
- else {
672
- const info = resolveExpressionName(child.expression, content, usedNames);
673
- if (!info) {
674
- valid = false;
675
- break;
681
+ }
682
+ if (pluralChild && pluralData) {
683
+ // ── JSX sibling run with embedded plural ternary ──
684
+ const countExpr = pluralData.countExpression;
685
+ // Resolve names for non-count, non-plural expressions
686
+ const usedNames = new Set();
687
+ const extraInterpolations = [];
688
+ const exprNameMap = new Map();
689
+ let valid = true;
690
+ for (const child of run) {
691
+ if (child.type === 'JSXExpressionContainer' && child !== pluralChild) {
692
+ const exprText = content.slice(child.expression.span.start, child.expression.span.end);
693
+ if (exprText === countExpr) {
694
+ exprNameMap.set(child, 'count');
695
+ }
696
+ else {
697
+ const info = resolveExpressionName(child.expression, content, usedNames);
698
+ if (!info) {
699
+ valid = false;
700
+ break;
701
+ }
702
+ exprNameMap.set(child, info.name);
703
+ extraInterpolations.push(info);
704
+ }
705
+ }
706
+ }
707
+ if (!valid)
708
+ continue;
709
+ // Build merged text for each plural form
710
+ const forms = [
711
+ ...(pluralData.zero !== undefined ? ['zero'] : []),
712
+ ...(pluralData.one !== undefined ? ['one'] : []),
713
+ 'other'
714
+ ];
715
+ const formTexts = {};
716
+ for (const form of forms) {
717
+ let text = '';
718
+ for (const child of run) {
719
+ if (child.type === 'JSXText') {
720
+ text += content.slice(child.span.start, child.span.end);
721
+ }
722
+ else if (child === pluralChild) {
723
+ const formText = form === 'zero'
724
+ ? pluralData.zero
725
+ : form === 'one'
726
+ ? pluralData.one
727
+ : pluralData.other;
728
+ text += formText;
729
+ }
730
+ else {
731
+ const name = exprNameMap.get(child);
732
+ text += `{{${name}}}`;
733
+ }
734
+ }
735
+ formTexts[form] = text.trim();
736
+ }
737
+ const spanStart = run[0].span.start;
738
+ const spanEnd = run[run.length - 1].span.end;
739
+ const otherText = formTexts.other;
740
+ if (!otherText)
741
+ continue;
742
+ const candidate = detectCandidate(otherText, spanStart, spanEnd, file, content, config);
743
+ if (candidate) {
744
+ candidate.type = 'jsx-text';
745
+ candidate.content = otherText;
746
+ candidate.pluralForms = {
747
+ countExpression: countExpr,
748
+ zero: formTexts.zero,
749
+ one: formTexts.one,
750
+ other: otherText
751
+ };
752
+ if (extraInterpolations.length > 0) {
753
+ candidate.interpolations = extraInterpolations;
754
+ }
755
+ // Plural + JSX merge is always user-facing
756
+ candidate.confidence = Math.min(1, candidate.confidence + 0.3);
757
+ if (candidate.confidence >= 0.7) {
758
+ // Remove individual candidates that overlap with the merged span
759
+ for (let i = candidates.length - 1; i >= 0; i--) {
760
+ if (candidates[i].offset >= spanStart && candidates[i].endOffset <= spanEnd) {
761
+ candidates.splice(i, 1);
762
+ }
763
+ }
764
+ candidates.push(candidate);
676
765
  }
677
- text += `{{${info.name}}}`;
678
- interpolations.push(info);
679
766
  }
680
767
  }
681
- if (!valid)
682
- continue;
683
- const trimmed = text.trim();
684
- if (!trimmed || interpolations.length === 0)
685
- continue;
686
- const spanStart = run[0].span.start;
687
- const spanEnd = run[run.length - 1].span.end;
688
- const candidate = detectCandidate(trimmed, spanStart, spanEnd, file, content, config);
689
- if (candidate) {
690
- candidate.type = 'jsx-text';
691
- candidate.content = trimmed;
692
- candidate.interpolations = interpolations;
693
- // Mixed text + expressions in JSX is almost always user-facing
694
- candidate.confidence = Math.min(1, candidate.confidence + 0.2);
695
- if (candidate.confidence >= 0.7) {
696
- // Remove individual candidates that overlap with the merged span
697
- for (let i = candidates.length - 1; i >= 0; i--) {
698
- if (candidates[i].offset >= spanStart && candidates[i].endOffset <= spanEnd) {
699
- candidates.splice(i, 1);
768
+ else {
769
+ // ── Original JSX sibling merging (no plural) ──
770
+ // Build the interpolated text from the run
771
+ const usedNames = new Set();
772
+ const interpolations = [];
773
+ let text = '';
774
+ let valid = true;
775
+ for (const child of run) {
776
+ if (child.type === 'JSXText') {
777
+ text += content.slice(child.span.start, child.span.end);
778
+ }
779
+ else {
780
+ const info = resolveExpressionName(child.expression, content, usedNames);
781
+ if (!info) {
782
+ valid = false;
783
+ break;
700
784
  }
785
+ text += `{{${info.name}}}`;
786
+ interpolations.push(info);
787
+ }
788
+ }
789
+ if (!valid)
790
+ continue;
791
+ const trimmed = text.trim();
792
+ if (!trimmed || interpolations.length === 0)
793
+ continue;
794
+ const spanStart = run[0].span.start;
795
+ const spanEnd = run[run.length - 1].span.end;
796
+ const candidate = detectCandidate(trimmed, spanStart, spanEnd, file, content, config);
797
+ if (candidate) {
798
+ candidate.type = 'jsx-text';
799
+ candidate.content = trimmed;
800
+ candidate.interpolations = interpolations;
801
+ // Mixed text + expressions in JSX is almost always user-facing
802
+ candidate.confidence = Math.min(1, candidate.confidence + 0.2);
803
+ if (candidate.confidence >= 0.7) {
804
+ // Remove individual candidates that overlap with the merged span
805
+ for (let i = candidates.length - 1; i >= 0; i--) {
806
+ if (candidates[i].offset >= spanStart && candidates[i].endOffset <= spanEnd) {
807
+ candidates.splice(i, 1);
808
+ }
809
+ }
810
+ candidates.push(candidate);
701
811
  }
702
- candidates.push(candidate);
703
812
  }
704
813
  }
705
814
  }
@@ -836,6 +945,11 @@ function detectPluralPatterns(node, content, file, config, candidates) {
836
945
  // Use the "other" form as the candidate content (with {{count}})
837
946
  const spanStart = node.span.start;
838
947
  const spanEnd = node.span.end;
948
+ // Skip if this ternary is already covered by a wider candidate
949
+ // (e.g. a JSX sibling run that merged surrounding text with this plural)
950
+ const alreadyHandled = candidates.some(c => c.pluralForms && c.offset <= spanStart && c.endOffset >= spanEnd);
951
+ if (alreadyHandled)
952
+ return;
839
953
  const candidate = detectCandidate(plural.other, spanStart, spanEnd, file, content, config);
840
954
  if (candidate) {
841
955
  candidate.type = 'string-literal';
@@ -194,6 +194,12 @@ function buildReplacement(candidate, key, useHookStyle, namespace) {
194
194
  }
195
195
  optionEntries.push(`defaultValue_other: '${escapeString(pf.other)}'`);
196
196
  optionEntries.push(`count: ${pf.countExpression}`);
197
+ // Add extra interpolation variables (e.g. from JSX sibling merging with plurals)
198
+ if (candidate.interpolations?.length) {
199
+ for (const interp of candidate.interpolations) {
200
+ optionEntries.push(interp.name === interp.expression ? interp.name : `${interp.name}: ${interp.expression}`);
201
+ }
202
+ }
197
203
  if (!useHookStyle && namespace) {
198
204
  optionEntries.push(`ns: '${namespace}'`);
199
205
  }
@@ -99,6 +99,34 @@ function isI18nextOptionKey(key) {
99
99
  return true;
100
100
  return false;
101
101
  }
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
+ }
102
130
  // Helper to lint interpolation parameter errors in t() calls
103
131
  function lintInterpolationParams(ast, code, config, translationValues) {
104
132
  const issues = [];
@@ -166,14 +194,27 @@ function lintInterpolationParams(ast, code, config, translationValues) {
166
194
  // Support both .expression and direct node for arguments
167
195
  const arg0raw = node.arguments?.[0];
168
196
  const arg1raw = node.arguments?.[1];
197
+ const arg2raw = node.arguments?.[2];
169
198
  const arg0 = arg0raw?.expression ?? arg0raw;
170
199
  const arg1 = arg1raw?.expression ?? arg1raw;
171
- // Only check interpolation params if the first argument is a string literal (translation key or string)
200
+ const arg2 = arg2raw?.expression ?? arg2raw;
201
+ // Only check interpolation params if the first argument is a string literal
172
202
  if (arg0?.type === 'StringLiteral') {
173
203
  const keyOrStr = arg0.value;
204
+ // Detect 3-argument form: t('key', 'Default string {{foo}}', { foo })
205
+ // In this form arg1 is the default string and arg2 is the params object.
206
+ const isThreeArgForm = arg1?.type === 'StringLiteral' && arg2 !== undefined;
207
+ const translationArg = isThreeArgForm ? arg1 : arg0;
208
+ const paramsArg = isThreeArgForm ? arg2 : arg1;
209
+ // If a params argument exists but is NOT an object literal (e.g. it's a variable),
210
+ // we cannot statically determine its keys — skip the interpolation check to avoid
211
+ // false positives.
212
+ if (paramsArg && paramsArg.type !== 'ObjectExpression') {
213
+ return;
214
+ }
174
215
  let paramKeys = [];
175
- if (arg1?.type === 'ObjectExpression') {
176
- paramKeys = arg1.properties
216
+ if (paramsArg?.type === 'ObjectExpression') {
217
+ paramKeys = paramsArg.properties
177
218
  .map((p) => {
178
219
  // Standard key:value property like { name: "value" }
179
220
  if (p.type === 'KeyValueProperty' && p.key) {
@@ -193,10 +234,17 @@ function lintInterpolationParams(ast, code, config, translationValues) {
193
234
  }
194
235
  if (!Array.isArray(paramKeys))
195
236
  paramKeys = [];
196
- // Resolve the actual translation string to check against:
197
- // fix if the first arg is a lookup key (no interpolation markers of its own)
198
- // and we have a loaded translation map, use the translated value instead.
199
- const resolvedStr = translationValues?.get(keyOrStr) ?? keyOrStr;
237
+ // Resolve the actual translation string:
238
+ // - For 3-arg form: the default string is always arg1 (use it directly)
239
+ // - For 2-arg / 1-arg form: if arg0 is a lookup key (no interpolation markers),
240
+ // try to resolve it from the loaded translation map.
241
+ let resolvedStr;
242
+ if (isThreeArgForm) {
243
+ resolvedStr = translationArg.value;
244
+ }
245
+ else {
246
+ resolvedStr = translationValues?.get(keyOrStr) ?? keyOrStr;
247
+ }
200
248
  const searchText = arg0.raw ?? `"${arg0.value}"`;
201
249
  callSites.push({ translationStr: resolvedStr, searchText, paramKeys });
202
250
  }
@@ -358,6 +406,12 @@ class Linter extends EventEmitter {
358
406
  // Collect interpolation parameter issues
359
407
  const interpolationIssues = lintInterpolationParams(ast, code, config, translationValues);
360
408
  let allIssues = [...hardcodedStrings, ...interpolationIssues];
409
+ // 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);
412
+ if (ignoredLines.size > 0) {
413
+ allIssues = allIssues.filter(issue => !ignoredLines.has(issue.line));
414
+ }
361
415
  allIssues = await this.runLintOnResultPipeline(file, allIssues, plugins);
362
416
  if (allIssues.length > 0) {
363
417
  totalIssues += allIssues.length;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "i18next-cli",
3
- "version": "1.47.6",
3
+ "version": "1.47.7",
4
4
  "description": "A unified, high-performance i18next CLI.",
5
5
  "type": "module",
6
6
  "bin": {
@@ -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,aAAa,CAAA;AAUvN;;;;;;;;GAQG;AACH,wBAAsB,eAAe,CACnC,MAAM,EAAE,oBAAoB,EAC5B,OAAO,EAAE,mBAAmB,EAC5B,MAAM,GAAE,MAA4B,GACnC,OAAO,CAAC,sBAAsB,CAAC,CA8MjC;AA81CD;;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,aAAa,CAAA;AAUvN;;;;;;;;GAQG;AACH,wBAAsB,eAAe,CACnC,MAAM,EAAE,oBAAoB,EAC5B,OAAO,EAAE,mBAAmB,EAC5B,MAAM,GAAE,MAA4B,GACnC,OAAO,CAAC,sBAAsB,CAAC,CA8MjC;AAs9CD;;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":"transformer.d.ts","sourceRoot":"","sources":["../../../src/instrumenter/core/transformer.ts"],"names":[],"mappings":"AACA,OAAO,KAAK,EAAE,oBAAoB,EAAE,eAAe,EAAE,eAAe,EAAE,iBAAiB,EAAE,kBAAkB,EAAE,MAAM,aAAa,CAAA;AAGhI,UAAU,kBAAkB;IAC1B,QAAQ,CAAC,EAAE,OAAO,CAAA;IAClB,QAAQ,EAAE,OAAO,CAAA;IACjB,qBAAqB,EAAE,OAAO,CAAA;IAC9B,MAAM,EAAE,IAAI,CAAC,oBAAoB,EAAE,SAAS,CAAC,CAAA;IAC7C,6EAA6E;IAC7E,UAAU,CAAC,EAAE,iBAAiB,EAAE,CAAA;IAChC,kEAAkE;IAClE,SAAS,CAAC,EAAE,MAAM,CAAA;IAClB,gFAAgF;IAChF,mBAAmB,CAAC,EAAE,kBAAkB,EAAE,CAAA;CAC3C;AAED;;;;;;;;;;GAUG;AACH,wBAAgB,aAAa,CAC3B,OAAO,EAAE,MAAM,EACf,IAAI,EAAE,MAAM,EACZ,UAAU,EAAE,eAAe,EAAE,EAC7B,OAAO,EAAE,kBAAkB,GAC1B,eAAe,CA6KjB;AAyKD;;GAEG;AACH,wBAAgB,YAAY,CAAE,QAAQ,EAAE,MAAM,EAAE,QAAQ,EAAE,MAAM,EAAE,QAAQ,EAAE,MAAM,GAAG,MAAM,CAwB1F"}
1
+ {"version":3,"file":"transformer.d.ts","sourceRoot":"","sources":["../../../src/instrumenter/core/transformer.ts"],"names":[],"mappings":"AACA,OAAO,KAAK,EAAE,oBAAoB,EAAE,eAAe,EAAE,eAAe,EAAE,iBAAiB,EAAE,kBAAkB,EAAE,MAAM,aAAa,CAAA;AAGhI,UAAU,kBAAkB;IAC1B,QAAQ,CAAC,EAAE,OAAO,CAAA;IAClB,QAAQ,EAAE,OAAO,CAAA;IACjB,qBAAqB,EAAE,OAAO,CAAA;IAC9B,MAAM,EAAE,IAAI,CAAC,oBAAoB,EAAE,SAAS,CAAC,CAAA;IAC7C,6EAA6E;IAC7E,UAAU,CAAC,EAAE,iBAAiB,EAAE,CAAA;IAChC,kEAAkE;IAClE,SAAS,CAAC,EAAE,MAAM,CAAA;IAClB,gFAAgF;IAChF,mBAAmB,CAAC,EAAE,kBAAkB,EAAE,CAAA;CAC3C;AAED;;;;;;;;;;GAUG;AACH,wBAAgB,aAAa,CAC3B,OAAO,EAAE,MAAM,EACf,IAAI,EAAE,MAAM,EACZ,UAAU,EAAE,eAAe,EAAE,EAC7B,OAAO,EAAE,kBAAkB,GAC1B,eAAe,CA6KjB;AAiLD;;GAEG;AACH,wBAAgB,YAAY,CAAE,QAAQ,EAAE,MAAM,EAAE,QAAQ,EAAE,MAAM,EAAE,QAAQ,EAAE,MAAM,GAAG,MAAM,CAwB1F"}
@@ -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,SAAS,CAAA;AAwPjG,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;;;;;;;IA2GT,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;AAK1C,OAAO,KAAK,EAAE,oBAAoB,EAAE,MAAM,EAAE,SAAS,EAA6B,MAAM,SAAS,CAAA;AA8SjG,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"}