i18next-cli 1.59.1 → 1.60.0
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/README.md +29 -0
- package/dist/cjs/cli.js +1 -1
- package/dist/cjs/extractor/parsers/ast-utils.js +169 -0
- package/dist/cjs/instrumenter/core/instrumenter.js +7 -96
- package/dist/cjs/linter.js +19 -30
- package/dist/esm/cli.js +1 -1
- package/dist/esm/extractor/parsers/ast-utils.js +167 -1
- package/dist/esm/instrumenter/core/instrumenter.js +6 -95
- package/dist/esm/linter.js +19 -30
- package/package.json +1 -1
- package/types/extractor/parsers/ast-utils.d.ts +40 -0
- package/types/extractor/parsers/ast-utils.d.ts.map +1 -1
- package/types/instrumenter/core/instrumenter.d.ts.map +1 -1
- package/types/linter.d.ts.map +1 -1
package/README.md
CHANGED
|
@@ -280,6 +280,8 @@ Analyzes your source code for internationalization issues like hardcoded strings
|
|
|
280
280
|
npx i18next-cli lint
|
|
281
281
|
```
|
|
282
282
|
|
|
283
|
+
To suppress warnings for code you intentionally aren't translating yet, use the [`i18next-instrument-ignore` directive](#suppressing-detection-with-i18next-instrument-ignore) — the same comment recognized by the `instrument` command.
|
|
284
|
+
|
|
283
285
|
### `instrument`
|
|
284
286
|
|
|
285
287
|
Scans your source code for hardcoded user-facing strings and instruments them with i18next translation calls. This is useful for adding i18next instrumentation to an existing codebase that wasn't built with internationalization in mind. You can see this in action in [this video](https://youtu.be/aWZnZXwGg34) or in [this blog post](https://www.locize.com/blog/i18next-cli-instrument?utm_source=i18next_cli_readme&utm_medium=github&utm_campaign=readme).
|
|
@@ -437,6 +439,33 @@ The intended usage pattern is:
|
|
|
437
439
|
5. Manually fix any false positives or false negatives
|
|
438
440
|
6. Run `extract` to finalize translation files
|
|
439
441
|
|
|
442
|
+
#### Suppressing detection with `i18next-instrument-ignore`
|
|
443
|
+
|
|
444
|
+
Both the `lint` and `instrument` commands honor an ignore comment so you can skip placeholder or intentionally-untranslated content. It works as a line or block comment, including the JSX `{/* ... */}` form, and comes in two variants:
|
|
445
|
+
|
|
446
|
+
| Directive | Scope |
|
|
447
|
+
| --- | --- |
|
|
448
|
+
| `i18next-instrument-ignore` | The **entire JSX element** that begins on the next line — its opening tag, all nested children, and its closing tag. Falls back to a single line when the next line isn't a JSX element (e.g. a plain `t()` call). |
|
|
449
|
+
| `i18next-instrument-ignore-next-line` | Only the **single line** immediately after the directive. |
|
|
450
|
+
|
|
451
|
+
```jsx
|
|
452
|
+
// Suppress a whole element (including multi-line opening tags and nested children)
|
|
453
|
+
{/* i18next-instrument-ignore */}
|
|
454
|
+
<div
|
|
455
|
+
css={css`text-align: center;`}>
|
|
456
|
+
Hi, I'm Bob 👋
|
|
457
|
+
<p>This nested text is ignored too</p>
|
|
458
|
+
</div>
|
|
459
|
+
|
|
460
|
+
// Suppress just one line
|
|
461
|
+
{/* i18next-instrument-ignore-next-line */}
|
|
462
|
+
<p>Only this line is ignored</p>
|
|
463
|
+
|
|
464
|
+
// Also works for t() interpolation warnings in the linter
|
|
465
|
+
// i18next-instrument-ignore
|
|
466
|
+
const msg = t('Hello {{name}}!', { wrong: 'world' })
|
|
467
|
+
```
|
|
468
|
+
|
|
440
469
|
### `migrate-config`
|
|
441
470
|
Automatically migrates a legacy `i18next-parser.config.js` file to the new `i18next.config.ts` format.
|
|
442
471
|
|
package/dist/cjs/cli.js
CHANGED
|
@@ -32,7 +32,7 @@ const program = new commander.Command();
|
|
|
32
32
|
program
|
|
33
33
|
.name('i18next-cli')
|
|
34
34
|
.description('A unified, high-performance i18next CLI.')
|
|
35
|
-
.version('1.
|
|
35
|
+
.version('1.60.0'); // This string is replaced with the actual version at build time by rollup
|
|
36
36
|
// new: global config override option
|
|
37
37
|
program.option('-c, --config <path>', 'Path to i18next-cli config file (overrides detection)');
|
|
38
38
|
program
|
|
@@ -103,6 +103,172 @@ function lineColumnFromOffset(code, offset) {
|
|
|
103
103
|
column: lines[lines.length - 1].length
|
|
104
104
|
};
|
|
105
105
|
}
|
|
106
|
+
// ─── Byte → char offset helpers ────────────────────────────────────────────
|
|
107
|
+
/**
|
|
108
|
+
* Builds a lookup table from UTF-8 byte offsets to JavaScript string character
|
|
109
|
+
* indices (UTF-16 code-unit positions).
|
|
110
|
+
*
|
|
111
|
+
* SWC internally represents source as UTF-8 and reports AST spans as byte
|
|
112
|
+
* offsets into that representation. MagicString and all JavaScript String
|
|
113
|
+
* methods operate on UTF-16 code-unit indices. For files that contain only
|
|
114
|
+
* ASCII characters the two coincide, so this function returns `null` as a
|
|
115
|
+
* fast path. For files with multi-byte characters (emoji, accented letters,
|
|
116
|
+
* CJK, etc.) the returned array allows O(1) conversion of any byte offset.
|
|
117
|
+
*/
|
|
118
|
+
function buildByteToCharMap(content) {
|
|
119
|
+
// Fast path: pure ASCII means byte offset ≡ char index
|
|
120
|
+
// eslint-disable-next-line no-control-regex
|
|
121
|
+
if (!/[^\x00-\x7F]/.test(content))
|
|
122
|
+
return null;
|
|
123
|
+
const map = [];
|
|
124
|
+
let byteIdx = 0;
|
|
125
|
+
for (let charIdx = 0; charIdx < content.length;) {
|
|
126
|
+
const cp = content.codePointAt(charIdx);
|
|
127
|
+
const byteLen = cp <= 0x7F ? 1 : cp <= 0x7FF ? 2 : cp <= 0xFFFF ? 3 : 4;
|
|
128
|
+
const charLen = cp > 0xFFFF ? 2 : 1; // surrogate pair
|
|
129
|
+
// Every byte belonging to this character maps to the same char index
|
|
130
|
+
for (let b = 0; b < byteLen; b++) {
|
|
131
|
+
map[byteIdx + b] = charIdx;
|
|
132
|
+
}
|
|
133
|
+
byteIdx += byteLen;
|
|
134
|
+
charIdx += charLen;
|
|
135
|
+
}
|
|
136
|
+
// Sentinel so that span.end (one-past-the-last-byte) resolves correctly
|
|
137
|
+
map[byteIdx] = content.length;
|
|
138
|
+
return map;
|
|
139
|
+
}
|
|
140
|
+
/**
|
|
141
|
+
* Recursively converts every `span.start` / `span.end` in an SWC AST from
|
|
142
|
+
* UTF-8 byte offsets to JavaScript string character indices using the
|
|
143
|
+
* pre-built lookup table.
|
|
144
|
+
*/
|
|
145
|
+
function convertSpansToCharIndices(node, byteToChar) {
|
|
146
|
+
if (!node || typeof node !== 'object')
|
|
147
|
+
return;
|
|
148
|
+
if (node.span && typeof node.span.start === 'number') {
|
|
149
|
+
const charStart = byteToChar[node.span.start];
|
|
150
|
+
const charEnd = byteToChar[node.span.end];
|
|
151
|
+
if (charStart !== undefined && charEnd !== undefined) {
|
|
152
|
+
node.span = { ...node.span, start: charStart, end: charEnd };
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
for (const key of Object.keys(node)) {
|
|
156
|
+
if (key === 'span')
|
|
157
|
+
continue;
|
|
158
|
+
const child = node[key];
|
|
159
|
+
if (Array.isArray(child)) {
|
|
160
|
+
for (const item of child) {
|
|
161
|
+
if (item && typeof item === 'object') {
|
|
162
|
+
convertSpansToCharIndices(item, byteToChar);
|
|
163
|
+
}
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
else if (child && typeof child === 'object') {
|
|
167
|
+
convertSpansToCharIndices(child, byteToChar);
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
}
|
|
171
|
+
// ─── Ignore-comment helpers ──────────────────────────────────────────────────
|
|
172
|
+
/**
|
|
173
|
+
* Matches the shared ignore directive used by both the instrumenter and the
|
|
174
|
+
* linter. The optional `-next-line` suffix is captured in group 1:
|
|
175
|
+
*
|
|
176
|
+
* i18next-instrument-ignore-next-line → suppress only the single next line
|
|
177
|
+
* i18next-instrument-ignore → suppress the whole next JSX element
|
|
178
|
+
*
|
|
179
|
+
* Works in line-comment (`// ...`) and block-comment form, including the JSX
|
|
180
|
+
* `{ /* ... * / }` form.
|
|
181
|
+
*/
|
|
182
|
+
const IGNORE_DIRECTIVE_RE = /i18next-instrument-ignore(-next-line)?/;
|
|
183
|
+
/**
|
|
184
|
+
* Scans `code` for ignore-directive comments and returns a Set of 1-based line
|
|
185
|
+
* numbers whose issues/strings should be suppressed.
|
|
186
|
+
*
|
|
187
|
+
* A directive on line `D` targets the following line `D + 1`. The behaviour
|
|
188
|
+
* depends on the directive variant:
|
|
189
|
+
*
|
|
190
|
+
* - `i18next-instrument-ignore-next-line` suppresses only line `D + 1`.
|
|
191
|
+
* - `i18next-instrument-ignore` suppresses the **entire JSX element** that
|
|
192
|
+
* begins on line `D + 1` — i.e. every line from its opening tag through its
|
|
193
|
+
* closing tag, including nested children. This makes a single directive
|
|
194
|
+
* cover multi-line elements (e.g. `<div … css={…}>…</div>`) and elements
|
|
195
|
+
* with nested children, instead of just the one physical line after it.
|
|
196
|
+
*
|
|
197
|
+
* When no AST node begins on the targeted line (e.g. the next line is a plain
|
|
198
|
+
* `t()` call rather than a JSX element), block scope falls back to suppressing
|
|
199
|
+
* the single targeted line, preserving the original line-based behaviour.
|
|
200
|
+
*
|
|
201
|
+
* `ast` spans must already be normalised to file-relative character indices
|
|
202
|
+
* (see {@link normalizeASTSpans} / {@link convertSpansToCharIndices}).
|
|
203
|
+
*/
|
|
204
|
+
function collectIgnoredLineRanges(ast, code) {
|
|
205
|
+
const ignored = new Set();
|
|
206
|
+
const lines = code.split('\n');
|
|
207
|
+
// 1-based target lines, split by directive variant.
|
|
208
|
+
const blockTargets = new Set();
|
|
209
|
+
const lineTargets = new Set();
|
|
210
|
+
for (let i = 0; i < lines.length; i++) {
|
|
211
|
+
const match = IGNORE_DIRECTIVE_RE.exec(lines[i]);
|
|
212
|
+
if (!match)
|
|
213
|
+
continue;
|
|
214
|
+
const target = i + 2; // directive on line i+1 (1-based) → target line i+2
|
|
215
|
+
if (match[1])
|
|
216
|
+
lineTargets.add(target); // `-next-line` variant
|
|
217
|
+
else
|
|
218
|
+
blockTargets.add(target);
|
|
219
|
+
}
|
|
220
|
+
if (blockTargets.size === 0 && lineTargets.size === 0)
|
|
221
|
+
return ignored;
|
|
222
|
+
// Always suppress the directly targeted line. For `-next-line` this is the
|
|
223
|
+
// whole effect; for block directives it is the fallback when no element is
|
|
224
|
+
// found, and otherwise the start of the suppressed range.
|
|
225
|
+
for (const target of lineTargets)
|
|
226
|
+
ignored.add(target);
|
|
227
|
+
for (const target of blockTargets)
|
|
228
|
+
ignored.add(target);
|
|
229
|
+
if (blockTargets.size === 0)
|
|
230
|
+
return ignored;
|
|
231
|
+
// For block directives, find the widest AST node that begins on the targeted
|
|
232
|
+
// line and expand suppression to cover its full line span. Multiple nodes can
|
|
233
|
+
// start on the same line (e.g. a JSXElement and its JSXOpeningElement); taking
|
|
234
|
+
// the largest end line picks the outermost element.
|
|
235
|
+
const widestEndLineByStartLine = new Map();
|
|
236
|
+
const visit = (node) => {
|
|
237
|
+
if (!node || typeof node !== 'object')
|
|
238
|
+
return;
|
|
239
|
+
if (node.span && typeof node.span.start === 'number') {
|
|
240
|
+
const start = lineColumnFromOffset(code, node.span.start);
|
|
241
|
+
if (start && blockTargets.has(start.line)) {
|
|
242
|
+
const end = lineColumnFromOffset(code, node.span.end);
|
|
243
|
+
if (end) {
|
|
244
|
+
const prev = widestEndLineByStartLine.get(start.line);
|
|
245
|
+
if (prev === undefined || end.line > prev) {
|
|
246
|
+
widestEndLineByStartLine.set(start.line, end.line);
|
|
247
|
+
}
|
|
248
|
+
}
|
|
249
|
+
}
|
|
250
|
+
}
|
|
251
|
+
for (const key of Object.keys(node)) {
|
|
252
|
+
if (key === 'span')
|
|
253
|
+
continue;
|
|
254
|
+
const child = node[key];
|
|
255
|
+
if (Array.isArray(child)) {
|
|
256
|
+
for (const item of child)
|
|
257
|
+
visit(item);
|
|
258
|
+
}
|
|
259
|
+
else if (child && typeof child === 'object') {
|
|
260
|
+
visit(child);
|
|
261
|
+
}
|
|
262
|
+
}
|
|
263
|
+
};
|
|
264
|
+
visit(ast);
|
|
265
|
+
for (const target of blockTargets) {
|
|
266
|
+
const endLine = widestEndLineByStartLine.get(target) ?? target;
|
|
267
|
+
for (let line = target; line <= endLine; line++)
|
|
268
|
+
ignored.add(line);
|
|
269
|
+
}
|
|
270
|
+
return ignored;
|
|
271
|
+
}
|
|
106
272
|
/**
|
|
107
273
|
* Finds and returns the full property node (KeyValueProperty) for the given
|
|
108
274
|
* property name from an ObjectExpression.
|
|
@@ -189,6 +355,9 @@ function getObjectPropValue(object, propName, identifierResolver) {
|
|
|
189
355
|
return undefined;
|
|
190
356
|
}
|
|
191
357
|
|
|
358
|
+
exports.buildByteToCharMap = buildByteToCharMap;
|
|
359
|
+
exports.collectIgnoredLineRanges = collectIgnoredLineRanges;
|
|
360
|
+
exports.convertSpansToCharIndices = convertSpansToCharIndices;
|
|
192
361
|
exports.findFirstTokenIndex = findFirstTokenIndex;
|
|
193
362
|
exports.getObjectPropValue = getObjectPropValue;
|
|
194
363
|
exports.getObjectPropValueExpression = getObjectPropValueExpression;
|
|
@@ -262,9 +262,9 @@ async function scanFileForCandidates(content, file, config) {
|
|
|
262
262
|
// SWC reports spans as UTF-8 byte offsets, but JavaScript strings and
|
|
263
263
|
// MagicString use UTF-16 code-unit indices. Without this conversion,
|
|
264
264
|
// every emoji / accented char / CJK char shifts all subsequent offsets.
|
|
265
|
-
const byteToChar = buildByteToCharMap(content);
|
|
265
|
+
const byteToChar = astUtils.buildByteToCharMap(content);
|
|
266
266
|
if (byteToChar) {
|
|
267
|
-
convertSpansToCharIndices(ast, byteToChar);
|
|
267
|
+
astUtils.convertSpansToCharIndices(ast, byteToChar);
|
|
268
268
|
}
|
|
269
269
|
// Detect React function component boundaries
|
|
270
270
|
detectComponentBoundaries(ast, content, components);
|
|
@@ -296,11 +296,13 @@ async function scanFileForCandidates(content, file, config) {
|
|
|
296
296
|
}
|
|
297
297
|
// Filter out candidates suppressed by ignore-comment directives.
|
|
298
298
|
// Supported comments (line or block):
|
|
299
|
-
// // i18next-instrument-ignore-next-line
|
|
300
|
-
// // i18next-instrument-ignore
|
|
299
|
+
// // i18next-instrument-ignore-next-line → suppress the single next line
|
|
300
|
+
// // i18next-instrument-ignore → suppress the whole next JSX element
|
|
301
301
|
// /* i18next-instrument-ignore-next-line */
|
|
302
302
|
// /* i18next-instrument-ignore */
|
|
303
|
-
|
|
303
|
+
// AST spans were normalised to char indices above, so the shared helper can
|
|
304
|
+
// resolve element line ranges directly.
|
|
305
|
+
const ignoredLines = astUtils.collectIgnoredLineRanges(ast, content);
|
|
304
306
|
if (ignoredLines.size > 0) {
|
|
305
307
|
const keep = [];
|
|
306
308
|
for (const c of candidates) {
|
|
@@ -318,32 +320,6 @@ async function scanFileForCandidates(content, file, config) {
|
|
|
318
320
|
}
|
|
319
321
|
return { candidates, components, languageChangeSites };
|
|
320
322
|
}
|
|
321
|
-
// ─── Ignore-comment helpers ──────────────────────────────────────────────────
|
|
322
|
-
/**
|
|
323
|
-
* Regex that matches a directive comment requesting the instrumenter to skip
|
|
324
|
-
* the **next** line. Works with both line comments (`// ...`) and block
|
|
325
|
-
* comments. The supported directives are:
|
|
326
|
-
*
|
|
327
|
-
* i18next-instrument-ignore-next-line
|
|
328
|
-
* i18next-instrument-ignore
|
|
329
|
-
*/
|
|
330
|
-
const IGNORE_RE = /i18next-instrument-ignore(?:-next-line)?/;
|
|
331
|
-
/**
|
|
332
|
-
* Scans `content` for ignore-directive comments and returns a Set of 1-based
|
|
333
|
-
* line numbers whose strings should be excluded from instrumentation.
|
|
334
|
-
*/
|
|
335
|
-
function collectIgnoredLines(content) {
|
|
336
|
-
const ignored = new Set();
|
|
337
|
-
const lines = content.split('\n');
|
|
338
|
-
for (let i = 0; i < lines.length; i++) {
|
|
339
|
-
if (IGNORE_RE.test(lines[i])) {
|
|
340
|
-
// Directive on line i → suppress the *following* line (i + 1)
|
|
341
|
-
// We store 1-based line numbers, so the suppressed line is i + 2
|
|
342
|
-
ignored.add(i + 2);
|
|
343
|
-
}
|
|
344
|
-
}
|
|
345
|
-
return ignored;
|
|
346
|
-
}
|
|
347
323
|
/**
|
|
348
324
|
* Returns the 1-based line number for a character offset.
|
|
349
325
|
*/
|
|
@@ -1964,71 +1940,6 @@ async function runInstrumentOnResultPipeline(filePath, initialCandidates, plugin
|
|
|
1964
1940
|
}
|
|
1965
1941
|
return candidates;
|
|
1966
1942
|
}
|
|
1967
|
-
// ─── Byte → char offset helpers ────────────────────────────────────────────
|
|
1968
|
-
/**
|
|
1969
|
-
* Builds a lookup table from UTF-8 byte offsets to JavaScript string character
|
|
1970
|
-
* indices (UTF-16 code-unit positions).
|
|
1971
|
-
*
|
|
1972
|
-
* SWC internally represents source as UTF-8 and reports AST spans as byte
|
|
1973
|
-
* offsets into that representation. MagicString and all JavaScript String
|
|
1974
|
-
* methods operate on UTF-16 code-unit indices. For files that contain only
|
|
1975
|
-
* ASCII characters the two coincide, so this function returns `null` as a
|
|
1976
|
-
* fast path. For files with multi-byte characters (emoji, accented letters,
|
|
1977
|
-
* CJK, etc.) the returned array allows O(1) conversion of any byte offset.
|
|
1978
|
-
*/
|
|
1979
|
-
function buildByteToCharMap(content) {
|
|
1980
|
-
// Fast path: pure ASCII means byte offset ≡ char index
|
|
1981
|
-
// eslint-disable-next-line no-control-regex
|
|
1982
|
-
if (!/[^\x00-\x7F]/.test(content))
|
|
1983
|
-
return null;
|
|
1984
|
-
const map = [];
|
|
1985
|
-
let byteIdx = 0;
|
|
1986
|
-
for (let charIdx = 0; charIdx < content.length;) {
|
|
1987
|
-
const cp = content.codePointAt(charIdx);
|
|
1988
|
-
const byteLen = cp <= 0x7F ? 1 : cp <= 0x7FF ? 2 : cp <= 0xFFFF ? 3 : 4;
|
|
1989
|
-
const charLen = cp > 0xFFFF ? 2 : 1; // surrogate pair
|
|
1990
|
-
// Every byte belonging to this character maps to the same char index
|
|
1991
|
-
for (let b = 0; b < byteLen; b++) {
|
|
1992
|
-
map[byteIdx + b] = charIdx;
|
|
1993
|
-
}
|
|
1994
|
-
byteIdx += byteLen;
|
|
1995
|
-
charIdx += charLen;
|
|
1996
|
-
}
|
|
1997
|
-
// Sentinel so that span.end (one-past-the-last-byte) resolves correctly
|
|
1998
|
-
map[byteIdx] = content.length;
|
|
1999
|
-
return map;
|
|
2000
|
-
}
|
|
2001
|
-
/**
|
|
2002
|
-
* Recursively converts every `span.start` / `span.end` in an SWC AST from
|
|
2003
|
-
* UTF-8 byte offsets to JavaScript string character indices using the
|
|
2004
|
-
* pre-built lookup table.
|
|
2005
|
-
*/
|
|
2006
|
-
function convertSpansToCharIndices(node, byteToChar) {
|
|
2007
|
-
if (!node || typeof node !== 'object')
|
|
2008
|
-
return;
|
|
2009
|
-
if (node.span && typeof node.span.start === 'number') {
|
|
2010
|
-
const charStart = byteToChar[node.span.start];
|
|
2011
|
-
const charEnd = byteToChar[node.span.end];
|
|
2012
|
-
if (charStart !== undefined && charEnd !== undefined) {
|
|
2013
|
-
node.span = { ...node.span, start: charStart, end: charEnd };
|
|
2014
|
-
}
|
|
2015
|
-
}
|
|
2016
|
-
for (const key of Object.keys(node)) {
|
|
2017
|
-
if (key === 'span')
|
|
2018
|
-
continue;
|
|
2019
|
-
const child = node[key];
|
|
2020
|
-
if (Array.isArray(child)) {
|
|
2021
|
-
for (const item of child) {
|
|
2022
|
-
if (item && typeof item === 'object') {
|
|
2023
|
-
convertSpansToCharIndices(item, byteToChar);
|
|
2024
|
-
}
|
|
2025
|
-
}
|
|
2026
|
-
}
|
|
2027
|
-
else if (child && typeof child === 'object') {
|
|
2028
|
-
convertSpansToCharIndices(child, byteToChar);
|
|
2029
|
-
}
|
|
2030
|
-
}
|
|
2031
|
-
}
|
|
2032
1943
|
|
|
2033
1944
|
exports.runInstrumenter = runInstrumenter;
|
|
2034
1945
|
exports.writeExtractedKeys = writeExtractedKeys;
|
package/dist/cjs/linter.js
CHANGED
|
@@ -9,6 +9,7 @@ var node_util = require('node:util');
|
|
|
9
9
|
var logger = require('./utils/logger.js');
|
|
10
10
|
var wrapOra = require('./utils/wrap-ora.js');
|
|
11
11
|
var jsxAttributes = require('./utils/jsx-attributes.js');
|
|
12
|
+
var astUtils = require('./extractor/parsers/ast-utils.js');
|
|
12
13
|
|
|
13
14
|
/**
|
|
14
15
|
* Loads all translation values from the primary locale's JSON files and returns
|
|
@@ -101,34 +102,6 @@ function isI18nextOptionKey(key) {
|
|
|
101
102
|
return true;
|
|
102
103
|
return false;
|
|
103
104
|
}
|
|
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
|
-
}
|
|
132
105
|
// Helper to lint interpolation parameter errors in t() calls
|
|
133
106
|
function lintInterpolationParams(ast, code, config, translationValues) {
|
|
134
107
|
const issues = [];
|
|
@@ -403,14 +376,30 @@ class Linter extends node_events.EventEmitter {
|
|
|
403
376
|
continue;
|
|
404
377
|
}
|
|
405
378
|
}
|
|
379
|
+
// Normalise AST spans to file-relative character indices so the ignore
|
|
380
|
+
// directive can resolve the line range of the JSX element it precedes.
|
|
381
|
+
// (findHardcodedStrings/lintInterpolationParams locate issues via text
|
|
382
|
+
// search and don't read spans, so this only affects ignore handling.)
|
|
383
|
+
try {
|
|
384
|
+
const spanBase = ast.span.start - astUtils.findFirstTokenIndex(code);
|
|
385
|
+
astUtils.normalizeASTSpans(ast, spanBase);
|
|
386
|
+
const byteToChar = astUtils.buildByteToCharMap(code);
|
|
387
|
+
if (byteToChar)
|
|
388
|
+
astUtils.convertSpansToCharIndices(ast, byteToChar);
|
|
389
|
+
}
|
|
390
|
+
catch {
|
|
391
|
+
// If span normalisation fails for any reason, fall back to text-based
|
|
392
|
+
// issue detection without block-scoped ignore support.
|
|
393
|
+
}
|
|
406
394
|
// Collect hardcoded string issues
|
|
407
395
|
const hardcodedStrings = findHardcodedStrings(ast, code, config);
|
|
408
396
|
// Collect interpolation parameter issues
|
|
409
397
|
const interpolationIssues = lintInterpolationParams(ast, code, config, translationValues);
|
|
410
398
|
let allIssues = [...hardcodedStrings, ...interpolationIssues];
|
|
411
399
|
// Filter issues suppressed by ignore-directive comments.
|
|
412
|
-
//
|
|
413
|
-
|
|
400
|
+
// i18next-instrument-ignore-next-line → suppresses the single next line
|
|
401
|
+
// i18next-instrument-ignore → suppresses the whole next JSX element
|
|
402
|
+
const ignoredLines = astUtils.collectIgnoredLineRanges(ast, code);
|
|
414
403
|
if (ignoredLines.size > 0) {
|
|
415
404
|
allIssues = allIssues.filter(issue => !ignoredLines.has(issue.line));
|
|
416
405
|
}
|
package/dist/esm/cli.js
CHANGED
|
@@ -30,7 +30,7 @@ const program = new Command();
|
|
|
30
30
|
program
|
|
31
31
|
.name('i18next-cli')
|
|
32
32
|
.description('A unified, high-performance i18next CLI.')
|
|
33
|
-
.version('1.
|
|
33
|
+
.version('1.60.0'); // This string is replaced with the actual version at build time by rollup
|
|
34
34
|
// new: global config override option
|
|
35
35
|
program.option('-c, --config <path>', 'Path to i18next-cli config file (overrides detection)');
|
|
36
36
|
program
|
|
@@ -101,6 +101,172 @@ function lineColumnFromOffset(code, offset) {
|
|
|
101
101
|
column: lines[lines.length - 1].length
|
|
102
102
|
};
|
|
103
103
|
}
|
|
104
|
+
// ─── Byte → char offset helpers ────────────────────────────────────────────
|
|
105
|
+
/**
|
|
106
|
+
* Builds a lookup table from UTF-8 byte offsets to JavaScript string character
|
|
107
|
+
* indices (UTF-16 code-unit positions).
|
|
108
|
+
*
|
|
109
|
+
* SWC internally represents source as UTF-8 and reports AST spans as byte
|
|
110
|
+
* offsets into that representation. MagicString and all JavaScript String
|
|
111
|
+
* methods operate on UTF-16 code-unit indices. For files that contain only
|
|
112
|
+
* ASCII characters the two coincide, so this function returns `null` as a
|
|
113
|
+
* fast path. For files with multi-byte characters (emoji, accented letters,
|
|
114
|
+
* CJK, etc.) the returned array allows O(1) conversion of any byte offset.
|
|
115
|
+
*/
|
|
116
|
+
function buildByteToCharMap(content) {
|
|
117
|
+
// Fast path: pure ASCII means byte offset ≡ char index
|
|
118
|
+
// eslint-disable-next-line no-control-regex
|
|
119
|
+
if (!/[^\x00-\x7F]/.test(content))
|
|
120
|
+
return null;
|
|
121
|
+
const map = [];
|
|
122
|
+
let byteIdx = 0;
|
|
123
|
+
for (let charIdx = 0; charIdx < content.length;) {
|
|
124
|
+
const cp = content.codePointAt(charIdx);
|
|
125
|
+
const byteLen = cp <= 0x7F ? 1 : cp <= 0x7FF ? 2 : cp <= 0xFFFF ? 3 : 4;
|
|
126
|
+
const charLen = cp > 0xFFFF ? 2 : 1; // surrogate pair
|
|
127
|
+
// Every byte belonging to this character maps to the same char index
|
|
128
|
+
for (let b = 0; b < byteLen; b++) {
|
|
129
|
+
map[byteIdx + b] = charIdx;
|
|
130
|
+
}
|
|
131
|
+
byteIdx += byteLen;
|
|
132
|
+
charIdx += charLen;
|
|
133
|
+
}
|
|
134
|
+
// Sentinel so that span.end (one-past-the-last-byte) resolves correctly
|
|
135
|
+
map[byteIdx] = content.length;
|
|
136
|
+
return map;
|
|
137
|
+
}
|
|
138
|
+
/**
|
|
139
|
+
* Recursively converts every `span.start` / `span.end` in an SWC AST from
|
|
140
|
+
* UTF-8 byte offsets to JavaScript string character indices using the
|
|
141
|
+
* pre-built lookup table.
|
|
142
|
+
*/
|
|
143
|
+
function convertSpansToCharIndices(node, byteToChar) {
|
|
144
|
+
if (!node || typeof node !== 'object')
|
|
145
|
+
return;
|
|
146
|
+
if (node.span && typeof node.span.start === 'number') {
|
|
147
|
+
const charStart = byteToChar[node.span.start];
|
|
148
|
+
const charEnd = byteToChar[node.span.end];
|
|
149
|
+
if (charStart !== undefined && charEnd !== undefined) {
|
|
150
|
+
node.span = { ...node.span, start: charStart, end: charEnd };
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
for (const key of Object.keys(node)) {
|
|
154
|
+
if (key === 'span')
|
|
155
|
+
continue;
|
|
156
|
+
const child = node[key];
|
|
157
|
+
if (Array.isArray(child)) {
|
|
158
|
+
for (const item of child) {
|
|
159
|
+
if (item && typeof item === 'object') {
|
|
160
|
+
convertSpansToCharIndices(item, byteToChar);
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
else if (child && typeof child === 'object') {
|
|
165
|
+
convertSpansToCharIndices(child, byteToChar);
|
|
166
|
+
}
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
// ─── Ignore-comment helpers ──────────────────────────────────────────────────
|
|
170
|
+
/**
|
|
171
|
+
* Matches the shared ignore directive used by both the instrumenter and the
|
|
172
|
+
* linter. The optional `-next-line` suffix is captured in group 1:
|
|
173
|
+
*
|
|
174
|
+
* i18next-instrument-ignore-next-line → suppress only the single next line
|
|
175
|
+
* i18next-instrument-ignore → suppress the whole next JSX element
|
|
176
|
+
*
|
|
177
|
+
* Works in line-comment (`// ...`) and block-comment form, including the JSX
|
|
178
|
+
* `{ /* ... * / }` form.
|
|
179
|
+
*/
|
|
180
|
+
const IGNORE_DIRECTIVE_RE = /i18next-instrument-ignore(-next-line)?/;
|
|
181
|
+
/**
|
|
182
|
+
* Scans `code` for ignore-directive comments and returns a Set of 1-based line
|
|
183
|
+
* numbers whose issues/strings should be suppressed.
|
|
184
|
+
*
|
|
185
|
+
* A directive on line `D` targets the following line `D + 1`. The behaviour
|
|
186
|
+
* depends on the directive variant:
|
|
187
|
+
*
|
|
188
|
+
* - `i18next-instrument-ignore-next-line` suppresses only line `D + 1`.
|
|
189
|
+
* - `i18next-instrument-ignore` suppresses the **entire JSX element** that
|
|
190
|
+
* begins on line `D + 1` — i.e. every line from its opening tag through its
|
|
191
|
+
* closing tag, including nested children. This makes a single directive
|
|
192
|
+
* cover multi-line elements (e.g. `<div … css={…}>…</div>`) and elements
|
|
193
|
+
* with nested children, instead of just the one physical line after it.
|
|
194
|
+
*
|
|
195
|
+
* When no AST node begins on the targeted line (e.g. the next line is a plain
|
|
196
|
+
* `t()` call rather than a JSX element), block scope falls back to suppressing
|
|
197
|
+
* the single targeted line, preserving the original line-based behaviour.
|
|
198
|
+
*
|
|
199
|
+
* `ast` spans must already be normalised to file-relative character indices
|
|
200
|
+
* (see {@link normalizeASTSpans} / {@link convertSpansToCharIndices}).
|
|
201
|
+
*/
|
|
202
|
+
function collectIgnoredLineRanges(ast, code) {
|
|
203
|
+
const ignored = new Set();
|
|
204
|
+
const lines = code.split('\n');
|
|
205
|
+
// 1-based target lines, split by directive variant.
|
|
206
|
+
const blockTargets = new Set();
|
|
207
|
+
const lineTargets = new Set();
|
|
208
|
+
for (let i = 0; i < lines.length; i++) {
|
|
209
|
+
const match = IGNORE_DIRECTIVE_RE.exec(lines[i]);
|
|
210
|
+
if (!match)
|
|
211
|
+
continue;
|
|
212
|
+
const target = i + 2; // directive on line i+1 (1-based) → target line i+2
|
|
213
|
+
if (match[1])
|
|
214
|
+
lineTargets.add(target); // `-next-line` variant
|
|
215
|
+
else
|
|
216
|
+
blockTargets.add(target);
|
|
217
|
+
}
|
|
218
|
+
if (blockTargets.size === 0 && lineTargets.size === 0)
|
|
219
|
+
return ignored;
|
|
220
|
+
// Always suppress the directly targeted line. For `-next-line` this is the
|
|
221
|
+
// whole effect; for block directives it is the fallback when no element is
|
|
222
|
+
// found, and otherwise the start of the suppressed range.
|
|
223
|
+
for (const target of lineTargets)
|
|
224
|
+
ignored.add(target);
|
|
225
|
+
for (const target of blockTargets)
|
|
226
|
+
ignored.add(target);
|
|
227
|
+
if (blockTargets.size === 0)
|
|
228
|
+
return ignored;
|
|
229
|
+
// For block directives, find the widest AST node that begins on the targeted
|
|
230
|
+
// line and expand suppression to cover its full line span. Multiple nodes can
|
|
231
|
+
// start on the same line (e.g. a JSXElement and its JSXOpeningElement); taking
|
|
232
|
+
// the largest end line picks the outermost element.
|
|
233
|
+
const widestEndLineByStartLine = new Map();
|
|
234
|
+
const visit = (node) => {
|
|
235
|
+
if (!node || typeof node !== 'object')
|
|
236
|
+
return;
|
|
237
|
+
if (node.span && typeof node.span.start === 'number') {
|
|
238
|
+
const start = lineColumnFromOffset(code, node.span.start);
|
|
239
|
+
if (start && blockTargets.has(start.line)) {
|
|
240
|
+
const end = lineColumnFromOffset(code, node.span.end);
|
|
241
|
+
if (end) {
|
|
242
|
+
const prev = widestEndLineByStartLine.get(start.line);
|
|
243
|
+
if (prev === undefined || end.line > prev) {
|
|
244
|
+
widestEndLineByStartLine.set(start.line, end.line);
|
|
245
|
+
}
|
|
246
|
+
}
|
|
247
|
+
}
|
|
248
|
+
}
|
|
249
|
+
for (const key of Object.keys(node)) {
|
|
250
|
+
if (key === 'span')
|
|
251
|
+
continue;
|
|
252
|
+
const child = node[key];
|
|
253
|
+
if (Array.isArray(child)) {
|
|
254
|
+
for (const item of child)
|
|
255
|
+
visit(item);
|
|
256
|
+
}
|
|
257
|
+
else if (child && typeof child === 'object') {
|
|
258
|
+
visit(child);
|
|
259
|
+
}
|
|
260
|
+
}
|
|
261
|
+
};
|
|
262
|
+
visit(ast);
|
|
263
|
+
for (const target of blockTargets) {
|
|
264
|
+
const endLine = widestEndLineByStartLine.get(target) ?? target;
|
|
265
|
+
for (let line = target; line <= endLine; line++)
|
|
266
|
+
ignored.add(line);
|
|
267
|
+
}
|
|
268
|
+
return ignored;
|
|
269
|
+
}
|
|
104
270
|
/**
|
|
105
271
|
* Finds and returns the full property node (KeyValueProperty) for the given
|
|
106
272
|
* property name from an ObjectExpression.
|
|
@@ -187,4 +353,4 @@ function getObjectPropValue(object, propName, identifierResolver) {
|
|
|
187
353
|
return undefined;
|
|
188
354
|
}
|
|
189
355
|
|
|
190
|
-
export { findFirstTokenIndex, getObjectPropValue, getObjectPropValueExpression, getObjectProperty, isSimpleTemplateLiteral, lineColumnFromOffset, normalizeASTSpans };
|
|
356
|
+
export { buildByteToCharMap, collectIgnoredLineRanges, convertSpansToCharIndices, findFirstTokenIndex, getObjectPropValue, getObjectPropValueExpression, getObjectProperty, isSimpleTemplateLiteral, lineColumnFromOffset, normalizeASTSpans };
|
|
@@ -10,7 +10,7 @@ import { createKeyRegistry, generateKeyFromContent } from './key-generator.js';
|
|
|
10
10
|
import { createSpinnerLike } from '../../utils/wrap-ora.js';
|
|
11
11
|
import { ConsoleLogger } from '../../utils/logger.js';
|
|
12
12
|
import { ignoredAttributeSet } from '../../utils/jsx-attributes.js';
|
|
13
|
-
import { findFirstTokenIndex, normalizeASTSpans } from '../../extractor/parsers/ast-utils.js';
|
|
13
|
+
import { findFirstTokenIndex, normalizeASTSpans, buildByteToCharMap, convertSpansToCharIndices, collectIgnoredLineRanges } from '../../extractor/parsers/ast-utils.js';
|
|
14
14
|
import { getOutputPath } from '../../utils/file-utils.js';
|
|
15
15
|
|
|
16
16
|
/**
|
|
@@ -294,11 +294,13 @@ async function scanFileForCandidates(content, file, config) {
|
|
|
294
294
|
}
|
|
295
295
|
// Filter out candidates suppressed by ignore-comment directives.
|
|
296
296
|
// Supported comments (line or block):
|
|
297
|
-
// // i18next-instrument-ignore-next-line
|
|
298
|
-
// // i18next-instrument-ignore
|
|
297
|
+
// // i18next-instrument-ignore-next-line → suppress the single next line
|
|
298
|
+
// // i18next-instrument-ignore → suppress the whole next JSX element
|
|
299
299
|
// /* i18next-instrument-ignore-next-line */
|
|
300
300
|
// /* i18next-instrument-ignore */
|
|
301
|
-
|
|
301
|
+
// AST spans were normalised to char indices above, so the shared helper can
|
|
302
|
+
// resolve element line ranges directly.
|
|
303
|
+
const ignoredLines = collectIgnoredLineRanges(ast, content);
|
|
302
304
|
if (ignoredLines.size > 0) {
|
|
303
305
|
const keep = [];
|
|
304
306
|
for (const c of candidates) {
|
|
@@ -316,32 +318,6 @@ async function scanFileForCandidates(content, file, config) {
|
|
|
316
318
|
}
|
|
317
319
|
return { candidates, components, languageChangeSites };
|
|
318
320
|
}
|
|
319
|
-
// ─── Ignore-comment helpers ──────────────────────────────────────────────────
|
|
320
|
-
/**
|
|
321
|
-
* Regex that matches a directive comment requesting the instrumenter to skip
|
|
322
|
-
* the **next** line. Works with both line comments (`// ...`) and block
|
|
323
|
-
* comments. The supported directives are:
|
|
324
|
-
*
|
|
325
|
-
* i18next-instrument-ignore-next-line
|
|
326
|
-
* i18next-instrument-ignore
|
|
327
|
-
*/
|
|
328
|
-
const IGNORE_RE = /i18next-instrument-ignore(?:-next-line)?/;
|
|
329
|
-
/**
|
|
330
|
-
* Scans `content` for ignore-directive comments and returns a Set of 1-based
|
|
331
|
-
* line numbers whose strings should be excluded from instrumentation.
|
|
332
|
-
*/
|
|
333
|
-
function collectIgnoredLines(content) {
|
|
334
|
-
const ignored = new Set();
|
|
335
|
-
const lines = content.split('\n');
|
|
336
|
-
for (let i = 0; i < lines.length; i++) {
|
|
337
|
-
if (IGNORE_RE.test(lines[i])) {
|
|
338
|
-
// Directive on line i → suppress the *following* line (i + 1)
|
|
339
|
-
// We store 1-based line numbers, so the suppressed line is i + 2
|
|
340
|
-
ignored.add(i + 2);
|
|
341
|
-
}
|
|
342
|
-
}
|
|
343
|
-
return ignored;
|
|
344
|
-
}
|
|
345
321
|
/**
|
|
346
322
|
* Returns the 1-based line number for a character offset.
|
|
347
323
|
*/
|
|
@@ -1962,70 +1938,5 @@ async function runInstrumentOnResultPipeline(filePath, initialCandidates, plugin
|
|
|
1962
1938
|
}
|
|
1963
1939
|
return candidates;
|
|
1964
1940
|
}
|
|
1965
|
-
// ─── Byte → char offset helpers ────────────────────────────────────────────
|
|
1966
|
-
/**
|
|
1967
|
-
* Builds a lookup table from UTF-8 byte offsets to JavaScript string character
|
|
1968
|
-
* indices (UTF-16 code-unit positions).
|
|
1969
|
-
*
|
|
1970
|
-
* SWC internally represents source as UTF-8 and reports AST spans as byte
|
|
1971
|
-
* offsets into that representation. MagicString and all JavaScript String
|
|
1972
|
-
* methods operate on UTF-16 code-unit indices. For files that contain only
|
|
1973
|
-
* ASCII characters the two coincide, so this function returns `null` as a
|
|
1974
|
-
* fast path. For files with multi-byte characters (emoji, accented letters,
|
|
1975
|
-
* CJK, etc.) the returned array allows O(1) conversion of any byte offset.
|
|
1976
|
-
*/
|
|
1977
|
-
function buildByteToCharMap(content) {
|
|
1978
|
-
// Fast path: pure ASCII means byte offset ≡ char index
|
|
1979
|
-
// eslint-disable-next-line no-control-regex
|
|
1980
|
-
if (!/[^\x00-\x7F]/.test(content))
|
|
1981
|
-
return null;
|
|
1982
|
-
const map = [];
|
|
1983
|
-
let byteIdx = 0;
|
|
1984
|
-
for (let charIdx = 0; charIdx < content.length;) {
|
|
1985
|
-
const cp = content.codePointAt(charIdx);
|
|
1986
|
-
const byteLen = cp <= 0x7F ? 1 : cp <= 0x7FF ? 2 : cp <= 0xFFFF ? 3 : 4;
|
|
1987
|
-
const charLen = cp > 0xFFFF ? 2 : 1; // surrogate pair
|
|
1988
|
-
// Every byte belonging to this character maps to the same char index
|
|
1989
|
-
for (let b = 0; b < byteLen; b++) {
|
|
1990
|
-
map[byteIdx + b] = charIdx;
|
|
1991
|
-
}
|
|
1992
|
-
byteIdx += byteLen;
|
|
1993
|
-
charIdx += charLen;
|
|
1994
|
-
}
|
|
1995
|
-
// Sentinel so that span.end (one-past-the-last-byte) resolves correctly
|
|
1996
|
-
map[byteIdx] = content.length;
|
|
1997
|
-
return map;
|
|
1998
|
-
}
|
|
1999
|
-
/**
|
|
2000
|
-
* Recursively converts every `span.start` / `span.end` in an SWC AST from
|
|
2001
|
-
* UTF-8 byte offsets to JavaScript string character indices using the
|
|
2002
|
-
* pre-built lookup table.
|
|
2003
|
-
*/
|
|
2004
|
-
function convertSpansToCharIndices(node, byteToChar) {
|
|
2005
|
-
if (!node || typeof node !== 'object')
|
|
2006
|
-
return;
|
|
2007
|
-
if (node.span && typeof node.span.start === 'number') {
|
|
2008
|
-
const charStart = byteToChar[node.span.start];
|
|
2009
|
-
const charEnd = byteToChar[node.span.end];
|
|
2010
|
-
if (charStart !== undefined && charEnd !== undefined) {
|
|
2011
|
-
node.span = { ...node.span, start: charStart, end: charEnd };
|
|
2012
|
-
}
|
|
2013
|
-
}
|
|
2014
|
-
for (const key of Object.keys(node)) {
|
|
2015
|
-
if (key === 'span')
|
|
2016
|
-
continue;
|
|
2017
|
-
const child = node[key];
|
|
2018
|
-
if (Array.isArray(child)) {
|
|
2019
|
-
for (const item of child) {
|
|
2020
|
-
if (item && typeof item === 'object') {
|
|
2021
|
-
convertSpansToCharIndices(item, byteToChar);
|
|
2022
|
-
}
|
|
2023
|
-
}
|
|
2024
|
-
}
|
|
2025
|
-
else if (child && typeof child === 'object') {
|
|
2026
|
-
convertSpansToCharIndices(child, byteToChar);
|
|
2027
|
-
}
|
|
2028
|
-
}
|
|
2029
|
-
}
|
|
2030
1941
|
|
|
2031
1942
|
export { runInstrumenter, writeExtractedKeys };
|
package/dist/esm/linter.js
CHANGED
|
@@ -7,6 +7,7 @@ import { styleText } from 'node:util';
|
|
|
7
7
|
import { ConsoleLogger } from './utils/logger.js';
|
|
8
8
|
import { createSpinnerLike } from './utils/wrap-ora.js';
|
|
9
9
|
import { acceptedTags, translatableAttributes, ignoredTags, ignoredAttributeLowerSet } from './utils/jsx-attributes.js';
|
|
10
|
+
import { findFirstTokenIndex, normalizeASTSpans, buildByteToCharMap, convertSpansToCharIndices, collectIgnoredLineRanges } from './extractor/parsers/ast-utils.js';
|
|
10
11
|
|
|
11
12
|
/**
|
|
12
13
|
* Loads all translation values from the primary locale's JSON files and returns
|
|
@@ -99,34 +100,6 @@ function isI18nextOptionKey(key) {
|
|
|
99
100
|
return true;
|
|
100
101
|
return false;
|
|
101
102
|
}
|
|
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
|
-
}
|
|
130
103
|
// Helper to lint interpolation parameter errors in t() calls
|
|
131
104
|
function lintInterpolationParams(ast, code, config, translationValues) {
|
|
132
105
|
const issues = [];
|
|
@@ -401,14 +374,30 @@ class Linter extends EventEmitter {
|
|
|
401
374
|
continue;
|
|
402
375
|
}
|
|
403
376
|
}
|
|
377
|
+
// Normalise AST spans to file-relative character indices so the ignore
|
|
378
|
+
// directive can resolve the line range of the JSX element it precedes.
|
|
379
|
+
// (findHardcodedStrings/lintInterpolationParams locate issues via text
|
|
380
|
+
// search and don't read spans, so this only affects ignore handling.)
|
|
381
|
+
try {
|
|
382
|
+
const spanBase = ast.span.start - findFirstTokenIndex(code);
|
|
383
|
+
normalizeASTSpans(ast, spanBase);
|
|
384
|
+
const byteToChar = buildByteToCharMap(code);
|
|
385
|
+
if (byteToChar)
|
|
386
|
+
convertSpansToCharIndices(ast, byteToChar);
|
|
387
|
+
}
|
|
388
|
+
catch {
|
|
389
|
+
// If span normalisation fails for any reason, fall back to text-based
|
|
390
|
+
// issue detection without block-scoped ignore support.
|
|
391
|
+
}
|
|
404
392
|
// Collect hardcoded string issues
|
|
405
393
|
const hardcodedStrings = findHardcodedStrings(ast, code, config);
|
|
406
394
|
// Collect interpolation parameter issues
|
|
407
395
|
const interpolationIssues = lintInterpolationParams(ast, code, config, translationValues);
|
|
408
396
|
let allIssues = [...hardcodedStrings, ...interpolationIssues];
|
|
409
397
|
// Filter issues suppressed by ignore-directive comments.
|
|
410
|
-
//
|
|
411
|
-
|
|
398
|
+
// i18next-instrument-ignore-next-line → suppresses the single next line
|
|
399
|
+
// i18next-instrument-ignore → suppresses the whole next JSX element
|
|
400
|
+
const ignoredLines = collectIgnoredLineRanges(ast, code);
|
|
412
401
|
if (ignoredLines.size > 0) {
|
|
413
402
|
allIssues = allIssues.filter(issue => !ignoredLines.has(issue.line));
|
|
414
403
|
}
|
package/package.json
CHANGED
|
@@ -36,6 +36,46 @@ export declare function lineColumnFromOffset(code: string, offset: number): {
|
|
|
36
36
|
line: number;
|
|
37
37
|
column: number;
|
|
38
38
|
} | undefined;
|
|
39
|
+
/**
|
|
40
|
+
* Builds a lookup table from UTF-8 byte offsets to JavaScript string character
|
|
41
|
+
* indices (UTF-16 code-unit positions).
|
|
42
|
+
*
|
|
43
|
+
* SWC internally represents source as UTF-8 and reports AST spans as byte
|
|
44
|
+
* offsets into that representation. MagicString and all JavaScript String
|
|
45
|
+
* methods operate on UTF-16 code-unit indices. For files that contain only
|
|
46
|
+
* ASCII characters the two coincide, so this function returns `null` as a
|
|
47
|
+
* fast path. For files with multi-byte characters (emoji, accented letters,
|
|
48
|
+
* CJK, etc.) the returned array allows O(1) conversion of any byte offset.
|
|
49
|
+
*/
|
|
50
|
+
export declare function buildByteToCharMap(content: string): number[] | null;
|
|
51
|
+
/**
|
|
52
|
+
* Recursively converts every `span.start` / `span.end` in an SWC AST from
|
|
53
|
+
* UTF-8 byte offsets to JavaScript string character indices using the
|
|
54
|
+
* pre-built lookup table.
|
|
55
|
+
*/
|
|
56
|
+
export declare function convertSpansToCharIndices(node: any, byteToChar: number[]): void;
|
|
57
|
+
/**
|
|
58
|
+
* Scans `code` for ignore-directive comments and returns a Set of 1-based line
|
|
59
|
+
* numbers whose issues/strings should be suppressed.
|
|
60
|
+
*
|
|
61
|
+
* A directive on line `D` targets the following line `D + 1`. The behaviour
|
|
62
|
+
* depends on the directive variant:
|
|
63
|
+
*
|
|
64
|
+
* - `i18next-instrument-ignore-next-line` suppresses only line `D + 1`.
|
|
65
|
+
* - `i18next-instrument-ignore` suppresses the **entire JSX element** that
|
|
66
|
+
* begins on line `D + 1` — i.e. every line from its opening tag through its
|
|
67
|
+
* closing tag, including nested children. This makes a single directive
|
|
68
|
+
* cover multi-line elements (e.g. `<div … css={…}>…</div>`) and elements
|
|
69
|
+
* with nested children, instead of just the one physical line after it.
|
|
70
|
+
*
|
|
71
|
+
* When no AST node begins on the targeted line (e.g. the next line is a plain
|
|
72
|
+
* `t()` call rather than a JSX element), block scope falls back to suppressing
|
|
73
|
+
* the single targeted line, preserving the original line-based behaviour.
|
|
74
|
+
*
|
|
75
|
+
* `ast` spans must already be normalised to file-relative character indices
|
|
76
|
+
* (see {@link normalizeASTSpans} / {@link convertSpansToCharIndices}).
|
|
77
|
+
*/
|
|
78
|
+
export declare function collectIgnoredLineRanges(ast: any, code: string): Set<number>;
|
|
39
79
|
/**
|
|
40
80
|
* Finds and returns the full property node (KeyValueProperty) for the given
|
|
41
81
|
* property name from an ObjectExpression.
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"ast-utils.d.ts","sourceRoot":"","sources":["../../../src/extractor/parsers/ast-utils.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,UAAU,EAAc,gBAAgB,EAAE,eAAe,EAAE,MAAM,WAAW,CAAA;AAE1F;;;;;;;;;GASG;AACH,wBAAgB,mBAAmB,CAAE,IAAI,EAAE,MAAM,GAAG,MAAM,CA2BzD;AAED;;;;;;;;;;;;;GAaG;AACH,wBAAgB,iBAAiB,CAAE,IAAI,EAAE,GAAG,EAAE,IAAI,EAAE,MAAM,GAAG,IAAI,CA0BhE;AAED;;;;;;GAMG;AACH,wBAAgB,oBAAoB,CAAE,IAAI,EAAE,MAAM,EAAE,MAAM,EAAE,MAAM,GAAG;IAAE,IAAI,EAAE,MAAM,CAAC;IAAC,MAAM,EAAE,MAAM,CAAA;CAAE,GAAG,SAAS,CAQhH;AAED;;;;;;;;;;;;;;GAcG;AACH,wBAAgB,iBAAiB,CAAE,MAAM,EAAE,gBAAgB,EAAE,QAAQ,EAAE,MAAM,qDAU5E;AAED;;;;;;;;;;;;;GAaG;AACH,wBAAgB,4BAA4B,CAAE,MAAM,EAAE,gBAAgB,EAAE,QAAQ,EAAE,MAAM,GAAG,UAAU,GAAG,SAAS,CAKhH;AAED;;;;;;;GAOG;AACH,wBAAgB,uBAAuB,CAAE,OAAO,EAAE,eAAe,GAAG,OAAO,CAE1E;AAED,KAAK,kBAAkB,GAAG,CAAC,IAAI,EAAE,MAAM,KAAK,MAAM,GAAG,OAAO,GAAG,MAAM,GAAG,SAAS,CAAA;AAEjF;;;;;;;;;;;;GAYG;AACH,wBAAgB,kBAAkB,CAAE,MAAM,EAAE,gBAAgB,EAAE,QAAQ,EAAE,MAAM,EAAE,kBAAkB,CAAC,EAAE,kBAAkB,GAAG,MAAM,GAAG,OAAO,GAAG,MAAM,GAAG,SAAS,CAkB9J"}
|
|
1
|
+
{"version":3,"file":"ast-utils.d.ts","sourceRoot":"","sources":["../../../src/extractor/parsers/ast-utils.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,UAAU,EAAc,gBAAgB,EAAE,eAAe,EAAE,MAAM,WAAW,CAAA;AAE1F;;;;;;;;;GASG;AACH,wBAAgB,mBAAmB,CAAE,IAAI,EAAE,MAAM,GAAG,MAAM,CA2BzD;AAED;;;;;;;;;;;;;GAaG;AACH,wBAAgB,iBAAiB,CAAE,IAAI,EAAE,GAAG,EAAE,IAAI,EAAE,MAAM,GAAG,IAAI,CA0BhE;AAED;;;;;;GAMG;AACH,wBAAgB,oBAAoB,CAAE,IAAI,EAAE,MAAM,EAAE,MAAM,EAAE,MAAM,GAAG;IAAE,IAAI,EAAE,MAAM,CAAC;IAAC,MAAM,EAAE,MAAM,CAAA;CAAE,GAAG,SAAS,CAQhH;AAID;;;;;;;;;;GAUG;AACH,wBAAgB,kBAAkB,CAAE,OAAO,EAAE,MAAM,GAAG,MAAM,EAAE,GAAG,IAAI,CA0BpE;AAED;;;;GAIG;AACH,wBAAgB,yBAAyB,CAAE,IAAI,EAAE,GAAG,EAAE,UAAU,EAAE,MAAM,EAAE,GAAG,IAAI,CAwBhF;AAgBD;;;;;;;;;;;;;;;;;;;;GAoBG;AACH,wBAAgB,wBAAwB,CAAE,GAAG,EAAE,GAAG,EAAE,IAAI,EAAE,MAAM,GAAG,GAAG,CAAC,MAAM,CAAC,CA4D7E;AAED;;;;;;;;;;;;;;GAcG;AACH,wBAAgB,iBAAiB,CAAE,MAAM,EAAE,gBAAgB,EAAE,QAAQ,EAAE,MAAM,qDAU5E;AAED;;;;;;;;;;;;;GAaG;AACH,wBAAgB,4BAA4B,CAAE,MAAM,EAAE,gBAAgB,EAAE,QAAQ,EAAE,MAAM,GAAG,UAAU,GAAG,SAAS,CAKhH;AAED;;;;;;;GAOG;AACH,wBAAgB,uBAAuB,CAAE,OAAO,EAAE,eAAe,GAAG,OAAO,CAE1E;AAED,KAAK,kBAAkB,GAAG,CAAC,IAAI,EAAE,MAAM,KAAK,MAAM,GAAG,OAAO,GAAG,MAAM,GAAG,SAAS,CAAA;AAEjF;;;;;;;;;;;;GAYG;AACH,wBAAgB,kBAAkB,CAAE,MAAM,EAAE,gBAAgB,EAAE,QAAQ,EAAE,MAAM,EAAE,kBAAkB,CAAC,EAAE,kBAAkB,GAAG,MAAM,GAAG,OAAO,GAAG,MAAM,GAAG,SAAS,CAkB9J"}
|
|
@@ -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,gBAAgB,CAAA;AAU1N;;;;;;;;GAQG;AACH,wBAAsB,eAAe,CACnC,MAAM,EAAE,oBAAoB,EAC5B,OAAO,EAAE,mBAAmB,EAC5B,MAAM,GAAE,MAA4B,GACnC,OAAO,CAAC,sBAAsB,CAAC,CAoNjC;
|
|
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,gBAAgB,CAAA;AAU1N;;;;;;;;GAQG;AACH,wBAAsB,eAAe,CACnC,MAAM,EAAE,oBAAoB,EAC5B,OAAO,EAAE,mBAAmB,EAC5B,MAAM,GAAE,MAA4B,GACnC,OAAO,CAAC,sBAAsB,CAAC,CAoNjC;AAivDD;;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"}
|
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;
|
|
1
|
+
{"version":3,"file":"linter.d.ts","sourceRoot":"","sources":["../src/linter.ts"],"names":[],"mappings":"AAIA,OAAO,EAAE,YAAY,EAAE,MAAM,aAAa,CAAA;AAM1C,OAAO,KAAK,EAAE,oBAAoB,EAAE,MAAM,EAAE,SAAS,EAA6B,MAAM,YAAY,CAAA;AA+QpG,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;;;;;;;IAkIT,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"}
|