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