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 +1 -1
- package/dist/cjs/instrumenter/core/instrumenter.js +313 -36
- package/dist/cjs/instrumenter/core/transformer.js +59 -11
- package/dist/cjs/linter.js +61 -7
- package/dist/esm/cli.js +1 -1
- package/dist/esm/instrumenter/core/instrumenter.js +313 -36
- package/dist/esm/instrumenter/core/transformer.js +59 -11
- package/dist/esm/linter.js +61 -7
- package/package.json +1 -1
- package/types/instrumenter/core/instrumenter.d.ts.map +1 -1
- package/types/instrumenter/core/transformer.d.ts.map +1 -1
- package/types/linter.d.ts.map +1 -1
- package/types/types.d.ts +6 -0
- package/types/types.d.ts.map +1 -1
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.
|
|
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
|
-
|
|
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
|
-
|
|
663
|
-
|
|
664
|
-
|
|
665
|
-
let
|
|
666
|
-
let
|
|
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 === '
|
|
669
|
-
|
|
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
|
-
|
|
672
|
-
|
|
673
|
-
|
|
674
|
-
|
|
675
|
-
|
|
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 (
|
|
682
|
-
|
|
683
|
-
|
|
684
|
-
|
|
685
|
-
|
|
686
|
-
|
|
687
|
-
|
|
688
|
-
|
|
689
|
-
|
|
690
|
-
|
|
691
|
-
|
|
692
|
-
|
|
693
|
-
|
|
694
|
-
|
|
695
|
-
|
|
696
|
-
|
|
697
|
-
|
|
698
|
-
|
|
699
|
-
|
|
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
|
-
|
|
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
|
-
|
|
85
|
-
|
|
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
|
|
119
|
-
|
|
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
|
-
|
|
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
|
-
|
|
286
|
-
|
|
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
|
*/
|
package/dist/esm/linter.js
CHANGED
|
@@ -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
|
-
|
|
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 (
|
|
176
|
-
paramKeys =
|
|
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
|
|
197
|
-
//
|
|
198
|
-
//
|
|
199
|
-
|
|
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;
|