i18next-cli 1.47.6 → 1.47.7
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/cjs/cli.js +1 -1
- package/dist/cjs/instrumenter/core/instrumenter.js +148 -34
- package/dist/cjs/instrumenter/core/transformer.js +6 -0
- package/dist/cjs/linter.js +61 -7
- package/dist/esm/cli.js +1 -1
- package/dist/esm/instrumenter/core/instrumenter.js +148 -34
- package/dist/esm/instrumenter/core/transformer.js +6 -0
- 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/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.7'); // This string is replaced with the actual version at build time by rollup
|
|
35
35
|
// new: global config override option
|
|
36
36
|
program.option('-c, --config <path>', 'Path to i18next-cli config file (overrides detection)');
|
|
37
37
|
program
|
|
@@ -645,6 +645,12 @@ function detectJSXInterpolation(node, content, file, config, candidates) {
|
|
|
645
645
|
else if (child.type === 'JSXExpressionContainer' && isSimpleJSXExpression(child.expression)) {
|
|
646
646
|
currentRun.push(child);
|
|
647
647
|
}
|
|
648
|
+
else if (child.type === 'JSXExpressionContainer' &&
|
|
649
|
+
child.expression?.type === 'ConditionalExpression' &&
|
|
650
|
+
tryParsePluralTernary(child.expression, content)) {
|
|
651
|
+
// Plural ternary expression — include in the run for merged handling
|
|
652
|
+
currentRun.push(child);
|
|
653
|
+
}
|
|
648
654
|
else {
|
|
649
655
|
// JSXElement, complex expression, etc. — break the run
|
|
650
656
|
if (currentRun.length > 0) {
|
|
@@ -661,47 +667,150 @@ function detectJSXInterpolation(node, content, file, config, candidates) {
|
|
|
661
667
|
const hasExpr = run.some(c => c.type === 'JSXExpressionContainer');
|
|
662
668
|
if (!hasText || !hasExpr || run.length < 2)
|
|
663
669
|
continue;
|
|
664
|
-
//
|
|
665
|
-
|
|
666
|
-
|
|
667
|
-
let text = '';
|
|
668
|
-
let valid = true;
|
|
670
|
+
// Check if any expression container in this run is a plural ternary
|
|
671
|
+
let pluralChild = null;
|
|
672
|
+
let pluralData = null;
|
|
669
673
|
for (const child of run) {
|
|
670
|
-
if (child.type === '
|
|
671
|
-
|
|
674
|
+
if (child.type === 'JSXExpressionContainer' &&
|
|
675
|
+
child.expression?.type === 'ConditionalExpression') {
|
|
676
|
+
const p = tryParsePluralTernary(child.expression, content);
|
|
677
|
+
if (p) {
|
|
678
|
+
pluralChild = child;
|
|
679
|
+
pluralData = p;
|
|
680
|
+
break; // only one plural ternary per run
|
|
681
|
+
}
|
|
672
682
|
}
|
|
673
|
-
|
|
674
|
-
|
|
675
|
-
|
|
676
|
-
|
|
677
|
-
|
|
683
|
+
}
|
|
684
|
+
if (pluralChild && pluralData) {
|
|
685
|
+
// ── JSX sibling run with embedded plural ternary ──
|
|
686
|
+
const countExpr = pluralData.countExpression;
|
|
687
|
+
// Resolve names for non-count, non-plural expressions
|
|
688
|
+
const usedNames = new Set();
|
|
689
|
+
const extraInterpolations = [];
|
|
690
|
+
const exprNameMap = new Map();
|
|
691
|
+
let valid = true;
|
|
692
|
+
for (const child of run) {
|
|
693
|
+
if (child.type === 'JSXExpressionContainer' && child !== pluralChild) {
|
|
694
|
+
const exprText = content.slice(child.expression.span.start, child.expression.span.end);
|
|
695
|
+
if (exprText === countExpr) {
|
|
696
|
+
exprNameMap.set(child, 'count');
|
|
697
|
+
}
|
|
698
|
+
else {
|
|
699
|
+
const info = resolveExpressionName(child.expression, content, usedNames);
|
|
700
|
+
if (!info) {
|
|
701
|
+
valid = false;
|
|
702
|
+
break;
|
|
703
|
+
}
|
|
704
|
+
exprNameMap.set(child, info.name);
|
|
705
|
+
extraInterpolations.push(info);
|
|
706
|
+
}
|
|
707
|
+
}
|
|
708
|
+
}
|
|
709
|
+
if (!valid)
|
|
710
|
+
continue;
|
|
711
|
+
// Build merged text for each plural form
|
|
712
|
+
const forms = [
|
|
713
|
+
...(pluralData.zero !== undefined ? ['zero'] : []),
|
|
714
|
+
...(pluralData.one !== undefined ? ['one'] : []),
|
|
715
|
+
'other'
|
|
716
|
+
];
|
|
717
|
+
const formTexts = {};
|
|
718
|
+
for (const form of forms) {
|
|
719
|
+
let text = '';
|
|
720
|
+
for (const child of run) {
|
|
721
|
+
if (child.type === 'JSXText') {
|
|
722
|
+
text += content.slice(child.span.start, child.span.end);
|
|
723
|
+
}
|
|
724
|
+
else if (child === pluralChild) {
|
|
725
|
+
const formText = form === 'zero'
|
|
726
|
+
? pluralData.zero
|
|
727
|
+
: form === 'one'
|
|
728
|
+
? pluralData.one
|
|
729
|
+
: pluralData.other;
|
|
730
|
+
text += formText;
|
|
731
|
+
}
|
|
732
|
+
else {
|
|
733
|
+
const name = exprNameMap.get(child);
|
|
734
|
+
text += `{{${name}}}`;
|
|
735
|
+
}
|
|
736
|
+
}
|
|
737
|
+
formTexts[form] = text.trim();
|
|
738
|
+
}
|
|
739
|
+
const spanStart = run[0].span.start;
|
|
740
|
+
const spanEnd = run[run.length - 1].span.end;
|
|
741
|
+
const otherText = formTexts.other;
|
|
742
|
+
if (!otherText)
|
|
743
|
+
continue;
|
|
744
|
+
const candidate = stringDetector.detectCandidate(otherText, spanStart, spanEnd, file, content, config);
|
|
745
|
+
if (candidate) {
|
|
746
|
+
candidate.type = 'jsx-text';
|
|
747
|
+
candidate.content = otherText;
|
|
748
|
+
candidate.pluralForms = {
|
|
749
|
+
countExpression: countExpr,
|
|
750
|
+
zero: formTexts.zero,
|
|
751
|
+
one: formTexts.one,
|
|
752
|
+
other: otherText
|
|
753
|
+
};
|
|
754
|
+
if (extraInterpolations.length > 0) {
|
|
755
|
+
candidate.interpolations = extraInterpolations;
|
|
756
|
+
}
|
|
757
|
+
// Plural + JSX merge is always user-facing
|
|
758
|
+
candidate.confidence = Math.min(1, candidate.confidence + 0.3);
|
|
759
|
+
if (candidate.confidence >= 0.7) {
|
|
760
|
+
// Remove individual candidates that overlap with the merged span
|
|
761
|
+
for (let i = candidates.length - 1; i >= 0; i--) {
|
|
762
|
+
if (candidates[i].offset >= spanStart && candidates[i].endOffset <= spanEnd) {
|
|
763
|
+
candidates.splice(i, 1);
|
|
764
|
+
}
|
|
765
|
+
}
|
|
766
|
+
candidates.push(candidate);
|
|
678
767
|
}
|
|
679
|
-
text += `{{${info.name}}}`;
|
|
680
|
-
interpolations.push(info);
|
|
681
768
|
}
|
|
682
769
|
}
|
|
683
|
-
|
|
684
|
-
|
|
685
|
-
|
|
686
|
-
|
|
687
|
-
|
|
688
|
-
|
|
689
|
-
|
|
690
|
-
|
|
691
|
-
|
|
692
|
-
|
|
693
|
-
|
|
694
|
-
|
|
695
|
-
|
|
696
|
-
|
|
697
|
-
|
|
698
|
-
|
|
699
|
-
for (let i = candidates.length - 1; i >= 0; i--) {
|
|
700
|
-
if (candidates[i].offset >= spanStart && candidates[i].endOffset <= spanEnd) {
|
|
701
|
-
candidates.splice(i, 1);
|
|
770
|
+
else {
|
|
771
|
+
// ── Original JSX sibling merging (no plural) ──
|
|
772
|
+
// Build the interpolated text from the run
|
|
773
|
+
const usedNames = new Set();
|
|
774
|
+
const interpolations = [];
|
|
775
|
+
let text = '';
|
|
776
|
+
let valid = true;
|
|
777
|
+
for (const child of run) {
|
|
778
|
+
if (child.type === 'JSXText') {
|
|
779
|
+
text += content.slice(child.span.start, child.span.end);
|
|
780
|
+
}
|
|
781
|
+
else {
|
|
782
|
+
const info = resolveExpressionName(child.expression, content, usedNames);
|
|
783
|
+
if (!info) {
|
|
784
|
+
valid = false;
|
|
785
|
+
break;
|
|
702
786
|
}
|
|
787
|
+
text += `{{${info.name}}}`;
|
|
788
|
+
interpolations.push(info);
|
|
789
|
+
}
|
|
790
|
+
}
|
|
791
|
+
if (!valid)
|
|
792
|
+
continue;
|
|
793
|
+
const trimmed = text.trim();
|
|
794
|
+
if (!trimmed || interpolations.length === 0)
|
|
795
|
+
continue;
|
|
796
|
+
const spanStart = run[0].span.start;
|
|
797
|
+
const spanEnd = run[run.length - 1].span.end;
|
|
798
|
+
const candidate = stringDetector.detectCandidate(trimmed, spanStart, spanEnd, file, content, config);
|
|
799
|
+
if (candidate) {
|
|
800
|
+
candidate.type = 'jsx-text';
|
|
801
|
+
candidate.content = trimmed;
|
|
802
|
+
candidate.interpolations = interpolations;
|
|
803
|
+
// Mixed text + expressions in JSX is almost always user-facing
|
|
804
|
+
candidate.confidence = Math.min(1, candidate.confidence + 0.2);
|
|
805
|
+
if (candidate.confidence >= 0.7) {
|
|
806
|
+
// Remove individual candidates that overlap with the merged span
|
|
807
|
+
for (let i = candidates.length - 1; i >= 0; i--) {
|
|
808
|
+
if (candidates[i].offset >= spanStart && candidates[i].endOffset <= spanEnd) {
|
|
809
|
+
candidates.splice(i, 1);
|
|
810
|
+
}
|
|
811
|
+
}
|
|
812
|
+
candidates.push(candidate);
|
|
703
813
|
}
|
|
704
|
-
candidates.push(candidate);
|
|
705
814
|
}
|
|
706
815
|
}
|
|
707
816
|
}
|
|
@@ -838,6 +947,11 @@ function detectPluralPatterns(node, content, file, config, candidates) {
|
|
|
838
947
|
// Use the "other" form as the candidate content (with {{count}})
|
|
839
948
|
const spanStart = node.span.start;
|
|
840
949
|
const spanEnd = node.span.end;
|
|
950
|
+
// Skip if this ternary is already covered by a wider candidate
|
|
951
|
+
// (e.g. a JSX sibling run that merged surrounding text with this plural)
|
|
952
|
+
const alreadyHandled = candidates.some(c => c.pluralForms && c.offset <= spanStart && c.endOffset >= spanEnd);
|
|
953
|
+
if (alreadyHandled)
|
|
954
|
+
return;
|
|
841
955
|
const candidate = stringDetector.detectCandidate(plural.other, spanStart, spanEnd, file, content, config);
|
|
842
956
|
if (candidate) {
|
|
843
957
|
candidate.type = 'string-literal';
|
|
@@ -196,6 +196,12 @@ function buildReplacement(candidate, key, useHookStyle, namespace) {
|
|
|
196
196
|
}
|
|
197
197
|
optionEntries.push(`defaultValue_other: '${escapeString(pf.other)}'`);
|
|
198
198
|
optionEntries.push(`count: ${pf.countExpression}`);
|
|
199
|
+
// Add extra interpolation variables (e.g. from JSX sibling merging with plurals)
|
|
200
|
+
if (candidate.interpolations?.length) {
|
|
201
|
+
for (const interp of candidate.interpolations) {
|
|
202
|
+
optionEntries.push(interp.name === interp.expression ? interp.name : `${interp.name}: ${interp.expression}`);
|
|
203
|
+
}
|
|
204
|
+
}
|
|
199
205
|
if (!useHookStyle && namespace) {
|
|
200
206
|
optionEntries.push(`ns: '${namespace}'`);
|
|
201
207
|
}
|
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;
|
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.7'); // This string is replaced with the actual version at build time by rollup
|
|
33
33
|
// new: global config override option
|
|
34
34
|
program.option('-c, --config <path>', 'Path to i18next-cli config file (overrides detection)');
|
|
35
35
|
program
|
|
@@ -643,6 +643,12 @@ function detectJSXInterpolation(node, content, file, config, candidates) {
|
|
|
643
643
|
else if (child.type === 'JSXExpressionContainer' && isSimpleJSXExpression(child.expression)) {
|
|
644
644
|
currentRun.push(child);
|
|
645
645
|
}
|
|
646
|
+
else if (child.type === 'JSXExpressionContainer' &&
|
|
647
|
+
child.expression?.type === 'ConditionalExpression' &&
|
|
648
|
+
tryParsePluralTernary(child.expression, content)) {
|
|
649
|
+
// Plural ternary expression — include in the run for merged handling
|
|
650
|
+
currentRun.push(child);
|
|
651
|
+
}
|
|
646
652
|
else {
|
|
647
653
|
// JSXElement, complex expression, etc. — break the run
|
|
648
654
|
if (currentRun.length > 0) {
|
|
@@ -659,47 +665,150 @@ function detectJSXInterpolation(node, content, file, config, candidates) {
|
|
|
659
665
|
const hasExpr = run.some(c => c.type === 'JSXExpressionContainer');
|
|
660
666
|
if (!hasText || !hasExpr || run.length < 2)
|
|
661
667
|
continue;
|
|
662
|
-
//
|
|
663
|
-
|
|
664
|
-
|
|
665
|
-
let text = '';
|
|
666
|
-
let valid = true;
|
|
668
|
+
// Check if any expression container in this run is a plural ternary
|
|
669
|
+
let pluralChild = null;
|
|
670
|
+
let pluralData = null;
|
|
667
671
|
for (const child of run) {
|
|
668
|
-
if (child.type === '
|
|
669
|
-
|
|
672
|
+
if (child.type === 'JSXExpressionContainer' &&
|
|
673
|
+
child.expression?.type === 'ConditionalExpression') {
|
|
674
|
+
const p = tryParsePluralTernary(child.expression, content);
|
|
675
|
+
if (p) {
|
|
676
|
+
pluralChild = child;
|
|
677
|
+
pluralData = p;
|
|
678
|
+
break; // only one plural ternary per run
|
|
679
|
+
}
|
|
670
680
|
}
|
|
671
|
-
|
|
672
|
-
|
|
673
|
-
|
|
674
|
-
|
|
675
|
-
|
|
681
|
+
}
|
|
682
|
+
if (pluralChild && pluralData) {
|
|
683
|
+
// ── JSX sibling run with embedded plural ternary ──
|
|
684
|
+
const countExpr = pluralData.countExpression;
|
|
685
|
+
// Resolve names for non-count, non-plural expressions
|
|
686
|
+
const usedNames = new Set();
|
|
687
|
+
const extraInterpolations = [];
|
|
688
|
+
const exprNameMap = new Map();
|
|
689
|
+
let valid = true;
|
|
690
|
+
for (const child of run) {
|
|
691
|
+
if (child.type === 'JSXExpressionContainer' && child !== pluralChild) {
|
|
692
|
+
const exprText = content.slice(child.expression.span.start, child.expression.span.end);
|
|
693
|
+
if (exprText === countExpr) {
|
|
694
|
+
exprNameMap.set(child, 'count');
|
|
695
|
+
}
|
|
696
|
+
else {
|
|
697
|
+
const info = resolveExpressionName(child.expression, content, usedNames);
|
|
698
|
+
if (!info) {
|
|
699
|
+
valid = false;
|
|
700
|
+
break;
|
|
701
|
+
}
|
|
702
|
+
exprNameMap.set(child, info.name);
|
|
703
|
+
extraInterpolations.push(info);
|
|
704
|
+
}
|
|
705
|
+
}
|
|
706
|
+
}
|
|
707
|
+
if (!valid)
|
|
708
|
+
continue;
|
|
709
|
+
// Build merged text for each plural form
|
|
710
|
+
const forms = [
|
|
711
|
+
...(pluralData.zero !== undefined ? ['zero'] : []),
|
|
712
|
+
...(pluralData.one !== undefined ? ['one'] : []),
|
|
713
|
+
'other'
|
|
714
|
+
];
|
|
715
|
+
const formTexts = {};
|
|
716
|
+
for (const form of forms) {
|
|
717
|
+
let text = '';
|
|
718
|
+
for (const child of run) {
|
|
719
|
+
if (child.type === 'JSXText') {
|
|
720
|
+
text += content.slice(child.span.start, child.span.end);
|
|
721
|
+
}
|
|
722
|
+
else if (child === pluralChild) {
|
|
723
|
+
const formText = form === 'zero'
|
|
724
|
+
? pluralData.zero
|
|
725
|
+
: form === 'one'
|
|
726
|
+
? pluralData.one
|
|
727
|
+
: pluralData.other;
|
|
728
|
+
text += formText;
|
|
729
|
+
}
|
|
730
|
+
else {
|
|
731
|
+
const name = exprNameMap.get(child);
|
|
732
|
+
text += `{{${name}}}`;
|
|
733
|
+
}
|
|
734
|
+
}
|
|
735
|
+
formTexts[form] = text.trim();
|
|
736
|
+
}
|
|
737
|
+
const spanStart = run[0].span.start;
|
|
738
|
+
const spanEnd = run[run.length - 1].span.end;
|
|
739
|
+
const otherText = formTexts.other;
|
|
740
|
+
if (!otherText)
|
|
741
|
+
continue;
|
|
742
|
+
const candidate = detectCandidate(otherText, spanStart, spanEnd, file, content, config);
|
|
743
|
+
if (candidate) {
|
|
744
|
+
candidate.type = 'jsx-text';
|
|
745
|
+
candidate.content = otherText;
|
|
746
|
+
candidate.pluralForms = {
|
|
747
|
+
countExpression: countExpr,
|
|
748
|
+
zero: formTexts.zero,
|
|
749
|
+
one: formTexts.one,
|
|
750
|
+
other: otherText
|
|
751
|
+
};
|
|
752
|
+
if (extraInterpolations.length > 0) {
|
|
753
|
+
candidate.interpolations = extraInterpolations;
|
|
754
|
+
}
|
|
755
|
+
// Plural + JSX merge is always user-facing
|
|
756
|
+
candidate.confidence = Math.min(1, candidate.confidence + 0.3);
|
|
757
|
+
if (candidate.confidence >= 0.7) {
|
|
758
|
+
// Remove individual candidates that overlap with the merged span
|
|
759
|
+
for (let i = candidates.length - 1; i >= 0; i--) {
|
|
760
|
+
if (candidates[i].offset >= spanStart && candidates[i].endOffset <= spanEnd) {
|
|
761
|
+
candidates.splice(i, 1);
|
|
762
|
+
}
|
|
763
|
+
}
|
|
764
|
+
candidates.push(candidate);
|
|
676
765
|
}
|
|
677
|
-
text += `{{${info.name}}}`;
|
|
678
|
-
interpolations.push(info);
|
|
679
766
|
}
|
|
680
767
|
}
|
|
681
|
-
|
|
682
|
-
|
|
683
|
-
|
|
684
|
-
|
|
685
|
-
|
|
686
|
-
|
|
687
|
-
|
|
688
|
-
|
|
689
|
-
|
|
690
|
-
|
|
691
|
-
|
|
692
|
-
|
|
693
|
-
|
|
694
|
-
|
|
695
|
-
|
|
696
|
-
|
|
697
|
-
for (let i = candidates.length - 1; i >= 0; i--) {
|
|
698
|
-
if (candidates[i].offset >= spanStart && candidates[i].endOffset <= spanEnd) {
|
|
699
|
-
candidates.splice(i, 1);
|
|
768
|
+
else {
|
|
769
|
+
// ── Original JSX sibling merging (no plural) ──
|
|
770
|
+
// Build the interpolated text from the run
|
|
771
|
+
const usedNames = new Set();
|
|
772
|
+
const interpolations = [];
|
|
773
|
+
let text = '';
|
|
774
|
+
let valid = true;
|
|
775
|
+
for (const child of run) {
|
|
776
|
+
if (child.type === 'JSXText') {
|
|
777
|
+
text += content.slice(child.span.start, child.span.end);
|
|
778
|
+
}
|
|
779
|
+
else {
|
|
780
|
+
const info = resolveExpressionName(child.expression, content, usedNames);
|
|
781
|
+
if (!info) {
|
|
782
|
+
valid = false;
|
|
783
|
+
break;
|
|
700
784
|
}
|
|
785
|
+
text += `{{${info.name}}}`;
|
|
786
|
+
interpolations.push(info);
|
|
787
|
+
}
|
|
788
|
+
}
|
|
789
|
+
if (!valid)
|
|
790
|
+
continue;
|
|
791
|
+
const trimmed = text.trim();
|
|
792
|
+
if (!trimmed || interpolations.length === 0)
|
|
793
|
+
continue;
|
|
794
|
+
const spanStart = run[0].span.start;
|
|
795
|
+
const spanEnd = run[run.length - 1].span.end;
|
|
796
|
+
const candidate = detectCandidate(trimmed, spanStart, spanEnd, file, content, config);
|
|
797
|
+
if (candidate) {
|
|
798
|
+
candidate.type = 'jsx-text';
|
|
799
|
+
candidate.content = trimmed;
|
|
800
|
+
candidate.interpolations = interpolations;
|
|
801
|
+
// Mixed text + expressions in JSX is almost always user-facing
|
|
802
|
+
candidate.confidence = Math.min(1, candidate.confidence + 0.2);
|
|
803
|
+
if (candidate.confidence >= 0.7) {
|
|
804
|
+
// Remove individual candidates that overlap with the merged span
|
|
805
|
+
for (let i = candidates.length - 1; i >= 0; i--) {
|
|
806
|
+
if (candidates[i].offset >= spanStart && candidates[i].endOffset <= spanEnd) {
|
|
807
|
+
candidates.splice(i, 1);
|
|
808
|
+
}
|
|
809
|
+
}
|
|
810
|
+
candidates.push(candidate);
|
|
701
811
|
}
|
|
702
|
-
candidates.push(candidate);
|
|
703
812
|
}
|
|
704
813
|
}
|
|
705
814
|
}
|
|
@@ -836,6 +945,11 @@ function detectPluralPatterns(node, content, file, config, candidates) {
|
|
|
836
945
|
// Use the "other" form as the candidate content (with {{count}})
|
|
837
946
|
const spanStart = node.span.start;
|
|
838
947
|
const spanEnd = node.span.end;
|
|
948
|
+
// Skip if this ternary is already covered by a wider candidate
|
|
949
|
+
// (e.g. a JSX sibling run that merged surrounding text with this plural)
|
|
950
|
+
const alreadyHandled = candidates.some(c => c.pluralForms && c.offset <= spanStart && c.endOffset >= spanEnd);
|
|
951
|
+
if (alreadyHandled)
|
|
952
|
+
return;
|
|
839
953
|
const candidate = detectCandidate(plural.other, spanStart, spanEnd, file, content, config);
|
|
840
954
|
if (candidate) {
|
|
841
955
|
candidate.type = 'string-literal';
|
|
@@ -194,6 +194,12 @@ function buildReplacement(candidate, key, useHookStyle, namespace) {
|
|
|
194
194
|
}
|
|
195
195
|
optionEntries.push(`defaultValue_other: '${escapeString(pf.other)}'`);
|
|
196
196
|
optionEntries.push(`count: ${pf.countExpression}`);
|
|
197
|
+
// Add extra interpolation variables (e.g. from JSX sibling merging with plurals)
|
|
198
|
+
if (candidate.interpolations?.length) {
|
|
199
|
+
for (const interp of candidate.interpolations) {
|
|
200
|
+
optionEntries.push(interp.name === interp.expression ? interp.name : `${interp.name}: ${interp.expression}`);
|
|
201
|
+
}
|
|
202
|
+
}
|
|
197
203
|
if (!useHookStyle && namespace) {
|
|
198
204
|
optionEntries.push(`ns: '${namespace}'`);
|
|
199
205
|
}
|
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;
|
package/package.json
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"instrumenter.d.ts","sourceRoot":"","sources":["../../../src/instrumenter/core/instrumenter.ts"],"names":[],"mappings":"AAQA,OAAO,KAAK,EAAE,MAAM,EAAE,oBAAoB,EAAE,mBAAmB,EAAE,eAAe,EAA6B,sBAAsB,EAAiE,MAAM,aAAa,CAAA;AAUvN;;;;;;;;GAQG;AACH,wBAAsB,eAAe,CACnC,MAAM,EAAE,oBAAoB,EAC5B,OAAO,EAAE,mBAAmB,EAC5B,MAAM,GAAE,MAA4B,GACnC,OAAO,CAAC,sBAAsB,CAAC,CA8MjC;
|
|
1
|
+
{"version":3,"file":"instrumenter.d.ts","sourceRoot":"","sources":["../../../src/instrumenter/core/instrumenter.ts"],"names":[],"mappings":"AAQA,OAAO,KAAK,EAAE,MAAM,EAAE,oBAAoB,EAAE,mBAAmB,EAAE,eAAe,EAA6B,sBAAsB,EAAiE,MAAM,aAAa,CAAA;AAUvN;;;;;;;;GAQG;AACH,wBAAsB,eAAe,CACnC,MAAM,EAAE,oBAAoB,EAC5B,OAAO,EAAE,mBAAmB,EAC5B,MAAM,GAAE,MAA4B,GACnC,OAAO,CAAC,sBAAsB,CAAC,CA8MjC;AAs9CD;;GAEG;AACH,wBAAsB,kBAAkB,CACtC,UAAU,EAAE,eAAe,EAAE,EAC7B,MAAM,EAAE,oBAAoB,EAC5B,SAAS,CAAC,EAAE,MAAM,EAClB,MAAM,GAAE,MAA4B,GACnC,OAAO,CAAC,IAAI,CAAC,CAoDf"}
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"transformer.d.ts","sourceRoot":"","sources":["../../../src/instrumenter/core/transformer.ts"],"names":[],"mappings":"AACA,OAAO,KAAK,EAAE,oBAAoB,EAAE,eAAe,EAAE,eAAe,EAAE,iBAAiB,EAAE,kBAAkB,EAAE,MAAM,aAAa,CAAA;AAGhI,UAAU,kBAAkB;IAC1B,QAAQ,CAAC,EAAE,OAAO,CAAA;IAClB,QAAQ,EAAE,OAAO,CAAA;IACjB,qBAAqB,EAAE,OAAO,CAAA;IAC9B,MAAM,EAAE,IAAI,CAAC,oBAAoB,EAAE,SAAS,CAAC,CAAA;IAC7C,6EAA6E;IAC7E,UAAU,CAAC,EAAE,iBAAiB,EAAE,CAAA;IAChC,kEAAkE;IAClE,SAAS,CAAC,EAAE,MAAM,CAAA;IAClB,gFAAgF;IAChF,mBAAmB,CAAC,EAAE,kBAAkB,EAAE,CAAA;CAC3C;AAED;;;;;;;;;;GAUG;AACH,wBAAgB,aAAa,CAC3B,OAAO,EAAE,MAAM,EACf,IAAI,EAAE,MAAM,EACZ,UAAU,EAAE,eAAe,EAAE,EAC7B,OAAO,EAAE,kBAAkB,GAC1B,eAAe,CA6KjB;
|
|
1
|
+
{"version":3,"file":"transformer.d.ts","sourceRoot":"","sources":["../../../src/instrumenter/core/transformer.ts"],"names":[],"mappings":"AACA,OAAO,KAAK,EAAE,oBAAoB,EAAE,eAAe,EAAE,eAAe,EAAE,iBAAiB,EAAE,kBAAkB,EAAE,MAAM,aAAa,CAAA;AAGhI,UAAU,kBAAkB;IAC1B,QAAQ,CAAC,EAAE,OAAO,CAAA;IAClB,QAAQ,EAAE,OAAO,CAAA;IACjB,qBAAqB,EAAE,OAAO,CAAA;IAC9B,MAAM,EAAE,IAAI,CAAC,oBAAoB,EAAE,SAAS,CAAC,CAAA;IAC7C,6EAA6E;IAC7E,UAAU,CAAC,EAAE,iBAAiB,EAAE,CAAA;IAChC,kEAAkE;IAClE,SAAS,CAAC,EAAE,MAAM,CAAA;IAClB,gFAAgF;IAChF,mBAAmB,CAAC,EAAE,kBAAkB,EAAE,CAAA;CAC3C;AAED;;;;;;;;;;GAUG;AACH,wBAAgB,aAAa,CAC3B,OAAO,EAAE,MAAM,EACf,IAAI,EAAE,MAAM,EACZ,UAAU,EAAE,eAAe,EAAE,EAC7B,OAAO,EAAE,kBAAkB,GAC1B,eAAe,CA6KjB;AAiLD;;GAEG;AACH,wBAAgB,YAAY,CAAE,QAAQ,EAAE,MAAM,EAAE,QAAQ,EAAE,MAAM,EAAE,QAAQ,EAAE,MAAM,GAAG,MAAM,CAwB1F"}
|
package/types/linter.d.ts.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"linter.d.ts","sourceRoot":"","sources":["../src/linter.ts"],"names":[],"mappings":"AAIA,OAAO,EAAE,YAAY,EAAE,MAAM,aAAa,CAAA;AAK1C,OAAO,KAAK,EAAE,oBAAoB,EAAE,MAAM,EAAE,SAAS,EAA6B,MAAM,SAAS,CAAA;
|
|
1
|
+
{"version":3,"file":"linter.d.ts","sourceRoot":"","sources":["../src/linter.ts"],"names":[],"mappings":"AAIA,OAAO,EAAE,YAAY,EAAE,MAAM,aAAa,CAAA;AAK1C,OAAO,KAAK,EAAE,oBAAoB,EAAE,MAAM,EAAE,SAAS,EAA6B,MAAM,SAAS,CAAA;AA8SjG,KAAK,cAAc,GAAG;IACpB,QAAQ,EAAE;QAAC;YACT,OAAO,EAAE,MAAM,CAAC;SACjB;KAAC,CAAC;IACH,IAAI,EAAE;QAAC;YACL,OAAO,EAAE,OAAO,CAAC;YACjB,OAAO,EAAE,MAAM,CAAC;YAChB,KAAK,EAAE,MAAM,CAAC,MAAM,EAAE,SAAS,EAAE,CAAC,CAAC;SACpC;KAAC,CAAC;IACH,KAAK,EAAE,CAAC,KAAK,EAAE,KAAK,CAAC,CAAC;CACvB,CAAA;AAED,eAAO,MAAM,uBAAuB,EAAE,MAAM,EAAiD,CAAA;AAC7F,eAAO,MAAM,6BAA6B,EAAE,MAAM,EAAqD,CAAA;AAKvG,qBAAa,MAAO,SAAQ,YAAY,CAAC,cAAc,CAAC;IACtD,OAAO,CAAC,MAAM,CAAsB;IACpC,OAAO,CAAC,MAAM,CAAQ;gBAET,MAAM,EAAE,oBAAoB,EAAE,MAAM,GAAE,MAA4B;IAM/E,SAAS,CAAE,KAAK,EAAE,OAAO;IAanB,GAAG;;;;;;;IAmHT,OAAO,CAAC,uBAAuB;YAOjB,qBAAqB;IAWnC,OAAO,CAAC,kBAAkB;IAM1B,OAAO,CAAC,0BAA0B;YAUpB,qBAAqB;YAgBrB,uBAAuB;CAgBtC;AAED;;;;;;;;;;;;;;;;;;;;;;;;;;;;GA4BG;AACH,wBAAsB,SAAS,CAAE,MAAM,EAAE,oBAAoB;;;;;;GAE5D;AAED,wBAAsB,YAAY,CAChC,MAAM,EAAE,oBAAoB,EAC5B,OAAO,GAAE;IAAE,KAAK,CAAC,EAAE,OAAO,CAAC;IAAC,MAAM,CAAC,EAAE,MAAM,CAAA;CAAO,iBAkCnD"}
|