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