i18next-cli 1.46.4 → 1.47.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.
Files changed (51) hide show
  1. package/README.md +198 -3
  2. package/dist/cjs/cli.js +50 -1
  3. package/dist/cjs/extractor/core/extractor.js +27 -18
  4. package/dist/cjs/extractor/core/translation-manager.js +10 -3
  5. package/dist/cjs/extractor/parsers/call-expression-handler.js +9 -1
  6. package/dist/cjs/extractor/parsers/jsx-handler.js +10 -1
  7. package/dist/cjs/index.js +5 -0
  8. package/dist/cjs/init.js +68 -12
  9. package/dist/cjs/instrumenter/core/instrumenter.js +1633 -0
  10. package/dist/cjs/instrumenter/core/key-generator.js +71 -0
  11. package/dist/cjs/instrumenter/core/string-detector.js +290 -0
  12. package/dist/cjs/instrumenter/core/transformer.js +339 -0
  13. package/dist/cjs/linter.js +6 -7
  14. package/dist/cjs/utils/jsx-attributes.js +131 -0
  15. package/dist/esm/cli.js +50 -1
  16. package/dist/esm/extractor/core/extractor.js +27 -18
  17. package/dist/esm/extractor/core/translation-manager.js +10 -3
  18. package/dist/esm/extractor/parsers/call-expression-handler.js +9 -1
  19. package/dist/esm/extractor/parsers/jsx-handler.js +10 -1
  20. package/dist/esm/index.js +3 -0
  21. package/dist/esm/init.js +68 -12
  22. package/dist/esm/instrumenter/core/instrumenter.js +1630 -0
  23. package/dist/esm/instrumenter/core/key-generator.js +68 -0
  24. package/dist/esm/instrumenter/core/string-detector.js +288 -0
  25. package/dist/esm/instrumenter/core/transformer.js +336 -0
  26. package/dist/esm/linter.js +6 -7
  27. package/dist/esm/utils/jsx-attributes.js +121 -0
  28. package/package.json +2 -1
  29. package/types/cli.d.ts.map +1 -1
  30. package/types/extractor/core/extractor.d.ts.map +1 -1
  31. package/types/extractor/core/translation-manager.d.ts.map +1 -1
  32. package/types/extractor/parsers/call-expression-handler.d.ts.map +1 -1
  33. package/types/extractor/parsers/jsx-handler.d.ts.map +1 -1
  34. package/types/index.d.ts +2 -1
  35. package/types/index.d.ts.map +1 -1
  36. package/types/init.d.ts.map +1 -1
  37. package/types/instrumenter/core/instrumenter.d.ts +16 -0
  38. package/types/instrumenter/core/instrumenter.d.ts.map +1 -0
  39. package/types/instrumenter/core/key-generator.d.ts +30 -0
  40. package/types/instrumenter/core/key-generator.d.ts.map +1 -0
  41. package/types/instrumenter/core/string-detector.d.ts +27 -0
  42. package/types/instrumenter/core/string-detector.d.ts.map +1 -0
  43. package/types/instrumenter/core/transformer.d.ts +31 -0
  44. package/types/instrumenter/core/transformer.d.ts.map +1 -0
  45. package/types/instrumenter/index.d.ts +6 -0
  46. package/types/instrumenter/index.d.ts.map +1 -0
  47. package/types/linter.d.ts.map +1 -1
  48. package/types/types.d.ts +285 -1
  49. package/types/types.d.ts.map +1 -1
  50. package/types/utils/jsx-attributes.d.ts +68 -0
  51. package/types/utils/jsx-attributes.d.ts.map +1 -0
@@ -0,0 +1,1630 @@
1
+ import { readFile, mkdir, writeFile, access } from 'node:fs/promises';
2
+ import { glob } from 'glob';
3
+ import { dirname, extname, join, relative, isAbsolute, resolve } from 'node:path';
4
+ import { parse } from '@swc/core';
5
+ import inquirer from 'inquirer';
6
+ import { styleText } from 'node:util';
7
+ import { detectCandidate } from './string-detector.js';
8
+ import { transformFile } from './transformer.js';
9
+ import { createKeyRegistry, generateKeyFromContent } from './key-generator.js';
10
+ import { createSpinnerLike } from '../../utils/wrap-ora.js';
11
+ import { ConsoleLogger } from '../../utils/logger.js';
12
+ import { ignoredAttributeSet } from '../../utils/jsx-attributes.js';
13
+ import { findFirstTokenIndex, normalizeASTSpans } from '../../extractor/parsers/ast-utils.js';
14
+ import { getOutputPath } from '../../utils/file-utils.js';
15
+
16
+ /**
17
+ * Main orchestrator for the instrument command.
18
+ * Scans source files for hardcoded strings and instruments them with i18next calls.
19
+ *
20
+ * @param config - Toolkit configuration
21
+ * @param options - Instrumentation options (dry-run, interactive, etc.)
22
+ * @param logger - Logger instance
23
+ * @returns Instrumentation results
24
+ */
25
+ async function runInstrumenter(config, options, logger = new ConsoleLogger()) {
26
+ config.extract.primaryLanguage ||= config.locales[0] || 'en';
27
+ config.extract.secondaryLanguages ||= config.locales.filter((l) => l !== config?.extract?.primaryLanguage);
28
+ const spinner = createSpinnerLike('Scanning for hardcoded strings...\n', { quiet: !!options.quiet, logger });
29
+ try {
30
+ // Get list of source files
31
+ const sourceFiles = await getSourceFilesForInstrumentation(config);
32
+ spinner.text = `Scanning ${sourceFiles.length} files for hardcoded strings...`;
33
+ // Scan files for candidate strings
34
+ const results = [];
35
+ const keyRegistry = createKeyRegistry();
36
+ let totalCandidates = 0;
37
+ let totalTransformed = 0;
38
+ let totalSkipped = 0;
39
+ let totalLanguageChanges = 0;
40
+ // Detect framework and language
41
+ const hasReact = await isProjectUsingReact();
42
+ const hasTypeScript = await isProjectUsingTypeScript();
43
+ // Initialize plugins
44
+ const plugins = config.plugins || [];
45
+ await initializeInstrumentPlugins(plugins, { config, logger });
46
+ // Resolve target namespace
47
+ let targetNamespace = options.namespace;
48
+ if (!targetNamespace && options.isInteractive) {
49
+ const defaultNS = config.extract.defaultNS ?? 'translation';
50
+ const { ns } = await inquirer.prompt([
51
+ {
52
+ type: 'input',
53
+ name: 'ns',
54
+ message: 'Target namespace for extracted keys:',
55
+ default: typeof defaultNS === 'string' ? defaultNS : 'translation'
56
+ }
57
+ ]);
58
+ if (ns && ns !== defaultNS) {
59
+ targetNamespace = ns;
60
+ }
61
+ }
62
+ for (const file of sourceFiles) {
63
+ try {
64
+ let content = await readFile(file, 'utf-8');
65
+ // Run instrumentOnLoad plugin pipeline
66
+ const loadResult = await runInstrumentOnLoadPipeline(content, file, plugins, logger);
67
+ if (loadResult === null)
68
+ continue; // plugin says skip this file
69
+ content = loadResult;
70
+ const scanResult = await scanFileForCandidates(content, file, config);
71
+ let { candidates } = scanResult;
72
+ const { components, languageChangeSites } = scanResult;
73
+ // Run instrumentOnResult plugin pipeline
74
+ candidates = await runInstrumentOnResultPipeline(file, candidates, plugins, logger);
75
+ if (candidates.length === 0 && languageChangeSites.length === 0) {
76
+ continue;
77
+ }
78
+ totalCandidates += candidates.length;
79
+ // Handle interactive mode
80
+ if (options.isInteractive) {
81
+ // Ask user about each candidate
82
+ for (const candidate of candidates) {
83
+ const { action } = await inquirer.prompt([
84
+ {
85
+ type: 'list',
86
+ name: 'action',
87
+ message: `Translate: "${candidate.content}" (${candidate.line}:${candidate.column})?`,
88
+ choices: [
89
+ { name: 'Approve', value: 'approve' },
90
+ { name: 'Skip', value: 'skip' },
91
+ { name: 'Edit key', value: 'edit-key' },
92
+ { name: 'Edit value', value: 'edit-value' }
93
+ ]
94
+ }
95
+ ]);
96
+ switch (action) {
97
+ case 'approve':
98
+ candidate.key = generateKeyFromContent(candidate.content);
99
+ break;
100
+ case 'skip':
101
+ candidate.skipReason = 'User skipped';
102
+ break;
103
+ case 'edit-key': {
104
+ const { key } = await inquirer.prompt([
105
+ {
106
+ type: 'input',
107
+ name: 'key',
108
+ message: 'New key:',
109
+ default: generateKeyFromContent(candidate.content)
110
+ }
111
+ ]);
112
+ candidate.key = key;
113
+ break;
114
+ }
115
+ case 'edit-value': {
116
+ const { value } = await inquirer.prompt([
117
+ {
118
+ type: 'input',
119
+ name: 'value',
120
+ message: 'New value:',
121
+ default: candidate.content
122
+ }
123
+ ]);
124
+ candidate.content = value;
125
+ candidate.key = generateKeyFromContent(value);
126
+ break;
127
+ }
128
+ }
129
+ }
130
+ }
131
+ // Filter candidates that were skipped by user
132
+ const approvedCandidates = candidates.filter(c => !c.skipReason);
133
+ if (approvedCandidates.length > 0 || languageChangeSites.length > 0) {
134
+ // Generate keys for approved candidates
135
+ for (const candidate of approvedCandidates) {
136
+ if (!candidate.key) {
137
+ candidate.key = keyRegistry.add(generateKeyFromContent(candidate.content), candidate.content);
138
+ }
139
+ }
140
+ // Transform the file
141
+ const transformResult = transformFile(content, file, approvedCandidates, {
142
+ isDryRun: options.isDryRun,
143
+ hasReact,
144
+ isPrimaryLanguageFile: true,
145
+ config,
146
+ components,
147
+ namespace: targetNamespace,
148
+ languageChangeSites
149
+ });
150
+ if (!options.isDryRun && transformResult.modified) {
151
+ // Write the transformed file
152
+ await mkdir(dirname(file), { recursive: true });
153
+ await writeFile(file, transformResult.newContent || content);
154
+ }
155
+ totalTransformed += transformResult.transformCount;
156
+ totalLanguageChanges += transformResult.languageChangeCount;
157
+ totalSkipped += candidates.length - approvedCandidates.length;
158
+ // Log any warnings (e.g. i18next.t() in React files)
159
+ if (transformResult.warnings?.length) {
160
+ for (const warning of transformResult.warnings) {
161
+ logger.warn(warning);
162
+ }
163
+ }
164
+ results.push({
165
+ file,
166
+ candidates: approvedCandidates,
167
+ result: transformResult
168
+ });
169
+ }
170
+ else {
171
+ totalSkipped += candidates.length;
172
+ }
173
+ }
174
+ catch (err) {
175
+ logger.warn(`Error processing ${file}:`, err);
176
+ }
177
+ }
178
+ const langChangeSuffix = totalLanguageChanges > 0 ? `, ${totalLanguageChanges} language-change site(s)` : '';
179
+ spinner.succeed(styleText('bold', `Scanned complete: ${totalCandidates} candidates, ${totalTransformed} approved${langChangeSuffix}`));
180
+ // Generate i18n init file if needed and any transformations were made
181
+ if ((totalTransformed > 0 || totalLanguageChanges > 0) && !options.isDryRun) {
182
+ const initFilePath = await ensureI18nInitFile(hasReact, hasTypeScript, config, logger);
183
+ if (initFilePath) {
184
+ await injectI18nImportIntoEntryFile(initFilePath, logger);
185
+ }
186
+ }
187
+ // Return summary
188
+ return {
189
+ files: results,
190
+ totalCandidates,
191
+ totalTransformed,
192
+ totalSkipped,
193
+ totalLanguageChanges,
194
+ extractedKeys: new Map()
195
+ };
196
+ }
197
+ catch (error) {
198
+ spinner.fail(styleText('red', 'Instrumentation failed'));
199
+ throw error;
200
+ }
201
+ }
202
+ /**
203
+ * Scans a source file for hardcoded string candidates and React component boundaries.
204
+ */
205
+ async function scanFileForCandidates(content, file, config) {
206
+ const candidates = [];
207
+ const components = [];
208
+ const languageChangeSites = [];
209
+ const fileExt = extname(file).toLowerCase();
210
+ const isTypeScriptFile = ['.ts', '.tsx', '.mts', '.cts'].includes(fileExt);
211
+ const isTSX = fileExt === '.tsx';
212
+ const isJSX = fileExt === '.jsx';
213
+ try {
214
+ // Parse the file
215
+ let ast;
216
+ try {
217
+ ast = await parse(content, {
218
+ syntax: isTypeScriptFile ? 'typescript' : 'ecmascript',
219
+ tsx: isTSX,
220
+ jsx: isJSX,
221
+ decorators: true,
222
+ dynamicImport: true,
223
+ comments: true
224
+ });
225
+ }
226
+ catch (err) {
227
+ // Fallback parsing for .ts with JSX
228
+ if (fileExt === '.ts' && !isTSX) {
229
+ ast = await parse(content, {
230
+ syntax: 'typescript',
231
+ tsx: true,
232
+ decorators: true,
233
+ dynamicImport: true,
234
+ comments: true
235
+ });
236
+ }
237
+ else if (fileExt === '.js' && !isJSX) {
238
+ ast = await parse(content, {
239
+ syntax: 'ecmascript',
240
+ jsx: true,
241
+ decorators: true,
242
+ dynamicImport: true,
243
+ comments: true
244
+ });
245
+ }
246
+ else {
247
+ throw err;
248
+ }
249
+ }
250
+ // Normalize spans
251
+ const firstTokenIdx = findFirstTokenIndex(content);
252
+ const spanBase = ast.span.start - firstTokenIdx;
253
+ normalizeASTSpans(ast, spanBase);
254
+ // Convert byte offsets → char indices for files with multi-byte characters.
255
+ // SWC reports spans as UTF-8 byte offsets, but JavaScript strings and
256
+ // MagicString use UTF-16 code-unit indices. Without this conversion,
257
+ // every emoji / accented char / CJK char shifts all subsequent offsets.
258
+ const byteToChar = buildByteToCharMap(content);
259
+ if (byteToChar) {
260
+ convertSpansToCharIndices(ast, byteToChar);
261
+ }
262
+ // Detect React function component boundaries
263
+ detectComponentBoundaries(ast, content, components);
264
+ // Visit AST to find string literals
265
+ visitNodeForStrings(ast, content, file, config, candidates);
266
+ // Detect JSX interpolation patterns (merge adjacent text + expression children)
267
+ detectJSXInterpolation(ast, content, file, config, candidates);
268
+ // Detect plural conditional patterns (ternary chains checking count === 0/1/other)
269
+ detectPluralPatterns(ast, content, file, config, candidates);
270
+ // Detect language-change call sites (e.g. updateSettings({ language: code }))
271
+ detectLanguageChangeSites(ast, content, languageChangeSites);
272
+ // Annotate candidates with their enclosing component (if any)
273
+ for (const candidate of candidates) {
274
+ for (const comp of components) {
275
+ if (candidate.offset >= comp.bodyStart && candidate.endOffset <= comp.bodyEnd) {
276
+ candidate.insideComponent = comp.name;
277
+ break;
278
+ }
279
+ }
280
+ }
281
+ // Annotate language change sites with their enclosing component (if any)
282
+ for (const site of languageChangeSites) {
283
+ for (const comp of components) {
284
+ if (site.callStart >= comp.bodyStart && site.callEnd <= comp.bodyEnd) {
285
+ site.insideComponent = comp.name;
286
+ break;
287
+ }
288
+ }
289
+ }
290
+ // Filter out candidates suppressed by ignore-comment directives.
291
+ // Supported comments (line or block):
292
+ // // i18next-instrument-ignore-next-line
293
+ // // i18next-instrument-ignore
294
+ // /* i18next-instrument-ignore-next-line */
295
+ // /* i18next-instrument-ignore */
296
+ const ignoredLines = collectIgnoredLines(content);
297
+ if (ignoredLines.size > 0) {
298
+ const keep = [];
299
+ for (const c of candidates) {
300
+ const line = lineOfOffset(content, c.offset);
301
+ if (!ignoredLines.has(line)) {
302
+ keep.push(c);
303
+ }
304
+ }
305
+ candidates.length = 0;
306
+ candidates.push(...keep);
307
+ }
308
+ }
309
+ catch (err) {
310
+ // Silently skip files that can't be parsed
311
+ }
312
+ return { candidates, components, languageChangeSites };
313
+ }
314
+ // ─── Ignore-comment helpers ──────────────────────────────────────────────────
315
+ /**
316
+ * Regex that matches a directive comment requesting the instrumenter to skip
317
+ * the **next** line. Works with both line comments (`// ...`) and block
318
+ * comments. The supported directives are:
319
+ *
320
+ * i18next-instrument-ignore-next-line
321
+ * i18next-instrument-ignore
322
+ */
323
+ const IGNORE_RE = /i18next-instrument-ignore(?:-next-line)?/;
324
+ /**
325
+ * Scans `content` for ignore-directive comments and returns a Set of 1-based
326
+ * line numbers whose strings should be excluded from instrumentation.
327
+ */
328
+ function collectIgnoredLines(content) {
329
+ const ignored = new Set();
330
+ const lines = content.split('\n');
331
+ for (let i = 0; i < lines.length; i++) {
332
+ if (IGNORE_RE.test(lines[i])) {
333
+ // Directive on line i → suppress the *following* line (i + 1)
334
+ // We store 1-based line numbers, so the suppressed line is i + 2
335
+ ignored.add(i + 2);
336
+ }
337
+ }
338
+ return ignored;
339
+ }
340
+ /**
341
+ * Returns the 1-based line number for a character offset.
342
+ */
343
+ function lineOfOffset(content, offset) {
344
+ let line = 1;
345
+ for (let i = 0; i < offset && i < content.length; i++) {
346
+ if (content[i] === '\n')
347
+ line++;
348
+ }
349
+ return line;
350
+ }
351
+ // ─── Component boundary detection ───────────────────────────────────────────
352
+ /**
353
+ * Recursively detects React function component boundaries in the AST.
354
+ *
355
+ * Identifies:
356
+ * - FunctionDeclaration with uppercase name: `function Greeting() { ... }`
357
+ * - FunctionExpression with uppercase name: `export default function Greeting() { ... }`
358
+ * - VariableDeclarator with uppercase name + arrow/function expression:
359
+ * `const Greeting = () => { ... }` or `const Greeting = function() { ... }`
360
+ * - forwardRef wrappers: `const Greeting = React.forwardRef((props, ref) => { ... })`
361
+ * - memo wrappers: `const Greeting = React.memo(() => { ... })`
362
+ */
363
+ function detectComponentBoundaries(node, content, components) {
364
+ if (!node)
365
+ return;
366
+ // FunctionDeclaration with uppercase name
367
+ if (node.type === 'FunctionDeclaration' && node.identifier?.value && /^[A-Z]/.test(node.identifier.value)) {
368
+ const body = node.body;
369
+ if (body?.type === 'BlockStatement' && body.span) {
370
+ components.push({
371
+ name: node.identifier.value,
372
+ bodyStart: body.span.start,
373
+ bodyEnd: body.span.end,
374
+ hasUseTranslation: content.slice(body.span.start, body.span.end).includes('useTranslation')
375
+ });
376
+ }
377
+ }
378
+ // FunctionExpression with uppercase name.
379
+ // Covers `export default function TasksPage() { ... }` — SWC represents the
380
+ // function inside ExportDefaultDeclaration as a FunctionExpression, not a
381
+ // FunctionDeclaration. Deduplicate against bodies already registered by the
382
+ // VariableDeclarator path (e.g. `const Foo = function Foo() {}`).
383
+ if (node.type === 'FunctionExpression' && node.identifier?.value && /^[A-Z]/.test(node.identifier.value)) {
384
+ const body = node.body;
385
+ if (body?.type === 'BlockStatement' && body.span) {
386
+ const alreadyRegistered = components.some(c => c.bodyStart === body.span.start && c.bodyEnd === body.span.end);
387
+ if (!alreadyRegistered) {
388
+ components.push({
389
+ name: node.identifier.value,
390
+ bodyStart: body.span.start,
391
+ bodyEnd: body.span.end,
392
+ hasUseTranslation: content.slice(body.span.start, body.span.end).includes('useTranslation')
393
+ });
394
+ }
395
+ }
396
+ }
397
+ // VariableDeclarator with uppercase name
398
+ if (node.type === 'VariableDeclarator' && node.id?.value && /^[A-Z]/.test(node.id.value)) {
399
+ const init = node.init;
400
+ if (init) {
401
+ // Direct arrow/function expression: const Greeting = () => { ... }
402
+ if (init.type === 'ArrowFunctionExpression' || init.type === 'FunctionExpression') {
403
+ addComponentFromFunctionNode(node.id.value, init, content, components);
404
+ }
405
+ // Wrapped in a call: React.memo(...), React.forwardRef(...), memo(...), forwardRef(...)
406
+ if (init.type === 'CallExpression') {
407
+ const callee = init.callee;
408
+ const isWrapper =
409
+ // React.forwardRef, React.memo
410
+ (callee?.type === 'MemberExpression' &&
411
+ (callee.property?.value === 'forwardRef' || callee.property?.value === 'memo')) ||
412
+ // forwardRef, memo (direct import)
413
+ (callee?.type === 'Identifier' &&
414
+ (callee.value === 'forwardRef' || callee.value === 'memo'));
415
+ if (isWrapper && init.arguments?.length > 0) {
416
+ const arg = init.arguments[0]?.expression || init.arguments[0];
417
+ if (arg && (arg.type === 'ArrowFunctionExpression' || arg.type === 'FunctionExpression')) {
418
+ addComponentFromFunctionNode(node.id.value, arg, content, components);
419
+ }
420
+ }
421
+ }
422
+ }
423
+ }
424
+ // Recurse into children
425
+ for (const key in node) {
426
+ const value = node[key];
427
+ if (value && typeof value === 'object') {
428
+ if (Array.isArray(value)) {
429
+ value.forEach(item => detectComponentBoundaries(item, content, components));
430
+ }
431
+ else {
432
+ detectComponentBoundaries(value, content, components);
433
+ }
434
+ }
435
+ }
436
+ }
437
+ /**
438
+ * Helper: extracts a ComponentBoundary from an ArrowFunctionExpression or FunctionExpression
439
+ * that has a BlockStatement body.
440
+ */
441
+ function addComponentFromFunctionNode(name, fnNode, content, components) {
442
+ const body = fnNode.body;
443
+ if (body?.type === 'BlockStatement' && body.span) {
444
+ components.push({
445
+ name,
446
+ bodyStart: body.span.start,
447
+ bodyEnd: body.span.end,
448
+ hasUseTranslation: content.slice(body.span.start, body.span.end).includes('useTranslation')
449
+ });
450
+ }
451
+ }
452
+ // Non-translatable JSX attributes are defined in utils/jsx-attributes.ts
453
+ // and shared with the linter. The instrumenter uses `ignoredAttributeSet`
454
+ // to skip recursing into non-translatable attribute values.
455
+ /**
456
+ * Recursively visits AST nodes to find string literals.
457
+ */
458
+ function visitNodeForStrings(node, content, file, config, candidates) {
459
+ if (!node)
460
+ return;
461
+ // Skip non-translatable JSX attributes entirely (e.g. className={...})
462
+ if (node.type === 'JSXAttribute') {
463
+ const nameNode = node.name;
464
+ let attrName = null;
465
+ if (nameNode?.type === 'Identifier') {
466
+ attrName = nameNode.value;
467
+ }
468
+ else if (nameNode?.type === 'JSXNamespacedName') {
469
+ // e.g. data-testid
470
+ attrName = `${nameNode.namespace?.value ?? ''}:${nameNode.name?.value ?? ''}`;
471
+ }
472
+ if (attrName && ignoredAttributeSet.has(attrName))
473
+ return;
474
+ // For hyphenated names (data-testid), also check with the raw text
475
+ if (attrName?.includes(':')) {
476
+ const hyphenated = attrName.replace(':', '-');
477
+ if (ignoredAttributeSet.has(hyphenated))
478
+ return;
479
+ }
480
+ }
481
+ // Check if this is a string literal
482
+ if (node.type === 'StringLiteral') {
483
+ const stringNode = node;
484
+ if (stringNode.span && typeof stringNode.span.start === 'number') {
485
+ const candidate = detectCandidate(stringNode.value, stringNode.span.start, stringNode.span.end, file, content, config);
486
+ if (candidate && candidate.confidence >= 0.7) {
487
+ // Detect JSX attribute context: in JSX, attr="value" has = right before the opening quote
488
+ if (stringNode.span.start > 0 && content[stringNode.span.start - 1] === '=') {
489
+ candidate.type = 'jsx-attribute';
490
+ }
491
+ candidates.push(candidate);
492
+ }
493
+ }
494
+ }
495
+ // Template literals
496
+ if (node.type === 'TemplateLiteral') {
497
+ const quasis = node.quasis;
498
+ const expressions = node.expressions;
499
+ if (quasis?.length === 1 && (!expressions || expressions.length === 0)) {
500
+ // Static template literal (no interpolation), e.g. `Welcome back`
501
+ const raw = quasis[0].raw || quasis[0].cooked || '';
502
+ const trimmed = raw.trim();
503
+ if (trimmed && node.span && typeof node.span.start === 'number') {
504
+ const candidate = detectCandidate(trimmed, node.span.start, node.span.end, file, content, config);
505
+ if (candidate && candidate.confidence >= 0.7) {
506
+ candidate.type = 'template-literal';
507
+ if (node.span.start > 0 && content[node.span.start - 1] === '=') {
508
+ candidate.type = 'jsx-attribute';
509
+ }
510
+ candidates.push(candidate);
511
+ }
512
+ }
513
+ }
514
+ else if (quasis?.length > 1 && expressions?.length > 0 && node.span) {
515
+ // Template literal with interpolation, e.g. `${count}-day streak`
516
+ const result = buildInterpolatedTemplate(quasis, expressions, content);
517
+ if (result) {
518
+ const trimmed = result.text.trim();
519
+ if (trimmed) {
520
+ const candidate = detectCandidate(trimmed, node.span.start, node.span.end, file, content, config);
521
+ if (candidate) {
522
+ candidate.content = trimmed;
523
+ candidate.type = 'template-literal';
524
+ candidate.interpolations = result.interpolations;
525
+ // Template literals mixing text + expressions are likely user-facing
526
+ candidate.confidence = Math.min(1, candidate.confidence + 0.15);
527
+ if (node.span.start > 0 && content[node.span.start - 1] === '=') {
528
+ candidate.type = 'jsx-attribute';
529
+ }
530
+ if (candidate.confidence >= 0.7) {
531
+ candidates.push(candidate);
532
+ }
533
+ }
534
+ }
535
+ }
536
+ }
537
+ }
538
+ // Check if this is JSX text content (e.g. <h1>Hello World</h1>)
539
+ if (node.type === 'JSXText') {
540
+ if (node.span && typeof node.span.start === 'number') {
541
+ const raw = content.slice(node.span.start, node.span.end);
542
+ const trimmed = raw.trim();
543
+ if (trimmed) {
544
+ const trimmedStart = raw.indexOf(trimmed);
545
+ const offset = node.span.start + trimmedStart;
546
+ const endOffset = offset + trimmed.length;
547
+ const candidate = detectCandidate(trimmed, offset, endOffset, file, content, config);
548
+ if (candidate) {
549
+ candidate.type = 'jsx-text';
550
+ // JSXText is almost always user-visible content; boost confidence
551
+ candidate.confidence = Math.min(1, candidate.confidence + 0.2);
552
+ if (candidate.confidence >= 0.7) {
553
+ candidates.push(candidate);
554
+ }
555
+ }
556
+ }
557
+ }
558
+ }
559
+ // Recursively visit children
560
+ for (const key in node) {
561
+ const value = node[key];
562
+ if (value && typeof value === 'object') {
563
+ if (Array.isArray(value)) {
564
+ value.forEach(item => visitNodeForStrings(item, content, file, config, candidates));
565
+ }
566
+ else {
567
+ visitNodeForStrings(value, content, file, config, candidates);
568
+ }
569
+ }
570
+ }
571
+ }
572
+ // ─── Template-literal interpolation helpers ──────────────────────────────────
573
+ /**
574
+ * Builds a merged text with `{{var}}` placeholders from a template literal's
575
+ * quasis and expressions arrays.
576
+ */
577
+ function buildInterpolatedTemplate(quasis, expressions, content) {
578
+ const interpolations = [];
579
+ const usedNames = new Set();
580
+ let text = '';
581
+ for (let i = 0; i < quasis.length; i++) {
582
+ const quasi = quasis[i];
583
+ text += quasi.cooked ?? quasi.raw ?? '';
584
+ if (i < expressions.length) {
585
+ const info = resolveExpressionName(expressions[i], content, usedNames);
586
+ if (!info)
587
+ return null; // un-resolvable expression — bail
588
+ text += `{{${info.name}}}`;
589
+ interpolations.push(info);
590
+ }
591
+ }
592
+ return interpolations.length > 0 ? { text, interpolations } : null;
593
+ }
594
+ /**
595
+ * Resolves a human-friendly interpolation name from an AST expression node.
596
+ *
597
+ * - `Identifier` → uses the identifier value (`count`)
598
+ * - `MemberExpression` → uses the deepest property name (`profile.name` → `name`)
599
+ * - anything else → generated placeholder (`val`, `val2`, ...)
600
+ */
601
+ function resolveExpressionName(expr, content, usedNames) {
602
+ if (!expr?.span)
603
+ return null;
604
+ const expression = content.slice(expr.span.start, expr.span.end);
605
+ let baseName;
606
+ if (expr.type === 'Identifier') {
607
+ baseName = expr.value;
608
+ }
609
+ else if (expr.type === 'MemberExpression' && !expr.computed && expr.property?.type === 'Identifier') {
610
+ baseName = expr.property.value;
611
+ }
612
+ else {
613
+ baseName = 'val';
614
+ }
615
+ let name = baseName;
616
+ let counter = 2;
617
+ while (usedNames.has(name)) {
618
+ name = `${baseName}${counter++}`;
619
+ }
620
+ usedNames.add(name);
621
+ return { name, expression };
622
+ }
623
+ // ─── JSX sibling interpolation merging ───────────────────────────────────────
624
+ /**
625
+ * Walks the AST looking for JSXElement / JSXFragment nodes whose children
626
+ * contain a mergeable mix of `JSXText` and `JSXExpressionContainer` with simple
627
+ * expressions (Identifier / MemberExpression). When found, a single merged
628
+ * `CandidateString` is created and any overlapping individual candidates are
629
+ * removed.
630
+ */
631
+ function detectJSXInterpolation(node, content, file, config, candidates) {
632
+ if (!node)
633
+ return;
634
+ const children = (node.type === 'JSXElement' || node.type === 'JSXFragment') ? node.children : null;
635
+ if (children?.length > 1) {
636
+ // Build "runs" of consecutive JSXText + simple-expression containers
637
+ const runs = [];
638
+ let currentRun = [];
639
+ for (const child of children) {
640
+ if (child.type === 'JSXText') {
641
+ currentRun.push(child);
642
+ }
643
+ else if (child.type === 'JSXExpressionContainer' && isSimpleJSXExpression(child.expression)) {
644
+ currentRun.push(child);
645
+ }
646
+ else {
647
+ // JSXElement, complex expression, etc. — break the run
648
+ if (currentRun.length > 0) {
649
+ runs.push(currentRun);
650
+ currentRun = [];
651
+ }
652
+ }
653
+ }
654
+ if (currentRun.length > 0) {
655
+ runs.push(currentRun);
656
+ }
657
+ for (const run of runs) {
658
+ const hasText = run.some(c => c.type === 'JSXText' && c.value?.trim());
659
+ const hasExpr = run.some(c => c.type === 'JSXExpressionContainer');
660
+ if (!hasText || !hasExpr || run.length < 2)
661
+ continue;
662
+ // Build the interpolated text from the run
663
+ const usedNames = new Set();
664
+ const interpolations = [];
665
+ let text = '';
666
+ let valid = true;
667
+ for (const child of run) {
668
+ if (child.type === 'JSXText') {
669
+ text += content.slice(child.span.start, child.span.end);
670
+ }
671
+ else {
672
+ const info = resolveExpressionName(child.expression, content, usedNames);
673
+ if (!info) {
674
+ valid = false;
675
+ break;
676
+ }
677
+ text += `{{${info.name}}}`;
678
+ interpolations.push(info);
679
+ }
680
+ }
681
+ if (!valid)
682
+ continue;
683
+ const trimmed = text.trim();
684
+ if (!trimmed || interpolations.length === 0)
685
+ continue;
686
+ const spanStart = run[0].span.start;
687
+ const spanEnd = run[run.length - 1].span.end;
688
+ const candidate = detectCandidate(trimmed, spanStart, spanEnd, file, content, config);
689
+ if (candidate) {
690
+ candidate.type = 'jsx-text';
691
+ candidate.content = trimmed;
692
+ candidate.interpolations = interpolations;
693
+ // Mixed text + expressions in JSX is almost always user-facing
694
+ candidate.confidence = Math.min(1, candidate.confidence + 0.2);
695
+ if (candidate.confidence >= 0.7) {
696
+ // Remove individual candidates that overlap with the merged span
697
+ for (let i = candidates.length - 1; i >= 0; i--) {
698
+ if (candidates[i].offset >= spanStart && candidates[i].endOffset <= spanEnd) {
699
+ candidates.splice(i, 1);
700
+ }
701
+ }
702
+ candidates.push(candidate);
703
+ }
704
+ }
705
+ }
706
+ }
707
+ // Recurse into children
708
+ for (const key in node) {
709
+ const value = node[key];
710
+ if (value && typeof value === 'object') {
711
+ if (Array.isArray(value)) {
712
+ value.forEach(item => detectJSXInterpolation(item, content, file, config, candidates));
713
+ }
714
+ else {
715
+ detectJSXInterpolation(value, content, file, config, candidates);
716
+ }
717
+ }
718
+ }
719
+ }
720
+ /**
721
+ * Returns true when the expression is a simple Identifier or non-computed
722
+ * MemberExpression (i.e. dot notation like `profile.name`).
723
+ */
724
+ function isSimpleJSXExpression(expr) {
725
+ if (!expr)
726
+ return false;
727
+ if (expr.type === 'Identifier')
728
+ return true;
729
+ if (expr.type === 'MemberExpression' && !expr.computed && expr.property?.type === 'Identifier')
730
+ return true;
731
+ return false;
732
+ }
733
+ // ─── Plural conditional pattern detection ────────────────────────────────────
734
+ /**
735
+ * Extracts the text value from a string literal or static template literal node.
736
+ * Returns `null` for anything else.
737
+ */
738
+ function extractStaticText(node) {
739
+ if (!node)
740
+ return null;
741
+ if (node.type === 'StringLiteral')
742
+ return node.value;
743
+ if (node.type === 'TemplateLiteral') {
744
+ const quasis = node.quasis;
745
+ if (quasis?.length === 1 && (!node.expressions || node.expressions.length === 0)) {
746
+ return quasis[0].cooked ?? quasis[0].raw ?? null;
747
+ }
748
+ }
749
+ return null;
750
+ }
751
+ /**
752
+ * Extracts text from a node that may contain the count variable as an
753
+ * interpolation (e.g. `${activeTasks} tasks left`). The count variable
754
+ * is replaced with `{{count}}` in the returned text.
755
+ * Returns `null` when the node isn't a recognised textual form.
756
+ */
757
+ function extractTextWithCount(node, countIdentifier, content) {
758
+ // Static text (no variable at all — still valid for zero/one forms)
759
+ const staticText = extractStaticText(node);
760
+ if (staticText !== null)
761
+ return staticText;
762
+ // Template literal with interpolation
763
+ if (node?.type === 'TemplateLiteral') {
764
+ const quasis = node.quasis ?? [];
765
+ const expressions = node.expressions ?? [];
766
+ if (quasis.length === 0 || expressions.length === 0)
767
+ return null;
768
+ let text = '';
769
+ for (let i = 0; i < quasis.length; i++) {
770
+ text += quasis[i].cooked ?? quasis[i].raw ?? '';
771
+ if (i < expressions.length) {
772
+ const expr = expressions[i];
773
+ const exprText = content.slice(expr.span.start, expr.span.end);
774
+ if (exprText === countIdentifier) {
775
+ text += '{{count}}';
776
+ }
777
+ else {
778
+ // Unknown expression — bail
779
+ return null;
780
+ }
781
+ }
782
+ }
783
+ return text;
784
+ }
785
+ return null;
786
+ }
787
+ /**
788
+ * Resolves the count variable name from a `BinaryExpression` test of the form
789
+ * `identifier === <number>`. Returns `{ identifier, number }` or `null`.
790
+ */
791
+ function parseCountTest(test, content) {
792
+ if (test?.type !== 'BinaryExpression')
793
+ return null;
794
+ if (test.operator !== '===' && test.operator !== '==')
795
+ return null;
796
+ let identSide;
797
+ let numSide;
798
+ if (test.left?.type === 'NumericLiteral') {
799
+ numSide = test.left;
800
+ identSide = test.right;
801
+ }
802
+ else if (test.right?.type === 'NumericLiteral') {
803
+ numSide = test.right;
804
+ identSide = test.left;
805
+ }
806
+ else {
807
+ return null;
808
+ }
809
+ if (!identSide?.span)
810
+ return null;
811
+ const identifier = content.slice(identSide.span.start, identSide.span.end);
812
+ return { identifier, value: numSide.value };
813
+ }
814
+ /**
815
+ * Walks the AST looking for conditional (ternary) expression chains that
816
+ * correspond to a count-based pluralisation pattern, e.g.
817
+ *
818
+ * ```
819
+ * tasks === 0
820
+ * ? 'No tasks'
821
+ * : tasks === 1
822
+ * ? 'One task'
823
+ * : `${tasks} tasks`
824
+ * ```
825
+ *
826
+ * When such a pattern is detected a single `CandidateString` is emitted with
827
+ * `pluralForms` populated and any individual candidates that overlap with the
828
+ * ternary's span are removed.
829
+ */
830
+ function detectPluralPatterns(node, content, file, config, candidates) {
831
+ if (!node)
832
+ return;
833
+ if (node.type === 'ConditionalExpression') {
834
+ const plural = tryParsePluralTernary(node, content);
835
+ if (plural) {
836
+ // Use the "other" form as the candidate content (with {{count}})
837
+ const spanStart = node.span.start;
838
+ const spanEnd = node.span.end;
839
+ const candidate = detectCandidate(plural.other, spanStart, spanEnd, file, content, config);
840
+ if (candidate) {
841
+ candidate.type = 'string-literal';
842
+ candidate.content = plural.other;
843
+ candidate.pluralForms = {
844
+ countExpression: plural.countExpression,
845
+ zero: plural.zero,
846
+ one: plural.one,
847
+ other: plural.other
848
+ };
849
+ // Plural patterns are always user-facing text — boost confidence
850
+ candidate.confidence = Math.min(1, candidate.confidence + 0.3);
851
+ if (candidate.confidence >= 0.7) {
852
+ // Remove individual candidates that overlap with the ternary span
853
+ for (let i = candidates.length - 1; i >= 0; i--) {
854
+ if (candidates[i].offset >= spanStart && candidates[i].endOffset <= spanEnd) {
855
+ candidates.splice(i, 1);
856
+ }
857
+ }
858
+ candidates.push(candidate);
859
+ // Don't recurse into children — we already consumed the whole ternary
860
+ return;
861
+ }
862
+ }
863
+ }
864
+ }
865
+ // Recurse into children
866
+ for (const key in node) {
867
+ const value = node[key];
868
+ if (value && typeof value === 'object') {
869
+ if (Array.isArray(value)) {
870
+ value.forEach(item => detectPluralPatterns(item, content, file, config, candidates));
871
+ }
872
+ else {
873
+ detectPluralPatterns(value, content, file, config, candidates);
874
+ }
875
+ }
876
+ }
877
+ }
878
+ /**
879
+ * Attempts to parse a `ConditionalExpression` tree into a plural pattern.
880
+ *
881
+ * Supported shapes:
882
+ * count === 0 ? zeroText : count === 1 ? oneText : otherText (3-way)
883
+ * count === 1 ? oneText : otherText (2-way)
884
+ *
885
+ * Returns `null` when the node doesn't match any recognised plural shape.
886
+ */
887
+ function tryParsePluralTernary(node, content) {
888
+ if (node?.type !== 'ConditionalExpression')
889
+ return null;
890
+ const outerTest = parseCountTest(node.test, content);
891
+ if (!outerTest)
892
+ return null;
893
+ const countExpr = outerTest.identifier;
894
+ // ──── 3-way: count === 0 ? zero : count === 1 ? one : other ──────────
895
+ if (outerTest.value === 0) {
896
+ const zeroText = extractTextWithCount(node.consequent, countExpr, content);
897
+ if (zeroText === null)
898
+ return null;
899
+ const alt = node.alternate;
900
+ if (alt?.type === 'ConditionalExpression') {
901
+ const innerTest = parseCountTest(alt.test, content);
902
+ if (!innerTest || innerTest.identifier !== countExpr || innerTest.value !== 1)
903
+ return null;
904
+ const oneText = extractTextWithCount(alt.consequent, countExpr, content);
905
+ const otherText = extractTextWithCount(alt.alternate, countExpr, content);
906
+ if (oneText === null || otherText === null)
907
+ return null;
908
+ return { countExpression: countExpr, zero: zeroText, one: oneText, other: otherText };
909
+ }
910
+ // 2-way zero/other: count === 0 ? zero : other
911
+ const otherText = extractTextWithCount(node.alternate, countExpr, content);
912
+ if (otherText === null)
913
+ return null;
914
+ return { countExpression: countExpr, zero: zeroText, other: otherText };
915
+ }
916
+ // ──── 2-way: count === 1 ? one : other ───────────────────────────────
917
+ if (outerTest.value === 1) {
918
+ const oneText = extractTextWithCount(node.consequent, countExpr, content);
919
+ const otherText = extractTextWithCount(node.alternate, countExpr, content);
920
+ if (oneText === null || otherText === null)
921
+ return null;
922
+ return { countExpression: countExpr, one: oneText, other: otherText };
923
+ }
924
+ return null;
925
+ }
926
+ // ─── Language-change detection ───────────────────────────────────────────────
927
+ /**
928
+ * Property names that indicate a "language" value in an object literal.
929
+ * When a function call passes an object with one of these keys, we treat
930
+ * the call as a language-change site.
931
+ */
932
+ const LANGUAGE_PROP_NAMES = new Set(['language', 'lang', 'locale', 'lng']);
933
+ /**
934
+ * Function-name patterns that indicate a direct language setter.
935
+ * Matched against the full function name (case-insensitive).
936
+ *
937
+ * Examples: `setLanguage(code)`, `setLocale(lng)`, `changeLanguage(x)`
938
+ */
939
+ const LANGUAGE_SETTER_RE = /^(?:set|change|update|select)(?:Language|Lang|Locale|Lng)$/i;
940
+ /**
941
+ * Walks the AST looking for call expressions that appear to change the
942
+ * application language. Detected sites will later be augmented with an
943
+ * `i18n.changeLanguage()` call by the transformer.
944
+ *
945
+ * Recognised patterns:
946
+ *
947
+ * 1. Object-property setters:
948
+ * `updateSettings({ language: lang.code })`
949
+ * `setState({ locale: selectedLng })`
950
+ *
951
+ * 2. Direct setters:
952
+ * `setLanguage(code)`
953
+ * `setLocale(lng)`
954
+ * `changeLanguage(selectedLng)`
955
+ */
956
+ function detectLanguageChangeSites(node, content, sites) {
957
+ if (!node)
958
+ return;
959
+ if (node.type === 'CallExpression' && node.span) {
960
+ const detected = tryParseLanguageChangeCall(node, content);
961
+ if (detected) {
962
+ // Check if changeLanguage() already exists nearby (e.g. same arrow body)
963
+ const lookbackStart = Math.max(0, detected.callStart - 200);
964
+ const nearbyBefore = content.slice(lookbackStart, detected.callStart);
965
+ if (nearbyBefore.includes('changeLanguage')) ;
966
+ else {
967
+ // Compute 1-based line and 0-based column from offset
968
+ let line = 1;
969
+ let lastNewline = -1;
970
+ for (let i = 0; i < detected.callStart && i < content.length; i++) {
971
+ if (content[i] === '\n') {
972
+ line++;
973
+ lastNewline = i;
974
+ }
975
+ }
976
+ sites.push({
977
+ ...detected,
978
+ line,
979
+ column: detected.callStart - lastNewline - 1
980
+ });
981
+ }
982
+ // Don't return — keep recursing for nested calls
983
+ }
984
+ }
985
+ // Recurse into children
986
+ for (const key in node) {
987
+ const value = node[key];
988
+ if (value && typeof value === 'object') {
989
+ if (Array.isArray(value)) {
990
+ value.forEach(item => detectLanguageChangeSites(item, content, sites));
991
+ }
992
+ else {
993
+ detectLanguageChangeSites(value, content, sites);
994
+ }
995
+ }
996
+ }
997
+ }
998
+ /**
999
+ * Inspects a single CallExpression node to determine if it is a language-change
1000
+ * call. Returns the detected site data (without line/column) or `null`.
1001
+ */
1002
+ function tryParseLanguageChangeCall(node, content) {
1003
+ if (node.type !== 'CallExpression')
1004
+ return null;
1005
+ if (!node.arguments?.length)
1006
+ return null;
1007
+ const calleeName = getCalleeName(node.callee);
1008
+ // Skip calls that are already i18next API calls (e.g. i18n.changeLanguage())
1009
+ if (node.callee?.type === 'MemberExpression' && node.callee.object?.type === 'Identifier') {
1010
+ const objName = node.callee.object.value;
1011
+ if (objName === 'i18n' || objName === 'i18next')
1012
+ return null;
1013
+ }
1014
+ // ── Pattern 1: direct setter — setLanguage(expr), changeLocale(expr) etc. ──
1015
+ if (calleeName && LANGUAGE_SETTER_RE.test(calleeName)) {
1016
+ const firstArg = node.arguments[0]?.expression;
1017
+ if (firstArg?.span) {
1018
+ const langExpr = content.slice(firstArg.span.start, firstArg.span.end);
1019
+ // Skip if the argument is already an i18next.changeLanguage call
1020
+ if (langExpr.includes('changeLanguage'))
1021
+ return null;
1022
+ return {
1023
+ languageExpression: langExpr,
1024
+ callStart: node.span.start,
1025
+ callEnd: node.span.end
1026
+ };
1027
+ }
1028
+ }
1029
+ // ── Pattern 2: object with language property — fn({ language: expr }) ──
1030
+ for (const arg of node.arguments) {
1031
+ const expr = arg.expression ?? arg;
1032
+ if (expr?.type !== 'ObjectExpression')
1033
+ continue;
1034
+ if (!Array.isArray(expr.properties))
1035
+ continue;
1036
+ for (const prop of expr.properties) {
1037
+ if (prop.type !== 'KeyValueProperty')
1038
+ continue;
1039
+ const keyName = (prop.key?.type === 'Identifier' && prop.key.value) ||
1040
+ (prop.key?.type === 'StringLiteral' && prop.key.value);
1041
+ if (typeof keyName !== 'string')
1042
+ continue;
1043
+ if (!LANGUAGE_PROP_NAMES.has(keyName.toLowerCase()))
1044
+ continue;
1045
+ // Found a language property — extract the value expression
1046
+ const valNode = prop.value;
1047
+ if (valNode?.span) {
1048
+ const langExpr = content.slice(valNode.span.start, valNode.span.end);
1049
+ // Skip if value is a static string (e.g. `{ language: 'en' }` in a config)
1050
+ if (valNode.type === 'StringLiteral')
1051
+ continue;
1052
+ // Skip if already contains changeLanguage
1053
+ if (langExpr.includes('changeLanguage'))
1054
+ continue;
1055
+ return {
1056
+ languageExpression: langExpr,
1057
+ callStart: node.span.start,
1058
+ callEnd: node.span.end
1059
+ };
1060
+ }
1061
+ }
1062
+ }
1063
+ return null;
1064
+ }
1065
+ /**
1066
+ * Extracts a simple function name from a CallExpression callee.
1067
+ * Handles `Identifier` (e.g. `setLanguage`) and `MemberExpression`
1068
+ * (extracts the final property, e.g. `settings.setLanguage` → `setLanguage`).
1069
+ */
1070
+ function getCalleeName(callee) {
1071
+ if (!callee)
1072
+ return null;
1073
+ if (callee.type === 'Identifier')
1074
+ return callee.value;
1075
+ if (callee.type === 'MemberExpression' && !callee.computed && callee.property?.type === 'Identifier') {
1076
+ return callee.property.value;
1077
+ }
1078
+ return null;
1079
+ }
1080
+ /**
1081
+ * Gets the list of source files to instrument.
1082
+ */
1083
+ async function getSourceFilesForInstrumentation(config) {
1084
+ const defaultIgnore = ['node_modules/**', 'dist/**', 'build/**', '.next/**'];
1085
+ const userIgnore = Array.isArray(config.extract.ignore)
1086
+ ? config.extract.ignore
1087
+ : config.extract.ignore ? [config.extract.ignore] : [];
1088
+ return await glob(config.extract.input, {
1089
+ ignore: [...defaultIgnore, ...userIgnore],
1090
+ cwd: process.cwd()
1091
+ });
1092
+ }
1093
+ /**
1094
+ * Checks if the project uses React.
1095
+ */
1096
+ async function isProjectUsingReact() {
1097
+ try {
1098
+ const packageJsonPath = process.cwd() + '/package.json';
1099
+ const content = await readFile(packageJsonPath, 'utf-8');
1100
+ const packageJson = JSON.parse(content);
1101
+ const deps = { ...packageJson.dependencies, ...packageJson.devDependencies };
1102
+ return !!deps.react || !!deps['react-i18next'];
1103
+ }
1104
+ catch {
1105
+ return false;
1106
+ }
1107
+ }
1108
+ /**
1109
+ * Checks if the project uses TypeScript (looks for tsconfig.json).
1110
+ */
1111
+ async function isProjectUsingTypeScript() {
1112
+ try {
1113
+ await access(process.cwd() + '/tsconfig.json');
1114
+ return true;
1115
+ }
1116
+ catch {
1117
+ return false;
1118
+ }
1119
+ }
1120
+ /**
1121
+ * Checks if a file exists.
1122
+ */
1123
+ async function fileExists(filePath) {
1124
+ try {
1125
+ await access(filePath);
1126
+ return true;
1127
+ }
1128
+ catch {
1129
+ return false;
1130
+ }
1131
+ }
1132
+ /**
1133
+ * Common i18n init file names to check for.
1134
+ */
1135
+ const I18N_INIT_FILE_NAMES = [
1136
+ 'i18n.ts', 'i18n.js', 'i18n.mjs', 'i18n.mts',
1137
+ 'i18next.ts', 'i18next.js', 'i18next.mjs', 'i18next.mts',
1138
+ 'i18n/index.ts', 'i18n/index.js', 'i18n/index.mjs',
1139
+ 'i18next/index.ts', 'i18next/index.js'
1140
+ ];
1141
+ /**
1142
+ * Computes a POSIX-style relative path from the init-file directory to the
1143
+ * output template path (which still contains {{language}} / {{namespace}} placeholders).
1144
+ */
1145
+ function buildDynamicImportPath(outputTemplate, initDir) {
1146
+ const cwd = process.cwd();
1147
+ const absTemplate = isAbsolute(outputTemplate) ? outputTemplate : resolve(cwd, outputTemplate);
1148
+ let rel = relative(initDir, absTemplate);
1149
+ if (!rel.startsWith('.')) {
1150
+ rel = './' + rel;
1151
+ }
1152
+ return rel.replace(/\\/g, '/');
1153
+ }
1154
+ /**
1155
+ * Ensures that an i18n initialization file exists in the project.
1156
+ * If no existing init file is found, generates a sensible default.
1157
+ *
1158
+ * When extract.output is a string template, the generated file uses
1159
+ * i18next-resources-to-backend with a dynamic import derived from the
1160
+ * output path — making i18next ready to load translations out of the box.
1161
+ *
1162
+ * For React projects: creates i18n.ts with react-i18next integration.
1163
+ * For non-React projects: creates i18n.ts with basic i18next init.
1164
+ */
1165
+ async function ensureI18nInitFile(hasReact, hasTypeScript, config, logger) {
1166
+ const cwd = process.cwd();
1167
+ // Check for existing init files in common locations
1168
+ const searchDirs = ['src', '.'];
1169
+ for (const dir of searchDirs) {
1170
+ for (const name of I18N_INIT_FILE_NAMES) {
1171
+ if (await fileExists(join(cwd, dir, name))) {
1172
+ return null; // Init file already exists
1173
+ }
1174
+ }
1175
+ }
1176
+ // Check if i18next.init() is called anywhere in the source
1177
+ try {
1178
+ const sourceFiles = await getSourceFilesForInstrumentation(config);
1179
+ for (const file of sourceFiles) {
1180
+ try {
1181
+ const content = await readFile(file, 'utf-8');
1182
+ if (content.includes('i18next.init') || content.includes('.init(') || content.includes('i18n.init')) {
1183
+ return null; // Init is already present somewhere
1184
+ }
1185
+ }
1186
+ catch {
1187
+ // Skip unreadable files
1188
+ }
1189
+ }
1190
+ }
1191
+ catch {
1192
+ // Skip if file scanning fails
1193
+ }
1194
+ // Determine output location — prefer src/ if it exists
1195
+ const srcExists = await fileExists(join(cwd, 'src'));
1196
+ const initDir = srcExists ? join(cwd, 'src') : cwd;
1197
+ const initFileExt = hasTypeScript ? '.ts' : '.js';
1198
+ const initFilePath = join(initDir, 'i18n' + initFileExt);
1199
+ const primaryLang = config.extract.primaryLanguage ?? config.locales[0] ?? 'en';
1200
+ const defaultNS = config.extract.defaultNS !== false ? (config.extract.defaultNS || 'translation') : null;
1201
+ // Build the .init({...}) options block
1202
+ const initOptions = [
1203
+ ' returnEmptyString: false, // allows empty string as valid translation',
1204
+ ` // lng: ${config.locales.at(-1)}, // or add a language detector to detect the preferred language of your user`,
1205
+ ` fallbackLng: '${primaryLang}'`,
1206
+ ];
1207
+ if (defaultNS) {
1208
+ initOptions.push(` defaultNS: '${defaultNS}'`);
1209
+ }
1210
+ const initBlock = initOptions.join(',\n');
1211
+ let initContent;
1212
+ if (typeof config.extract.output === 'string') {
1213
+ // Derive a dynamic import path so the generated init file loads translations automatically
1214
+ const dynamicImportPath = buildDynamicImportPath(config.extract.output, initDir);
1215
+ const importPathTemplate = dynamicImportPath
1216
+ // eslint-disable-next-line no-template-curly-in-string
1217
+ .replace(/\{\{language\}\}|\{\{lng\}\}/g, '${language}')
1218
+ // eslint-disable-next-line no-template-curly-in-string
1219
+ .replace(/\{\{namespace\}\}/g, '${namespace}');
1220
+ const hasNamespace = config.extract.output.includes('{{namespace}}');
1221
+ const callbackParams = hasNamespace ? hasTypeScript ? 'language: string, namespace: string' : 'language, namespace' : hasTypeScript ? 'language: string' : 'language';
1222
+ const backendUseLine = ' .use(resourcesToBackend((' + callbackParams + ') => import(`' + importPathTemplate + '`)))';
1223
+ if (hasReact) {
1224
+ initContent = `// Generated by i18next-cli — review and adapt to your project's needs.
1225
+ // You may need to install dependencies: npm install i18next react-i18next i18next-resources-to-backend
1226
+ //
1227
+ // Other translation loading approaches:
1228
+ // • Static imports or bundled JSON: https://www.i18next.com/how-to/add-or-load-translations
1229
+ // • Lazy-load from a server: https://github.com/i18next/i18next-http-backend
1230
+ // • Manage translations with your team via Locize: https://www.locize.com
1231
+ // (see i18next-locize-backend: https://github.com/locize/i18next-locize-backend)
1232
+ import i18next from 'i18next'
1233
+ import { initReactI18next } from 'react-i18next'
1234
+ import resourcesToBackend from 'i18next-resources-to-backend'
1235
+
1236
+ i18next
1237
+ .use(initReactI18next)
1238
+ ${backendUseLine}
1239
+ .init({
1240
+ ${initBlock}
1241
+ })
1242
+
1243
+ export default i18next
1244
+ `;
1245
+ }
1246
+ else {
1247
+ initContent = `// Generated by i18next-cli — review and adapt to your project's needs.
1248
+ // You may need to install the dependency: npm install i18next i18next-resources-to-backend
1249
+ //
1250
+ // Other translation loading approaches:
1251
+ // • Static imports or bundled JSON: https://www.i18next.com/how-to/add-or-load-translations
1252
+ // • Lazy-load from a server: https://github.com/i18next/i18next-http-backend
1253
+ // • Manage translations with your team via Locize: https://www.locize.com
1254
+ // (see i18next-locize-backend: https://github.com/locize/i18next-locize-backend)
1255
+ import i18next from 'i18next'
1256
+ import resourcesToBackend from 'i18next-resources-to-backend'
1257
+
1258
+ i18next
1259
+ ${backendUseLine}
1260
+ .init({
1261
+ ${initBlock}
1262
+ })
1263
+
1264
+ export default i18next
1265
+ `;
1266
+ }
1267
+ }
1268
+ else {
1269
+ // Output is a function — can't derive import path, fall back to comments only
1270
+ if (hasReact) {
1271
+ initContent = `// Generated by i18next-cli — review and adapt to your project's needs.
1272
+ // You may need to install dependencies: npm install i18next react-i18next
1273
+ //
1274
+ // Loading translations:
1275
+ // • Static imports or bundled JSON: https://www.i18next.com/how-to/add-or-load-translations
1276
+ // • Lazy-load in memory with dynamic imports: https://github.com/i18next/i18next-resources-to-backend
1277
+ // • Lazy-load from a backend: https://github.com/i18next/i18next-http-backend
1278
+ // • Manage translations with your team via Locize: https://www.locize.com
1279
+ // (see i18next-locize-backend: https://github.com/locize/i18next-locize-backend)
1280
+ import i18next from 'i18next'
1281
+ import { initReactI18next } from 'react-i18next'
1282
+
1283
+ i18next
1284
+ .use(initReactI18next)
1285
+ .init({
1286
+ returnEmptyString: false, // allows empty string as valid translation
1287
+ // lng: ${config.locales.at(-1)}, // or add a language detector to detect the preferred language of your user
1288
+ fallbackLng: '${primaryLang}'
1289
+ // resources: { ... } — or use a backend plugin to load translations
1290
+ })
1291
+
1292
+ export default i18next
1293
+ `;
1294
+ }
1295
+ else {
1296
+ initContent = `// Generated by i18next-cli — review and adapt to your project's needs.
1297
+ // You may need to install the dependency: npm install i18next
1298
+ //
1299
+ // Loading translations:
1300
+ // • Static imports or bundled JSON: https://www.i18next.com/how-to/add-or-load-translations
1301
+ // • Lazy-load in memory with dynamic imports: https://github.com/i18next/i18next-resources-to-backend
1302
+ // • Lazy-load from a backend: https://github.com/i18next/i18next-http-backend
1303
+ // • Manage translations with your team via Locize: https://www.locize.com
1304
+ // (see i18next-locize-backend: https://github.com/locize/i18next-locize-backend)
1305
+ import i18next from 'i18next'
1306
+
1307
+ i18next.init({
1308
+ returnEmptyString: false, // allows empty string as valid translation
1309
+ // lng: ${config.locales.at(-1)}, // or add a language detector to detect the preferred language of your user
1310
+ fallbackLng: '${primaryLang}'
1311
+ // resources: { ... } — or use a backend plugin to load translations
1312
+ })
1313
+
1314
+ export default i18next
1315
+ `;
1316
+ }
1317
+ }
1318
+ try {
1319
+ await mkdir(initDir, { recursive: true });
1320
+ await writeFile(initFilePath, initContent);
1321
+ logger.info(`Generated i18n init file: ${initFilePath}`);
1322
+ return initFilePath;
1323
+ }
1324
+ catch (err) {
1325
+ logger.warn(`Failed to generate i18n init file: ${err}`);
1326
+ return null;
1327
+ }
1328
+ }
1329
+ /**
1330
+ * Common entry-point file names, checked in priority order.
1331
+ */
1332
+ const ENTRY_FILE_CANDIDATES = [
1333
+ 'src/main.tsx', 'src/main.ts', 'src/main.jsx', 'src/main.js',
1334
+ 'src/index.tsx', 'src/index.ts', 'src/index.jsx', 'src/index.js',
1335
+ 'src/App.tsx', 'src/App.ts', 'src/App.jsx', 'src/App.js',
1336
+ 'pages/_app.tsx', 'pages/_app.ts', 'pages/_app.jsx', 'pages/_app.js',
1337
+ 'app/layout.tsx', 'app/layout.ts', 'app/layout.jsx', 'app/layout.js',
1338
+ 'index.tsx', 'index.ts', 'index.jsx', 'index.js',
1339
+ 'main.tsx', 'main.ts', 'main.jsx', 'main.js'
1340
+ ];
1341
+ /**
1342
+ * Attempts to inject a side-effect import of the i18n init file into the
1343
+ * project's main entry file. If no recognisable entry file is found, or the
1344
+ * import already exists, the function silently returns.
1345
+ */
1346
+ async function injectI18nImportIntoEntryFile(initFilePath, logger) {
1347
+ const cwd = process.cwd();
1348
+ // 1. Find the first existing entry file
1349
+ let entryFilePath = null;
1350
+ for (const candidate of ENTRY_FILE_CANDIDATES) {
1351
+ const abs = join(cwd, candidate);
1352
+ if (await fileExists(abs)) {
1353
+ entryFilePath = abs;
1354
+ break;
1355
+ }
1356
+ }
1357
+ if (!entryFilePath) {
1358
+ logger.info('No recognisable entry file found — please import the i18n init file manually.');
1359
+ return;
1360
+ }
1361
+ // 2. Compute the relative import path (POSIX-style, without extension)
1362
+ const entryDir = dirname(entryFilePath);
1363
+ let rel = relative(entryDir, initFilePath).replace(/\\/g, '/');
1364
+ // Strip file extension for a cleaner import
1365
+ rel = rel.replace(/\.(tsx?|jsx?|mjs|mts)$/, '');
1366
+ if (!rel.startsWith('.')) {
1367
+ rel = './' + rel;
1368
+ }
1369
+ // 3. Check whether the import is already present
1370
+ let content;
1371
+ try {
1372
+ content = await readFile(entryFilePath, 'utf-8');
1373
+ }
1374
+ catch {
1375
+ logger.warn(`Could not read entry file: ${entryFilePath}`);
1376
+ return;
1377
+ }
1378
+ // Check for existing import of the i18n init file (with or without extension)
1379
+ const importBase = rel.replace(/^\.\//, '');
1380
+ const importPatterns = [
1381
+ `import '${rel}'`, `import "${rel}"`,
1382
+ `import './${importBase}'`, `import "./${importBase}"`,
1383
+ `import '${rel}.`, `import "${rel}.`,
1384
+ // Also check for require or named imports
1385
+ `from '${rel}'`, `from "${rel}"`,
1386
+ `from './${importBase}'`, `from "./${importBase}"`,
1387
+ // Bare 'i18n' import
1388
+ "import './i18n'", 'import "./i18n"',
1389
+ "import '../i18n'", 'import "../i18n"',
1390
+ "from './i18n'", 'from "./i18n"',
1391
+ "from '../i18n'", 'from "../i18n"'
1392
+ ];
1393
+ for (const pattern of importPatterns) {
1394
+ if (content.includes(pattern)) {
1395
+ return; // Already imported
1396
+ }
1397
+ }
1398
+ // 4. Insert the import at the top, right after any existing import block
1399
+ const importStatement = `import '${rel}'\n`;
1400
+ // Find the best insertion point: after the last top-level import statement
1401
+ const lines = content.split('\n');
1402
+ let lastImportLine = -1;
1403
+ for (let i = 0; i < lines.length; i++) {
1404
+ const trimmed = lines[i].trimStart();
1405
+ if (trimmed.startsWith('import ') || trimmed.startsWith('import\t') || trimmed.startsWith('import{')) {
1406
+ // Walk past multi-line imports
1407
+ lastImportLine = i;
1408
+ }
1409
+ }
1410
+ let newContent;
1411
+ if (lastImportLine >= 0) {
1412
+ // Insert after the last import line
1413
+ lines.splice(lastImportLine + 1, 0, importStatement.trimEnd());
1414
+ newContent = lines.join('\n');
1415
+ }
1416
+ else {
1417
+ // No imports found — prepend at the very top
1418
+ newContent = importStatement + content;
1419
+ }
1420
+ try {
1421
+ await writeFile(entryFilePath, newContent);
1422
+ logger.info(`Injected i18n import into entry file: ${entryFilePath}`);
1423
+ }
1424
+ catch (err) {
1425
+ logger.warn(`Failed to inject i18n import into entry file: ${err}`);
1426
+ }
1427
+ }
1428
+ /**
1429
+ * Extracts and writes translation keys discovered during instrumentation.
1430
+ */
1431
+ async function writeExtractedKeys(candidates, config, namespace, logger = new ConsoleLogger()) {
1432
+ if (candidates.length === 0)
1433
+ return;
1434
+ const primaryLanguage = config.extract.primaryLanguage ?? config.locales[0] ?? 'en';
1435
+ const ns = namespace ?? config.extract.defaultNS ?? 'translation';
1436
+ // Build a map of keys from candidates
1437
+ const translations = {};
1438
+ for (const candidate of candidates) {
1439
+ if (candidate.key) {
1440
+ if (candidate.pluralForms) {
1441
+ // Write separate plural-suffix entries for i18next
1442
+ const pf = candidate.pluralForms;
1443
+ if (pf.zero != null) {
1444
+ translations[`${candidate.key}_zero`] = pf.zero;
1445
+ }
1446
+ if (pf.one != null) {
1447
+ translations[`${candidate.key}_one`] = pf.one;
1448
+ }
1449
+ translations[`${candidate.key}_other`] = pf.other;
1450
+ }
1451
+ else {
1452
+ translations[candidate.key] = candidate.content;
1453
+ }
1454
+ }
1455
+ }
1456
+ if (Object.keys(translations).length === 0)
1457
+ return;
1458
+ // Get the output path
1459
+ const outputPath = getOutputPath(config.extract.output, primaryLanguage, typeof ns === 'string' ? ns : 'translation');
1460
+ try {
1461
+ // Load existing translations
1462
+ let existingContent = {};
1463
+ try {
1464
+ const content = await readFile(outputPath, 'utf-8');
1465
+ existingContent = JSON.parse(content);
1466
+ }
1467
+ catch {
1468
+ existingContent = {};
1469
+ }
1470
+ // Merge with new translations
1471
+ const merged = { ...existingContent, ...translations };
1472
+ // Write the file
1473
+ await mkdir(dirname(outputPath), { recursive: true });
1474
+ await writeFile(outputPath, JSON.stringify(merged, null, 2));
1475
+ logger.info(`Updated ${outputPath}`);
1476
+ }
1477
+ catch (err) {
1478
+ logger.warn(`Failed to write translated keys: ${err}`);
1479
+ }
1480
+ }
1481
+ // ─── Plugin pipeline helpers ───────────────────────────────────────────────
1482
+ /**
1483
+ * Normalizes a file extension to lowercase with a leading dot.
1484
+ */
1485
+ function normalizeExtension(ext) {
1486
+ const trimmed = ext.trim().toLowerCase();
1487
+ if (!trimmed)
1488
+ return '';
1489
+ return trimmed.startsWith('.') ? trimmed : `.${trimmed}`;
1490
+ }
1491
+ /**
1492
+ * Checks whether an instrument plugin should run for a given file,
1493
+ * based on its `instrumentExtensions` hint.
1494
+ */
1495
+ function shouldRunInstrumentPluginForFile(plugin, filePath) {
1496
+ const hints = plugin.instrumentExtensions;
1497
+ if (!hints || hints.length === 0)
1498
+ return true;
1499
+ const fileExt = normalizeExtension(extname(filePath));
1500
+ if (!fileExt)
1501
+ return false;
1502
+ const normalizedHints = hints.map(h => normalizeExtension(h)).filter(Boolean);
1503
+ if (normalizedHints.length === 0)
1504
+ return true;
1505
+ return normalizedHints.includes(fileExt);
1506
+ }
1507
+ /**
1508
+ * Runs `instrumentSetup` for every plugin that implements it.
1509
+ */
1510
+ async function initializeInstrumentPlugins(plugins, context) {
1511
+ for (const plugin of plugins) {
1512
+ try {
1513
+ await plugin.instrumentSetup?.(context);
1514
+ }
1515
+ catch (err) {
1516
+ context.logger.warn(`Plugin ${plugin.name} instrumentSetup failed:`, err);
1517
+ }
1518
+ }
1519
+ }
1520
+ /**
1521
+ * Runs the `instrumentOnLoad` pipeline for a file.
1522
+ * Returns `null` if a plugin wants to skip the file, otherwise the
1523
+ * (possibly transformed) source code.
1524
+ */
1525
+ async function runInstrumentOnLoadPipeline(initialCode, filePath, plugins, logger) {
1526
+ let code = initialCode;
1527
+ for (const plugin of plugins) {
1528
+ if (!shouldRunInstrumentPluginForFile(plugin, filePath))
1529
+ continue;
1530
+ try {
1531
+ const result = await plugin.instrumentOnLoad?.(code, filePath);
1532
+ if (result === null)
1533
+ return null;
1534
+ if (typeof result === 'string')
1535
+ code = result;
1536
+ }
1537
+ catch (err) {
1538
+ logger.warn(`Plugin ${plugin.name} instrumentOnLoad failed:`, err);
1539
+ }
1540
+ }
1541
+ return code;
1542
+ }
1543
+ /**
1544
+ * Runs the `instrumentOnResult` pipeline for a file's candidates.
1545
+ * Returns the (possibly modified) array of candidates.
1546
+ */
1547
+ async function runInstrumentOnResultPipeline(filePath, initialCandidates, plugins, logger) {
1548
+ let candidates = initialCandidates;
1549
+ for (const plugin of plugins) {
1550
+ if (!shouldRunInstrumentPluginForFile(plugin, filePath))
1551
+ continue;
1552
+ try {
1553
+ const result = await plugin.instrumentOnResult?.(filePath, candidates);
1554
+ if (Array.isArray(result)) {
1555
+ candidates = result;
1556
+ }
1557
+ }
1558
+ catch (err) {
1559
+ logger.warn(`Plugin ${plugin.name} instrumentOnResult failed:`, err);
1560
+ }
1561
+ }
1562
+ return candidates;
1563
+ }
1564
+ // ─── Byte → char offset helpers ────────────────────────────────────────────
1565
+ /**
1566
+ * Builds a lookup table from UTF-8 byte offsets to JavaScript string character
1567
+ * indices (UTF-16 code-unit positions).
1568
+ *
1569
+ * SWC internally represents source as UTF-8 and reports AST spans as byte
1570
+ * offsets into that representation. MagicString and all JavaScript String
1571
+ * methods operate on UTF-16 code-unit indices. For files that contain only
1572
+ * ASCII characters the two coincide, so this function returns `null` as a
1573
+ * fast path. For files with multi-byte characters (emoji, accented letters,
1574
+ * CJK, etc.) the returned array allows O(1) conversion of any byte offset.
1575
+ */
1576
+ function buildByteToCharMap(content) {
1577
+ // Fast path: pure ASCII means byte offset ≡ char index
1578
+ // eslint-disable-next-line no-control-regex
1579
+ if (!/[^\x00-\x7F]/.test(content))
1580
+ return null;
1581
+ const map = [];
1582
+ let byteIdx = 0;
1583
+ for (let charIdx = 0; charIdx < content.length;) {
1584
+ const cp = content.codePointAt(charIdx);
1585
+ const byteLen = cp <= 0x7F ? 1 : cp <= 0x7FF ? 2 : cp <= 0xFFFF ? 3 : 4;
1586
+ const charLen = cp > 0xFFFF ? 2 : 1; // surrogate pair
1587
+ // Every byte belonging to this character maps to the same char index
1588
+ for (let b = 0; b < byteLen; b++) {
1589
+ map[byteIdx + b] = charIdx;
1590
+ }
1591
+ byteIdx += byteLen;
1592
+ charIdx += charLen;
1593
+ }
1594
+ // Sentinel so that span.end (one-past-the-last-byte) resolves correctly
1595
+ map[byteIdx] = content.length;
1596
+ return map;
1597
+ }
1598
+ /**
1599
+ * Recursively converts every `span.start` / `span.end` in an SWC AST from
1600
+ * UTF-8 byte offsets to JavaScript string character indices using the
1601
+ * pre-built lookup table.
1602
+ */
1603
+ function convertSpansToCharIndices(node, byteToChar) {
1604
+ if (!node || typeof node !== 'object')
1605
+ return;
1606
+ if (node.span && typeof node.span.start === 'number') {
1607
+ const charStart = byteToChar[node.span.start];
1608
+ const charEnd = byteToChar[node.span.end];
1609
+ if (charStart !== undefined && charEnd !== undefined) {
1610
+ node.span = { ...node.span, start: charStart, end: charEnd };
1611
+ }
1612
+ }
1613
+ for (const key of Object.keys(node)) {
1614
+ if (key === 'span')
1615
+ continue;
1616
+ const child = node[key];
1617
+ if (Array.isArray(child)) {
1618
+ for (const item of child) {
1619
+ if (item && typeof item === 'object') {
1620
+ convertSpansToCharIndices(item, byteToChar);
1621
+ }
1622
+ }
1623
+ }
1624
+ else if (child && typeof child === 'object') {
1625
+ convertSpansToCharIndices(child, byteToChar);
1626
+ }
1627
+ }
1628
+ }
1629
+
1630
+ export { runInstrumenter, writeExtractedKeys };