i18next-cli 1.47.6 → 1.47.8

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.8'); // 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
@@ -454,12 +454,41 @@ function addComponentFromFunctionNode(name, fnNode, content, components) {
454
454
  // Non-translatable JSX attributes are defined in utils/jsx-attributes.ts
455
455
  // and shared with the linter. The instrumenter uses `ignoredAttributeSet`
456
456
  // to skip recursing into non-translatable attribute values.
457
+ /**
458
+ * Returns true when the AST node is a `t(...)` or `i18next.t(...)` call
459
+ * expression — i.e. code that was already instrumented.
460
+ */
461
+ function isTranslationCall(node) {
462
+ const callee = node.callee;
463
+ if (!callee)
464
+ return false;
465
+ // t(...)
466
+ if (callee.type === 'Identifier' && callee.value === 't')
467
+ return true;
468
+ // i18next.t(...)
469
+ if (callee.type === 'MemberExpression' &&
470
+ !callee.computed &&
471
+ callee.property?.type === 'Identifier' &&
472
+ callee.property.value === 't' &&
473
+ callee.object?.type === 'Identifier' &&
474
+ callee.object.value === 'i18next')
475
+ return true;
476
+ return false;
477
+ }
457
478
  /**
458
479
  * Recursively visits AST nodes to find string literals.
459
480
  */
460
481
  function visitNodeForStrings(node, content, file, config, candidates) {
461
482
  if (!node)
462
483
  return;
484
+ // Skip already-instrumented t() / i18next.t() calls entirely so that
485
+ // strings inside the options object (defaultValue_one, etc.) are not
486
+ // picked up as new candidates on a second run.
487
+ if (node.type === 'CallExpression' && isTranslationCall(node))
488
+ return;
489
+ // Skip <Trans> elements (already instrumented)
490
+ if (node.type === 'JSXElement' && isTransComponent(node))
491
+ return;
463
492
  // Skip non-translatable JSX attributes entirely (e.g. className={...})
464
493
  if (node.type === 'JSXAttribute') {
465
494
  const nameNode = node.name;
@@ -633,6 +662,9 @@ function resolveExpressionName(expr, content, usedNames) {
633
662
  function detectJSXInterpolation(node, content, file, config, candidates) {
634
663
  if (!node)
635
664
  return;
665
+ // Skip <Trans> elements (already instrumented)
666
+ if (node.type === 'JSXElement' && isTransComponent(node))
667
+ return;
636
668
  const children = (node.type === 'JSXElement' || node.type === 'JSXFragment') ? node.children : null;
637
669
  if (children?.length > 1) {
638
670
  // Build "runs" of consecutive JSXText + simple-expression containers
@@ -645,6 +677,16 @@ function detectJSXInterpolation(node, content, file, config, candidates) {
645
677
  else if (child.type === 'JSXExpressionContainer' && isSimpleJSXExpression(child.expression)) {
646
678
  currentRun.push(child);
647
679
  }
680
+ else if (child.type === 'JSXExpressionContainer' &&
681
+ child.expression?.type === 'ConditionalExpression' &&
682
+ tryParsePluralTernary(child.expression, content)) {
683
+ // Plural ternary expression — include in the run for merged handling
684
+ currentRun.push(child);
685
+ }
686
+ else if (child.type === 'JSXElement' && isSimpleJSXElement(child)) {
687
+ // Simple HTML element — include in the run for Trans detection
688
+ currentRun.push(child);
689
+ }
648
690
  else {
649
691
  // JSXElement, complex expression, etc. — break the run
650
692
  if (currentRun.length > 0) {
@@ -659,49 +701,226 @@ function detectJSXInterpolation(node, content, file, config, candidates) {
659
701
  for (const run of runs) {
660
702
  const hasText = run.some(c => c.type === 'JSXText' && c.value?.trim());
661
703
  const hasExpr = run.some(c => c.type === 'JSXExpressionContainer');
662
- if (!hasText || !hasExpr || run.length < 2)
704
+ const hasElement = run.some(c => c.type === 'JSXElement');
705
+ // Require at least one text node plus either an expression or element
706
+ if (!hasText || run.length < 2)
663
707
  continue;
664
- // Build the interpolated text from the run
665
- const usedNames = new Set();
666
- const interpolations = [];
667
- let text = '';
668
- let valid = true;
708
+ if (!hasExpr && !hasElement)
709
+ continue;
710
+ // Check if any expression container in this run is a plural ternary
711
+ let pluralChild = null;
712
+ let pluralData = null;
669
713
  for (const child of run) {
670
- if (child.type === 'JSXText') {
671
- text += content.slice(child.span.start, child.span.end);
714
+ if (child.type === 'JSXExpressionContainer' &&
715
+ child.expression?.type === 'ConditionalExpression') {
716
+ const p = tryParsePluralTernary(child.expression, content);
717
+ if (p) {
718
+ pluralChild = child;
719
+ pluralData = p;
720
+ break; // only one plural ternary per run
721
+ }
672
722
  }
673
- else {
674
- const info = resolveExpressionName(child.expression, content, usedNames);
675
- if (!info) {
676
- valid = false;
677
- break;
723
+ }
724
+ if (hasElement) {
725
+ // ── JSX sibling run with nested HTML elements → <Trans> ──
726
+ const spanStart = run[0].span.start;
727
+ const spanEnd = run[run.length - 1].span.end;
728
+ // Build the translation string (with indexed tags) and text-only version (for scoring)
729
+ const usedNames = new Set();
730
+ const interpolations = [];
731
+ let transValue = '';
732
+ let textOnly = '';
733
+ let transContent = '';
734
+ let childIndex = 0;
735
+ let valid = true;
736
+ for (const child of run) {
737
+ if (child.type === 'JSXText') {
738
+ const raw = content.slice(child.span.start, child.span.end);
739
+ transValue += raw;
740
+ textOnly += raw;
741
+ transContent += raw;
742
+ childIndex++;
743
+ }
744
+ else if (child.type === 'JSXExpressionContainer') {
745
+ const info = resolveExpressionName(child.expression, content, usedNames);
746
+ if (!info) {
747
+ valid = false;
748
+ break;
749
+ }
750
+ transValue += `{{${info.name}}}`;
751
+ textOnly += info.name;
752
+ // In <Trans> children, simple expressions become {{ obj }} syntax
753
+ const objExpr = info.name === info.expression ? info.name : `${info.name}: ${info.expression}`;
754
+ transContent += `{{ ${objExpr} }}`;
755
+ interpolations.push(info);
756
+ childIndex++;
757
+ }
758
+ else if (child.type === 'JSXElement') {
759
+ const innerText = getJSXElementTextContent(child, content);
760
+ transValue += `<${childIndex}>${innerText}</${childIndex}>`;
761
+ textOnly += innerText;
762
+ // Keep the original JSX element source for the <Trans> children
763
+ transContent += content.slice(child.span.start, child.span.end);
764
+ childIndex++;
765
+ }
766
+ }
767
+ if (!valid)
768
+ continue;
769
+ const trimmedText = textOnly.trim();
770
+ const trimmedTransValue = transValue.trim();
771
+ if (!trimmedText || !trimmedTransValue)
772
+ continue;
773
+ const candidate = stringDetector.detectCandidate(trimmedText, spanStart, spanEnd, file, content, config);
774
+ if (candidate) {
775
+ candidate.type = 'jsx-mixed';
776
+ candidate.content = transContent.trim();
777
+ candidate.transValue = trimmedTransValue;
778
+ if (interpolations.length > 0) {
779
+ candidate.interpolations = interpolations;
780
+ }
781
+ // Mixed text + elements in JSX is almost always user-facing
782
+ candidate.confidence = Math.min(1, candidate.confidence + 0.25);
783
+ if (candidate.confidence >= 0.7) {
784
+ // Remove individual candidates that overlap with the merged span
785
+ for (let i = candidates.length - 1; i >= 0; i--) {
786
+ if (candidates[i].offset >= spanStart && candidates[i].endOffset <= spanEnd) {
787
+ candidates.splice(i, 1);
788
+ }
789
+ }
790
+ candidates.push(candidate);
678
791
  }
679
- text += `{{${info.name}}}`;
680
- interpolations.push(info);
681
792
  }
682
793
  }
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);
794
+ else if (pluralChild && pluralData) {
795
+ // ── JSX sibling run with embedded plural ternary ──
796
+ const countExpr = pluralData.countExpression;
797
+ // Resolve names for non-count, non-plural expressions
798
+ const usedNames = new Set();
799
+ const extraInterpolations = [];
800
+ const exprNameMap = new Map();
801
+ let valid = true;
802
+ for (const child of run) {
803
+ if (child.type === 'JSXExpressionContainer' && child !== pluralChild) {
804
+ const exprText = content.slice(child.expression.span.start, child.expression.span.end);
805
+ if (exprText === countExpr) {
806
+ exprNameMap.set(child, 'count');
807
+ }
808
+ else {
809
+ const info = resolveExpressionName(child.expression, content, usedNames);
810
+ if (!info) {
811
+ valid = false;
812
+ break;
813
+ }
814
+ exprNameMap.set(child, info.name);
815
+ extraInterpolations.push(info);
702
816
  }
703
817
  }
704
- candidates.push(candidate);
818
+ }
819
+ if (!valid)
820
+ continue;
821
+ // Build merged text for each plural form
822
+ const forms = [
823
+ ...(pluralData.zero !== undefined ? ['zero'] : []),
824
+ ...(pluralData.one !== undefined ? ['one'] : []),
825
+ 'other'
826
+ ];
827
+ const formTexts = {};
828
+ for (const form of forms) {
829
+ let text = '';
830
+ for (const child of run) {
831
+ if (child.type === 'JSXText') {
832
+ text += content.slice(child.span.start, child.span.end);
833
+ }
834
+ else if (child === pluralChild) {
835
+ const formText = form === 'zero'
836
+ ? pluralData.zero
837
+ : form === 'one'
838
+ ? pluralData.one
839
+ : pluralData.other;
840
+ text += formText;
841
+ }
842
+ else {
843
+ const name = exprNameMap.get(child);
844
+ text += `{{${name}}}`;
845
+ }
846
+ }
847
+ formTexts[form] = text.trim();
848
+ }
849
+ const spanStart = run[0].span.start;
850
+ const spanEnd = run[run.length - 1].span.end;
851
+ const otherText = formTexts.other;
852
+ if (!otherText)
853
+ continue;
854
+ const candidate = stringDetector.detectCandidate(otherText, spanStart, spanEnd, file, content, config);
855
+ if (candidate) {
856
+ candidate.type = 'jsx-text';
857
+ candidate.content = otherText;
858
+ candidate.pluralForms = {
859
+ countExpression: countExpr,
860
+ zero: formTexts.zero,
861
+ one: formTexts.one,
862
+ other: otherText
863
+ };
864
+ if (extraInterpolations.length > 0) {
865
+ candidate.interpolations = extraInterpolations;
866
+ }
867
+ // Plural + JSX merge is always user-facing
868
+ candidate.confidence = Math.min(1, candidate.confidence + 0.3);
869
+ if (candidate.confidence >= 0.7) {
870
+ // Remove individual candidates that overlap with the merged span
871
+ for (let i = candidates.length - 1; i >= 0; i--) {
872
+ if (candidates[i].offset >= spanStart && candidates[i].endOffset <= spanEnd) {
873
+ candidates.splice(i, 1);
874
+ }
875
+ }
876
+ candidates.push(candidate);
877
+ }
878
+ }
879
+ }
880
+ else {
881
+ // ── Original JSX sibling merging (text + expressions, no elements) ──
882
+ // Build the interpolated text from the run
883
+ const usedNames = new Set();
884
+ const interpolations = [];
885
+ let text = '';
886
+ let valid = true;
887
+ for (const child of run) {
888
+ if (child.type === 'JSXText') {
889
+ text += content.slice(child.span.start, child.span.end);
890
+ }
891
+ else {
892
+ const info = resolveExpressionName(child.expression, content, usedNames);
893
+ if (!info) {
894
+ valid = false;
895
+ break;
896
+ }
897
+ text += `{{${info.name}}}`;
898
+ interpolations.push(info);
899
+ }
900
+ }
901
+ if (!valid)
902
+ continue;
903
+ const trimmed = text.trim();
904
+ if (!trimmed || interpolations.length === 0)
905
+ continue;
906
+ const spanStart = run[0].span.start;
907
+ const spanEnd = run[run.length - 1].span.end;
908
+ const candidate = stringDetector.detectCandidate(trimmed, spanStart, spanEnd, file, content, config);
909
+ if (candidate) {
910
+ candidate.type = 'jsx-text';
911
+ candidate.content = trimmed;
912
+ candidate.interpolations = interpolations;
913
+ // Mixed text + expressions in JSX is almost always user-facing
914
+ candidate.confidence = Math.min(1, candidate.confidence + 0.2);
915
+ if (candidate.confidence >= 0.7) {
916
+ // Remove individual candidates that overlap with the merged span
917
+ for (let i = candidates.length - 1; i >= 0; i--) {
918
+ if (candidates[i].offset >= spanStart && candidates[i].endOffset <= spanEnd) {
919
+ candidates.splice(i, 1);
920
+ }
921
+ }
922
+ candidates.push(candidate);
923
+ }
705
924
  }
706
925
  }
707
926
  }
@@ -732,6 +951,59 @@ function isSimpleJSXExpression(expr) {
732
951
  return true;
733
952
  return false;
734
953
  }
954
+ /**
955
+ * Returns true when a JSXElement is "simple" enough to be included in a
956
+ * `<Trans>` JSX sibling run. Accepts:
957
+ * - Self-closing elements (`<br />`, `<img />`)
958
+ * - Elements whose only children are `JSXText` nodes
959
+ * Only HTML-like elements (lowercase tag name) are accepted; React
960
+ * components (uppercase, e.g. `<Button />`) break the run.
961
+ */
962
+ function isSimpleJSXElement(node) {
963
+ if (node.type !== 'JSXElement')
964
+ return false;
965
+ const namePart = node.opening?.name;
966
+ if (!namePart)
967
+ return false;
968
+ // Only include HTML-like elements (lowercase first char)
969
+ let tagName = null;
970
+ if (namePart.type === 'Identifier') {
971
+ tagName = namePart.value;
972
+ }
973
+ if (!tagName || tagName[0] !== tagName[0].toLowerCase())
974
+ return false;
975
+ // Self-closing elements are simple
976
+ if (node.opening?.selfClosing)
977
+ return true;
978
+ // Elements with only text children (or empty) are simple
979
+ const children = node.children || [];
980
+ return children.length === 0 || children.every((c) => c.type === 'JSXText');
981
+ }
982
+ /**
983
+ * Returns the text content of a simple JSXElement's children.
984
+ */
985
+ function getJSXElementTextContent(node, content) {
986
+ const children = node.children || [];
987
+ return children
988
+ .filter((c) => c.type === 'JSXText')
989
+ .map((c) => content.slice(c.span.start, c.span.end))
990
+ .join('');
991
+ }
992
+ /**
993
+ * Returns true when a JSXElement is a `<Trans>` component
994
+ * (already instrumented content).
995
+ */
996
+ function isTransComponent(node) {
997
+ const opening = node.opening;
998
+ if (!opening)
999
+ return false;
1000
+ const name = opening.name;
1001
+ if (name?.type === 'Identifier' && name.value === 'Trans')
1002
+ return true;
1003
+ if (name?.type === 'JSXMemberExpression' && name.property?.type === 'Identifier' && name.property.value === 'Trans')
1004
+ return true;
1005
+ return false;
1006
+ }
735
1007
  // ─── Plural conditional pattern detection ────────────────────────────────────
736
1008
  /**
737
1009
  * Extracts the text value from a string literal or static template literal node.
@@ -838,6 +1110,11 @@ function detectPluralPatterns(node, content, file, config, candidates) {
838
1110
  // Use the "other" form as the candidate content (with {{count}})
839
1111
  const spanStart = node.span.start;
840
1112
  const spanEnd = node.span.end;
1113
+ // Skip if this ternary is already covered by a wider candidate
1114
+ // (e.g. a JSX sibling run that merged surrounding text with this plural)
1115
+ const alreadyHandled = candidates.some(c => c.pluralForms && c.offset <= spanStart && c.endOffset >= spanEnd);
1116
+ if (alreadyHandled)
1117
+ return;
841
1118
  const candidate = stringDetector.detectCandidate(plural.other, spanStart, spanEnd, file, content, config);
842
1119
  if (candidate) {
843
1120
  candidate.type = 'string-literal';
@@ -1451,7 +1728,7 @@ async function writeExtractedKeys(candidates, config, namespace, logger$1 = new
1451
1728
  translations[`${candidate.key}_other`] = pf.other;
1452
1729
  }
1453
1730
  else {
1454
- translations[candidate.key] = candidate.content;
1731
+ translations[candidate.key] = candidate.transValue ?? candidate.content;
1455
1732
  }
1456
1733
  }
1457
1734
  }
@@ -29,6 +29,7 @@ function transformFile(content, file, candidates, options) {
29
29
  const transformedComponents = new Set();
30
30
  let hasComponentCandidates = false;
31
31
  let hasNonComponentCandidates = false;
32
+ let hasTransCandidates = false;
32
33
  // ── Language-change site injections ────────────────────────────────────
33
34
  const languageChangeSites = options.languageChangeSites || [];
34
35
  // Track components that need `i18n` from useTranslation()
@@ -82,9 +83,15 @@ function transformFile(content, file, candidates, options) {
82
83
  if (replacement) {
83
84
  s.overwrite(candidate.offset, candidate.endOffset, replacement);
84
85
  transformCount++;
86
+ if (candidate.type === 'jsx-mixed') {
87
+ hasTransCandidates = true;
88
+ }
85
89
  if (candidate.insideComponent) {
86
- transformedComponents.add(candidate.insideComponent);
87
- hasComponentCandidates = true;
90
+ // jsx-mixed candidates use <Trans>, not t(), so they don't need useTranslation
91
+ if (candidate.type !== 'jsx-mixed') {
92
+ transformedComponents.add(candidate.insideComponent);
93
+ hasComponentCandidates = true;
94
+ }
88
95
  }
89
96
  else {
90
97
  hasNonComponentCandidates = true;
@@ -108,17 +115,20 @@ function transformFile(content, file, candidates, options) {
108
115
  const indent = detectIndent(content, comp.bodyStart);
109
116
  const defaultNS = options.config.extract?.defaultNS ?? 'translation';
110
117
  const nsArg = (options.namespace && options.namespace !== defaultNS) ? `'${options.namespace}'` : '';
111
- // Build destructuring: include `t` if the component has string candidates,
118
+ // Build destructuring: include `t` if the component has string candidates
119
+ // (but not jsx-mixed which use <Trans>),
112
120
  // include `i18n` if the component has language-change sites.
113
- const needsT = highConfidenceCandidates.some(c => c.insideComponent === comp.name);
121
+ const needsT = highConfidenceCandidates.some(c => c.insideComponent === comp.name && c.type !== 'jsx-mixed');
114
122
  const needsI18n = componentsNeedingI18n.has(comp.name);
115
123
  const parts = [];
116
124
  if (needsT)
117
125
  parts.push('t');
118
126
  if (needsI18n)
119
127
  parts.push('i18n');
120
- if (parts.length === 0)
121
- parts.push('t'); // fallback
128
+ // Skip if component needs neither t nor i18n
129
+ // (e.g. component only has jsx-mixed / <Trans> candidates)
130
+ if (!needsT && !needsI18n)
131
+ continue;
122
132
  const destructured = `{ ${parts.join(', ')} }`;
123
133
  s.appendRight(comp.bodyStart + 1, `\n${indent}const ${destructured} = useTranslation(${nsArg})`);
124
134
  injections.hookInjected = true;
@@ -148,7 +158,8 @@ function transformFile(content, file, candidates, options) {
148
158
  // Add import statements
149
159
  addImportStatements(s, content, {
150
160
  needsUseTranslation: hasComponentCandidates && options.hasReact,
151
- needsI18next: hasNonComponentCandidates || !options.hasReact
161
+ needsI18next: hasNonComponentCandidates || !options.hasReact,
162
+ needsTrans: hasTransCandidates && options.hasReact
152
163
  });
153
164
  injections.importAdded = true;
154
165
  }
@@ -196,6 +207,12 @@ function buildReplacement(candidate, key, useHookStyle, namespace) {
196
207
  }
197
208
  optionEntries.push(`defaultValue_other: '${escapeString(pf.other)}'`);
198
209
  optionEntries.push(`count: ${pf.countExpression}`);
210
+ // Add extra interpolation variables (e.g. from JSX sibling merging with plurals)
211
+ if (candidate.interpolations?.length) {
212
+ for (const interp of candidate.interpolations) {
213
+ optionEntries.push(interp.name === interp.expression ? interp.name : `${interp.name}: ${interp.expression}`);
214
+ }
215
+ }
199
216
  if (!useHookStyle && namespace) {
200
217
  optionEntries.push(`ns: '${namespace}'`);
201
218
  }
@@ -226,7 +243,8 @@ function buildReplacement(candidate, key, useHookStyle, namespace) {
226
243
  return `{${tCall}}`;
227
244
  case 'jsx-mixed':
228
245
  if (useHookStyle) {
229
- return `<Trans i18nKey="${key}">${candidate.content}</Trans>`;
246
+ const nsAttr = namespace ? ` ns="${namespace}"` : '';
247
+ return `<Trans i18nKey="${key}"${nsAttr}>${candidate.content}</Trans>`;
230
248
  }
231
249
  return candidate.content;
232
250
  case 'template-literal':
@@ -280,12 +298,24 @@ function detectIndent(content, braceOffset) {
280
298
  return ' ';
281
299
  }
282
300
  /**
283
- * Adds necessary import statements (useTranslation and/or i18next).
301
+ * Adds necessary import statements (useTranslation, Trans, and/or i18next).
284
302
  */
285
303
  function addImportStatements(s, content, needs) {
286
304
  let importStatement = '';
287
- if (needs.needsUseTranslation && !hasImport(content, 'react-i18next')) {
288
- importStatement += "import { useTranslation } from 'react-i18next'\n";
305
+ // Build a combined react-i18next import
306
+ const reactI18nextImports = [];
307
+ if (needs.needsUseTranslation)
308
+ reactI18nextImports.push('useTranslation');
309
+ if (needs.needsTrans)
310
+ reactI18nextImports.push('Trans');
311
+ if (reactI18nextImports.length > 0) {
312
+ if (!hasImport(content, 'react-i18next')) {
313
+ importStatement += `import { ${reactI18nextImports.join(', ')} } from 'react-i18next'\n`;
314
+ }
315
+ else {
316
+ // react-i18next is already imported — augment with any missing named exports
317
+ augmentReactI18nextImport(s, content, reactI18nextImports);
318
+ }
289
319
  }
290
320
  if (needs.needsI18next && !hasImport(content, 'i18next')) {
291
321
  importStatement += "import i18next from 'i18next'\n";
@@ -310,6 +340,24 @@ function addImportStatements(s, content, needs) {
310
340
  }
311
341
  s.appendRight(insertPos, importStatement);
312
342
  }
343
+ /**
344
+ * Augments an existing `import { ... } from 'react-i18next'` with any missing
345
+ * named exports (e.g. adds `Trans` when only `useTranslation` is imported).
346
+ */
347
+ function augmentReactI18nextImport(s, content, needed) {
348
+ const importMatch = /import\s*\{([^}]*)\}\s*from\s*['"]react-i18next['"]/.exec(content);
349
+ if (!importMatch)
350
+ return;
351
+ const existingImports = importMatch[1].split(',').map(x => x.trim()).filter(Boolean);
352
+ const toAdd = needed.filter(n => !existingImports.includes(n));
353
+ if (toAdd.length === 0)
354
+ return;
355
+ const newImports = [...existingImports, ...toAdd].join(', ');
356
+ const newImportStatement = `import { ${newImports} } from 'react-i18next'`;
357
+ const matchStart = importMatch.index;
358
+ const matchEnd = matchStart + importMatch[0].length;
359
+ s.overwrite(matchStart, matchEnd, newImportStatement);
360
+ }
313
361
  /**
314
362
  * Generates a unified diff showing what changed.
315
363
  */
@@ -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;