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/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.
|
|
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
|
-
|
|
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
|
-
|
|
665
|
-
|
|
666
|
-
|
|
667
|
-
let
|
|
668
|
-
let
|
|
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 === '
|
|
671
|
-
|
|
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
|
-
|
|
674
|
-
|
|
675
|
-
|
|
676
|
-
|
|
677
|
-
|
|
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 (
|
|
684
|
-
|
|
685
|
-
|
|
686
|
-
|
|
687
|
-
|
|
688
|
-
|
|
689
|
-
|
|
690
|
-
|
|
691
|
-
|
|
692
|
-
|
|
693
|
-
|
|
694
|
-
|
|
695
|
-
|
|
696
|
-
|
|
697
|
-
|
|
698
|
-
|
|
699
|
-
|
|
700
|
-
|
|
701
|
-
|
|
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
|
-
|
|
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
|
-
|
|
87
|
-
|
|
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
|
|
121
|
-
|
|
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
|
-
|
|
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
|
-
|
|
288
|
-
|
|
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
|
*/
|
package/dist/cjs/linter.js
CHANGED
|
@@ -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
|
-
|
|
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 (
|
|
178
|
-
paramKeys =
|
|
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
|
|
199
|
-
//
|
|
200
|
-
//
|
|
201
|
-
|
|
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;
|