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.
- package/README.md +198 -3
- package/dist/cjs/cli.js +50 -1
- package/dist/cjs/extractor/core/extractor.js +29 -19
- package/dist/cjs/extractor/core/translation-manager.js +18 -10
- package/dist/cjs/extractor/parsers/call-expression-handler.js +9 -1
- package/dist/cjs/extractor/parsers/jsx-handler.js +10 -1
- package/dist/cjs/index.js +5 -0
- package/dist/cjs/init.js +68 -12
- package/dist/cjs/instrumenter/core/instrumenter.js +1633 -0
- package/dist/cjs/instrumenter/core/key-generator.js +71 -0
- package/dist/cjs/instrumenter/core/string-detector.js +290 -0
- package/dist/cjs/instrumenter/core/transformer.js +339 -0
- package/dist/cjs/linter.js +6 -7
- package/dist/cjs/utils/jsx-attributes.js +131 -0
- package/dist/esm/cli.js +50 -1
- package/dist/esm/extractor/core/extractor.js +29 -19
- package/dist/esm/extractor/core/translation-manager.js +18 -10
- package/dist/esm/extractor/parsers/call-expression-handler.js +9 -1
- package/dist/esm/extractor/parsers/jsx-handler.js +10 -1
- package/dist/esm/index.js +3 -0
- package/dist/esm/init.js +68 -12
- package/dist/esm/instrumenter/core/instrumenter.js +1630 -0
- package/dist/esm/instrumenter/core/key-generator.js +68 -0
- package/dist/esm/instrumenter/core/string-detector.js +288 -0
- package/dist/esm/instrumenter/core/transformer.js +336 -0
- package/dist/esm/linter.js +6 -7
- package/dist/esm/utils/jsx-attributes.js +121 -0
- package/package.json +2 -1
- package/types/cli.d.ts.map +1 -1
- package/types/extractor/core/extractor.d.ts.map +1 -1
- package/types/extractor/core/translation-manager.d.ts +3 -2
- package/types/extractor/core/translation-manager.d.ts.map +1 -1
- package/types/extractor/parsers/call-expression-handler.d.ts.map +1 -1
- package/types/extractor/parsers/jsx-handler.d.ts.map +1 -1
- package/types/index.d.ts +2 -1
- package/types/index.d.ts.map +1 -1
- package/types/init.d.ts.map +1 -1
- package/types/instrumenter/core/instrumenter.d.ts +16 -0
- package/types/instrumenter/core/instrumenter.d.ts.map +1 -0
- package/types/instrumenter/core/key-generator.d.ts +30 -0
- package/types/instrumenter/core/key-generator.d.ts.map +1 -0
- package/types/instrumenter/core/string-detector.d.ts +27 -0
- package/types/instrumenter/core/string-detector.d.ts.map +1 -0
- package/types/instrumenter/core/transformer.d.ts +31 -0
- package/types/instrumenter/core/transformer.d.ts.map +1 -0
- package/types/instrumenter/index.d.ts +6 -0
- package/types/instrumenter/index.d.ts.map +1 -0
- package/types/linter.d.ts.map +1 -1
- package/types/types.d.ts +285 -1
- package/types/types.d.ts.map +1 -1
- package/types/utils/jsx-attributes.d.ts +68 -0
- 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;
|