i18next-cli 1.46.4 → 1.47.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +198 -3
- package/dist/cjs/cli.js +50 -1
- package/dist/cjs/extractor/core/extractor.js +27 -18
- package/dist/cjs/extractor/core/translation-manager.js +10 -3
- 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 +27 -18
- package/dist/esm/extractor/core/translation-manager.js +10 -3
- 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.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,68 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Generates camelCase keys from English string content.
|
|
3
|
+
*
|
|
4
|
+
* Examples:
|
|
5
|
+
* "Welcome back" → "welcomeBack"
|
|
6
|
+
* "Hello, World!" → "helloWorld"
|
|
7
|
+
* "You have 3 items" → "youHave3Items"
|
|
8
|
+
*
|
|
9
|
+
* @param content - The string content to derive a key from
|
|
10
|
+
* @returns camelCase key
|
|
11
|
+
*/
|
|
12
|
+
function generateKeyFromContent(content) {
|
|
13
|
+
// Remove punctuation and split by whitespace and word boundaries
|
|
14
|
+
const normalized = content
|
|
15
|
+
.replace(/[^\w\s\d]/g, '') // Remove punctuation
|
|
16
|
+
.trim();
|
|
17
|
+
if (!normalized) {
|
|
18
|
+
return 'key';
|
|
19
|
+
}
|
|
20
|
+
// Split on whitespace and camelCase
|
|
21
|
+
const words = normalized.split(/\s+/);
|
|
22
|
+
const camelCased = words
|
|
23
|
+
.map((word, index) => {
|
|
24
|
+
if (index === 0) {
|
|
25
|
+
return word.toLowerCase();
|
|
26
|
+
}
|
|
27
|
+
return word.charAt(0).toUpperCase() + word.slice(1).toLowerCase();
|
|
28
|
+
})
|
|
29
|
+
.join('');
|
|
30
|
+
// If result is empty, use fallback
|
|
31
|
+
return camelCased.length > 0 ? camelCased : 'key';
|
|
32
|
+
}
|
|
33
|
+
/**
|
|
34
|
+
* Creates a new key registry with collision detection.
|
|
35
|
+
*/
|
|
36
|
+
function createKeyRegistry() {
|
|
37
|
+
const keys = new Map();
|
|
38
|
+
return {
|
|
39
|
+
keys,
|
|
40
|
+
add(baseKey, content) {
|
|
41
|
+
const existing = keys.get(baseKey);
|
|
42
|
+
// No collision - add the key
|
|
43
|
+
if (!existing) {
|
|
44
|
+
keys.set(baseKey, content);
|
|
45
|
+
return baseKey;
|
|
46
|
+
}
|
|
47
|
+
// Same content already exists - return the existing key
|
|
48
|
+
if (existing === content) {
|
|
49
|
+
return baseKey;
|
|
50
|
+
}
|
|
51
|
+
// Collision detected - try with numeric suffixes
|
|
52
|
+
let counter = 2;
|
|
53
|
+
let candidateKey = `${baseKey}${counter}`;
|
|
54
|
+
while (keys.has(candidateKey)) {
|
|
55
|
+
const candidate = keys.get(candidateKey);
|
|
56
|
+
if (candidate === content) {
|
|
57
|
+
return candidateKey; // This exact content already has a numbered key
|
|
58
|
+
}
|
|
59
|
+
counter++;
|
|
60
|
+
candidateKey = `${baseKey}${counter}`;
|
|
61
|
+
}
|
|
62
|
+
keys.set(candidateKey, content);
|
|
63
|
+
return candidateKey;
|
|
64
|
+
}
|
|
65
|
+
};
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
export { createKeyRegistry, generateKeyFromContent };
|
|
@@ -0,0 +1,288 @@
|
|
|
1
|
+
import { lineColumnFromOffset } from '../../extractor/parsers/ast-utils.js';
|
|
2
|
+
import { translatableAttributeSet, translatablePropertySet } from '../../utils/jsx-attributes.js';
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Detects if a string is a candidate for translation based on confidence heuristics.
|
|
6
|
+
* Returns null if the string should be skipped, otherwise returns a CandidateString
|
|
7
|
+
* with a confidence score.
|
|
8
|
+
*
|
|
9
|
+
* When a custom scorer is provided via `config.extract.instrumentScorer`, it is
|
|
10
|
+
* called after the built-in skip checks. The scorer can:
|
|
11
|
+
* - Return a number (0-1) to override the confidence score
|
|
12
|
+
* - Return `null` to force-skip the candidate
|
|
13
|
+
* - Return `undefined` to fall back to the built-in heuristic
|
|
14
|
+
*
|
|
15
|
+
* **Important:** This uses heuristic-based detection and will not catch 100% of cases.
|
|
16
|
+
* False positives and false negatives are expected. The results serve as a starting point
|
|
17
|
+
* for manual review and refinement. Always review the generated transformations before
|
|
18
|
+
* committing them to your codebase.
|
|
19
|
+
*
|
|
20
|
+
* @param content - The string content to evaluate
|
|
21
|
+
* @param offset - Byte offset in file (normalized)
|
|
22
|
+
* @param endOffset - End byte offset in file
|
|
23
|
+
* @param file - Source file path
|
|
24
|
+
* @param code - Full source code for context
|
|
25
|
+
* @param config - Toolkit configuration
|
|
26
|
+
* @returns CandidateString with confidence score, or null if should be skipped
|
|
27
|
+
*/
|
|
28
|
+
function detectCandidate(content, offset, endOffset, file, code, config) {
|
|
29
|
+
const skipReason = shouldSkip(content, file, code, offset, endOffset);
|
|
30
|
+
if (skipReason) {
|
|
31
|
+
return null;
|
|
32
|
+
}
|
|
33
|
+
const position = lineColumnFromOffset(code, offset) ?? { line: 0, column: 0 };
|
|
34
|
+
const { line, column } = position;
|
|
35
|
+
// If a custom scorer is provided, call it first
|
|
36
|
+
const customScorer = config.extract?.instrumentScorer;
|
|
37
|
+
if (customScorer) {
|
|
38
|
+
const beforeContext = code.substring(Math.max(0, offset - 100), offset);
|
|
39
|
+
const afterContext = code.substring(endOffset, Math.min(code.length, endOffset + 100));
|
|
40
|
+
const customResult = customScorer(content, { file, offset, code, beforeContext, afterContext });
|
|
41
|
+
if (customResult === null) {
|
|
42
|
+
return null; // Custom scorer says skip
|
|
43
|
+
}
|
|
44
|
+
if (typeof customResult === 'number') {
|
|
45
|
+
return {
|
|
46
|
+
content,
|
|
47
|
+
confidence: Math.max(0, Math.min(1, customResult)),
|
|
48
|
+
offset,
|
|
49
|
+
endOffset,
|
|
50
|
+
type: 'string-literal',
|
|
51
|
+
file,
|
|
52
|
+
line,
|
|
53
|
+
column
|
|
54
|
+
};
|
|
55
|
+
}
|
|
56
|
+
// customResult === undefined → fall through to built-in heuristic
|
|
57
|
+
}
|
|
58
|
+
const confidence = calculateConfidence(content, code, offset);
|
|
59
|
+
return {
|
|
60
|
+
content,
|
|
61
|
+
confidence,
|
|
62
|
+
offset,
|
|
63
|
+
endOffset,
|
|
64
|
+
type: 'string-literal',
|
|
65
|
+
file,
|
|
66
|
+
line,
|
|
67
|
+
column
|
|
68
|
+
};
|
|
69
|
+
}
|
|
70
|
+
/**
|
|
71
|
+
* Determines if a string should be skipped from instrumentation.
|
|
72
|
+
*
|
|
73
|
+
* @returns Skip reason if should be skipped, null otherwise
|
|
74
|
+
*/
|
|
75
|
+
function shouldSkip(content, file, code, offset, endOffset, config) {
|
|
76
|
+
// Skip test files
|
|
77
|
+
if (file.match(/\.(test|spec)\.(ts|tsx|js|jsx)$/i)) {
|
|
78
|
+
return 'Test file';
|
|
79
|
+
}
|
|
80
|
+
// Skip empty strings and single characters
|
|
81
|
+
if (!content || content.length <= 1) {
|
|
82
|
+
return 'Empty or single character';
|
|
83
|
+
}
|
|
84
|
+
// Skip pure whitespace
|
|
85
|
+
if (/^\s+$/.test(content)) {
|
|
86
|
+
return 'Whitespace only';
|
|
87
|
+
}
|
|
88
|
+
// Skip pure numbers (including decimals and negative numbers)
|
|
89
|
+
if (/^-?\d+(\.\d+)?$/.test(content)) {
|
|
90
|
+
return 'Pure number';
|
|
91
|
+
}
|
|
92
|
+
// Skip URL-like strings
|
|
93
|
+
if (isURLLike(content)) {
|
|
94
|
+
return 'URL or path';
|
|
95
|
+
}
|
|
96
|
+
// Skip technical strings: attribute values, class names, technical IDs
|
|
97
|
+
if (isTechnicalString(content)) {
|
|
98
|
+
return 'Technical string';
|
|
99
|
+
}
|
|
100
|
+
// Skip developer-facing error codes (all-caps, underscore-delimited)
|
|
101
|
+
if (isErrorCode(content)) {
|
|
102
|
+
return 'Error code pattern';
|
|
103
|
+
}
|
|
104
|
+
// Skip strings that are already wrapped in t() or Trans components
|
|
105
|
+
if (isAlreadyInstrumented(code, offset, endOffset)) {
|
|
106
|
+
return 'Already instrumented';
|
|
107
|
+
}
|
|
108
|
+
// Skip console.log/warn/error arguments
|
|
109
|
+
if (isConsoleArgument(code, offset)) {
|
|
110
|
+
return 'Console argument';
|
|
111
|
+
}
|
|
112
|
+
// Skip HTML/JSX attribute values that are clearly technical
|
|
113
|
+
if (isAttributeValue(code, offset)) {
|
|
114
|
+
return 'HTML attribute value';
|
|
115
|
+
}
|
|
116
|
+
return null;
|
|
117
|
+
}
|
|
118
|
+
/**
|
|
119
|
+
* Checks if a string looks like a URL or file path.
|
|
120
|
+
*/
|
|
121
|
+
function isURLLike(str) {
|
|
122
|
+
// URLs: http://, https://, ftp://, file://, mailto:, etc.
|
|
123
|
+
if (/^(https?|ftp|file|mailto|data):/.test(str))
|
|
124
|
+
return true;
|
|
125
|
+
// File paths: ./path, ../path, /absolute/path, C:\windows\path
|
|
126
|
+
if (/^(\.\.?\/|\/|[A-Za-z]:\\)/.test(str))
|
|
127
|
+
return true;
|
|
128
|
+
// Import paths (common patterns) — must have no spaces to avoid false positives on sentences
|
|
129
|
+
if (/^['"]?[@a-z][\w-]*/.test(str.toLowerCase()) && !str.includes(' ') && (str.includes('/') || str.includes('.'))) {
|
|
130
|
+
return true;
|
|
131
|
+
}
|
|
132
|
+
return false;
|
|
133
|
+
}
|
|
134
|
+
/**
|
|
135
|
+
* Checks if a string is technical (class name, HTML attribute, IDs, etc).
|
|
136
|
+
*/
|
|
137
|
+
function isTechnicalString(str) {
|
|
138
|
+
// kebab-case or camelCase all lowercase suggests CSS class or technical ID
|
|
139
|
+
if (/^[a-z0-9-_]+$/.test(str) && str.length < 30) {
|
|
140
|
+
return true;
|
|
141
|
+
}
|
|
142
|
+
// HTML attribute patterns like type="text", role="button", etc.
|
|
143
|
+
if (/^(type|role|aria-|data-|href|src|id|class|name)$/i.test(str)) {
|
|
144
|
+
return true;
|
|
145
|
+
}
|
|
146
|
+
// Very short all-uppercase abbreviations (CSS, DOM, API, URL, etc.)
|
|
147
|
+
if (/^[A-Z]{2,3}$/.test(str)) {
|
|
148
|
+
return true;
|
|
149
|
+
}
|
|
150
|
+
return false;
|
|
151
|
+
}
|
|
152
|
+
/**
|
|
153
|
+
* Checks if a string looks like a developer-facing error code.
|
|
154
|
+
* Examples: ERROR_NOT_FOUND, ERR_INVALID_TOKEN, etc.
|
|
155
|
+
*/
|
|
156
|
+
function isErrorCode(str) {
|
|
157
|
+
// All uppercase with underscores, typically error codes
|
|
158
|
+
if (/^[A-Z][A-Z0-9_]*$/.test(str) && str.includes('_') && str.length > 3) {
|
|
159
|
+
return true;
|
|
160
|
+
}
|
|
161
|
+
return false;
|
|
162
|
+
}
|
|
163
|
+
/**
|
|
164
|
+
* Checks if a string is already wrapped in a t() call or Trans component.
|
|
165
|
+
*/
|
|
166
|
+
function isAlreadyInstrumented(code, offset, endOffset) {
|
|
167
|
+
// Look backwards for t( — use 20 chars to cover patterns like `i18next.t(`
|
|
168
|
+
const beforeStr = code.substring(Math.max(0, offset - 20), offset);
|
|
169
|
+
if (beforeStr.includes('t(') || beforeStr.includes('.t(')) {
|
|
170
|
+
return true;
|
|
171
|
+
}
|
|
172
|
+
// Look forward for ) or Trans opening
|
|
173
|
+
const afterStr = code.substring(endOffset, Math.min(code.length, endOffset + 20));
|
|
174
|
+
if (afterStr.startsWith(')') || afterStr.includes('Trans')) {
|
|
175
|
+
return true;
|
|
176
|
+
}
|
|
177
|
+
return false;
|
|
178
|
+
}
|
|
179
|
+
/**
|
|
180
|
+
* Checks if this string is inside a console.log/warn/error call.
|
|
181
|
+
*/
|
|
182
|
+
function isConsoleArgument(code, offset) {
|
|
183
|
+
const beforeStr = code.substring(Math.max(0, offset - 100), offset);
|
|
184
|
+
return /console\.(log|warn|error|info|debug|trace)\s*\(\s*["']?\s*$/.test(beforeStr);
|
|
185
|
+
}
|
|
186
|
+
// Translatable attribute and property sets are defined in
|
|
187
|
+
// utils/jsx-attributes.ts and shared with the linter.
|
|
188
|
+
const TRANSLATABLE_ATTRIBUTES = translatableAttributeSet;
|
|
189
|
+
const TRANSLATABLE_PROPERTIES = translatablePropertySet;
|
|
190
|
+
/**
|
|
191
|
+
* Checks if the string appears to be an HTML/JSX attribute value.
|
|
192
|
+
* Returns true only for *technical* attribute values that should be skipped.
|
|
193
|
+
* Translatable attributes (placeholder, title, alt, aria-label, etc.) are
|
|
194
|
+
* allowed through so they can be instrumented.
|
|
195
|
+
*/
|
|
196
|
+
function isAttributeValue(code, offset) {
|
|
197
|
+
// Note: offset points at the opening quote character, so beforeStr goes up to
|
|
198
|
+
// (but does not include) that quote. We check for `attr=` at the end.
|
|
199
|
+
const beforeStr = code.substring(Math.max(0, offset - 50), offset);
|
|
200
|
+
// Pattern: attributeName="..." or attributeName='...'
|
|
201
|
+
// Require `=` immediately before the quote (no trailing space) to
|
|
202
|
+
// distinguish JSX attributes (`placeholder="..."` — no space) from JS
|
|
203
|
+
// variable assignments (`const x = "..."` — space before quote).
|
|
204
|
+
const match = beforeStr.match(/([\w-]+)\s*=$/);
|
|
205
|
+
if (match) {
|
|
206
|
+
const attrName = match[1].toLowerCase();
|
|
207
|
+
// Allow translatable attributes to pass through
|
|
208
|
+
if (TRANSLATABLE_ATTRIBUTES.has(attrName)) {
|
|
209
|
+
return false;
|
|
210
|
+
}
|
|
211
|
+
return true;
|
|
212
|
+
}
|
|
213
|
+
return false;
|
|
214
|
+
}
|
|
215
|
+
/**
|
|
216
|
+
* Calculates a confidence score (0-1) for a candidate string.
|
|
217
|
+
* Higher scores indicate higher likelihood of being user-facing content.
|
|
218
|
+
*
|
|
219
|
+
* **Note:** This is a heuristic-based approach and won't be 100% accurate.
|
|
220
|
+
* High-confidence strings should still be reviewed by developers before final deployment.
|
|
221
|
+
* Use this as a first pass to identify candidates, not as an authoritative decision.
|
|
222
|
+
*/
|
|
223
|
+
function calculateConfidence(content, code, offset) {
|
|
224
|
+
let confidence = 0.5; // Base confidence
|
|
225
|
+
// Longer sentences are more likely to be translatable
|
|
226
|
+
const wordCount = content.split(/\s+/).length;
|
|
227
|
+
if (wordCount >= 3) {
|
|
228
|
+
confidence += 0.2;
|
|
229
|
+
}
|
|
230
|
+
else if (wordCount === 2) {
|
|
231
|
+
confidence += 0.1;
|
|
232
|
+
}
|
|
233
|
+
// Contains common sentence starters (user-facing)
|
|
234
|
+
if (/^(the|a|an|you|your|we|our|hello|welcome|please|thank|sorry)/i.test(content)) {
|
|
235
|
+
confidence += 0.15;
|
|
236
|
+
}
|
|
237
|
+
// Contains punctuation (sentences)
|
|
238
|
+
if (/[.!?]$/.test(content)) {
|
|
239
|
+
confidence += 0.1;
|
|
240
|
+
}
|
|
241
|
+
// Contains mixed case (likely English, not technical)
|
|
242
|
+
if (/[A-Z].*[a-z].*[A-Z]/.test(content) || /[a-z].*[A-Z]/.test(content)) {
|
|
243
|
+
confidence += 0.05;
|
|
244
|
+
}
|
|
245
|
+
// Contains numbers mixed with words (like "Step 1")
|
|
246
|
+
if (/\d.*[a-z]|[a-z].*\d/i.test(content)) {
|
|
247
|
+
confidence += 0.05;
|
|
248
|
+
}
|
|
249
|
+
// Contains action verb (likely a button or instruction)
|
|
250
|
+
if (/^(click|save|delete|create|update|submit|continue|back|next|yes|no|ok|close|open|download|upload|add|edit|remove|cancel|confirm|send|search|find|apply|reset|clear|done|finish|start|stop|retry|sign|log)/i.test(content)) {
|
|
251
|
+
confidence += 0.1;
|
|
252
|
+
}
|
|
253
|
+
// Reduce confidence if appears in specific technical contexts
|
|
254
|
+
const beforeContext = code.substring(Math.max(0, offset - 40), offset);
|
|
255
|
+
const afterContext = code.substring(offset, Math.min(code.length, offset + 20));
|
|
256
|
+
// Import paths, requires, etc.
|
|
257
|
+
if (/from\s+$|require\s*\(\s*$|import\s+$/.test(beforeContext)) {
|
|
258
|
+
confidence -= 0.3;
|
|
259
|
+
}
|
|
260
|
+
// HTML/JSX attribute values (offset is at opening quote, so beforeContext ends with `=`)
|
|
261
|
+
const attrCtxMatch = beforeContext.match(/([\w-]+)\s*=$/);
|
|
262
|
+
if (attrCtxMatch) {
|
|
263
|
+
const attrName = attrCtxMatch[1].toLowerCase();
|
|
264
|
+
if (TRANSLATABLE_ATTRIBUTES.has(attrName)) {
|
|
265
|
+
confidence += 0.15; // Translatable attribute — boost
|
|
266
|
+
}
|
|
267
|
+
else {
|
|
268
|
+
confidence -= 0.2;
|
|
269
|
+
}
|
|
270
|
+
}
|
|
271
|
+
// Object-property context: { label: 'All', description: 'Some text' }
|
|
272
|
+
// When the property name is a translatable-sounding key, boost confidence
|
|
273
|
+
const propMatch = beforeContext.match(/(\w+)\s*:\s*$/);
|
|
274
|
+
if (propMatch && !attrCtxMatch) {
|
|
275
|
+
const propName = propMatch[1].toLowerCase();
|
|
276
|
+
if (TRANSLATABLE_PROPERTIES.has(propName)) {
|
|
277
|
+
confidence += 0.25;
|
|
278
|
+
}
|
|
279
|
+
}
|
|
280
|
+
// Regular expressions or patterns
|
|
281
|
+
if (/\//.test(content) && /regex|pattern|match|search/i.test(beforeContext + afterContext)) {
|
|
282
|
+
confidence -= 0.15;
|
|
283
|
+
}
|
|
284
|
+
// Clamp to [0, 1]
|
|
285
|
+
return Math.max(0, Math.min(1, confidence));
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
export { detectCandidate };
|
|
@@ -0,0 +1,336 @@
|
|
|
1
|
+
import MagicString from 'magic-string';
|
|
2
|
+
import { generateKeyFromContent } from './key-generator.js';
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Transforms a source file, replacing candidate strings with instrumented code.
|
|
6
|
+
* Also injects useTranslation() hooks into React function components that
|
|
7
|
+
* contain transformed strings.
|
|
8
|
+
*
|
|
9
|
+
* @param content - Original source code
|
|
10
|
+
* @param file - File path
|
|
11
|
+
* @param candidates - Candidate strings to transform
|
|
12
|
+
* @param options - Transformation options
|
|
13
|
+
* @returns TransformResult with modified content and diff
|
|
14
|
+
*/
|
|
15
|
+
function transformFile(content, file, candidates, options) {
|
|
16
|
+
const s = new MagicString(content);
|
|
17
|
+
const errors = [];
|
|
18
|
+
const warnings = [];
|
|
19
|
+
let transformCount = 0;
|
|
20
|
+
const injections = {
|
|
21
|
+
importAdded: false,
|
|
22
|
+
hookInjected: false
|
|
23
|
+
};
|
|
24
|
+
// Filter high-confidence candidates
|
|
25
|
+
const highConfidenceCandidates = candidates.filter(c => c.confidence >= 0.7);
|
|
26
|
+
// Track which components have transformed candidates
|
|
27
|
+
const transformedComponents = new Set();
|
|
28
|
+
let hasComponentCandidates = false;
|
|
29
|
+
let hasNonComponentCandidates = false;
|
|
30
|
+
// ── Language-change site injections ────────────────────────────────────
|
|
31
|
+
const languageChangeSites = options.languageChangeSites || [];
|
|
32
|
+
// Track components that need `i18n` from useTranslation()
|
|
33
|
+
const componentsNeedingI18n = new Set();
|
|
34
|
+
let languageChangeCount = 0;
|
|
35
|
+
// Apply language-change injections in reverse order (to preserve offsets)
|
|
36
|
+
const sortedSites = [...languageChangeSites].sort((a, b) => b.callStart - a.callStart);
|
|
37
|
+
for (const site of sortedSites) {
|
|
38
|
+
try {
|
|
39
|
+
const changeCall = site.insideComponent
|
|
40
|
+
? `i18n.changeLanguage(${site.languageExpression})`
|
|
41
|
+
: `i18next.changeLanguage(${site.languageExpression})`;
|
|
42
|
+
// Check if the call is the expression body of an arrow function (no braces):
|
|
43
|
+
// () => updateSettings({ language: code })
|
|
44
|
+
// We need to wrap it:
|
|
45
|
+
// () => { i18n.changeLanguage(code); updateSettings({ language: code }); }
|
|
46
|
+
const beforeCall = content.slice(0, site.callStart);
|
|
47
|
+
const arrowMatch = beforeCall.match(/=>\s*$/);
|
|
48
|
+
if (arrowMatch) {
|
|
49
|
+
// Arrow function expression body → wrap in block
|
|
50
|
+
const originalCall = content.slice(site.callStart, site.callEnd);
|
|
51
|
+
s.overwrite(site.callStart, site.callEnd, `{ ${changeCall}; ${originalCall}; }`);
|
|
52
|
+
}
|
|
53
|
+
else {
|
|
54
|
+
// Already in a block → prepend as a statement
|
|
55
|
+
s.appendLeft(site.callStart, `${changeCall}; `);
|
|
56
|
+
}
|
|
57
|
+
languageChangeCount++;
|
|
58
|
+
if (site.insideComponent) {
|
|
59
|
+
componentsNeedingI18n.add(site.insideComponent);
|
|
60
|
+
transformedComponents.add(site.insideComponent);
|
|
61
|
+
hasComponentCandidates = true;
|
|
62
|
+
}
|
|
63
|
+
else {
|
|
64
|
+
hasNonComponentCandidates = true;
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
catch (err) {
|
|
68
|
+
errors.push(`Failed to inject changeLanguage at offset ${site.callStart}: ${err}`);
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
// Apply transformations in reverse order (to maintain correct offsets)
|
|
72
|
+
for (let i = highConfidenceCandidates.length - 1; i >= 0; i--) {
|
|
73
|
+
const candidate = highConfidenceCandidates[i];
|
|
74
|
+
try {
|
|
75
|
+
const key = candidate.key || generateKeyFromContent(candidate.content);
|
|
76
|
+
const useHookStyle = !!candidate.insideComponent;
|
|
77
|
+
const defaultNS = options.config.extract?.defaultNS ?? 'translation';
|
|
78
|
+
const nsForReplacement = (options.namespace && options.namespace !== defaultNS) ? options.namespace : undefined;
|
|
79
|
+
const replacement = buildReplacement(candidate, key, useHookStyle, nsForReplacement);
|
|
80
|
+
if (replacement) {
|
|
81
|
+
s.overwrite(candidate.offset, candidate.endOffset, replacement);
|
|
82
|
+
transformCount++;
|
|
83
|
+
if (candidate.insideComponent) {
|
|
84
|
+
transformedComponents.add(candidate.insideComponent);
|
|
85
|
+
hasComponentCandidates = true;
|
|
86
|
+
}
|
|
87
|
+
else {
|
|
88
|
+
hasNonComponentCandidates = true;
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
catch (err) {
|
|
93
|
+
errors.push(`Failed to transform candidate at offset ${candidate.offset}: ${err}`);
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
// Add necessary imports and hooks if any transformations were made
|
|
97
|
+
if (transformCount > 0 || languageChangeCount > 0) {
|
|
98
|
+
const components = options.components || [];
|
|
99
|
+
// Inject useTranslation() hooks into affected React components
|
|
100
|
+
if (options.hasReact && transformedComponents.size > 0) {
|
|
101
|
+
// Process components in reverse order of bodyStart to avoid offset shifts
|
|
102
|
+
const affectedComponents = components
|
|
103
|
+
.filter(c => transformedComponents.has(c.name) && !c.hasUseTranslation)
|
|
104
|
+
.sort((a, b) => b.bodyStart - a.bodyStart);
|
|
105
|
+
for (const comp of affectedComponents) {
|
|
106
|
+
const indent = detectIndent(content, comp.bodyStart);
|
|
107
|
+
const defaultNS = options.config.extract?.defaultNS ?? 'translation';
|
|
108
|
+
const nsArg = (options.namespace && options.namespace !== defaultNS) ? `'${options.namespace}'` : '';
|
|
109
|
+
// Build destructuring: include `t` if the component has string candidates,
|
|
110
|
+
// include `i18n` if the component has language-change sites.
|
|
111
|
+
const needsT = highConfidenceCandidates.some(c => c.insideComponent === comp.name);
|
|
112
|
+
const needsI18n = componentsNeedingI18n.has(comp.name);
|
|
113
|
+
const parts = [];
|
|
114
|
+
if (needsT)
|
|
115
|
+
parts.push('t');
|
|
116
|
+
if (needsI18n)
|
|
117
|
+
parts.push('i18n');
|
|
118
|
+
if (parts.length === 0)
|
|
119
|
+
parts.push('t'); // fallback
|
|
120
|
+
const destructured = `{ ${parts.join(', ')} }`;
|
|
121
|
+
s.appendRight(comp.bodyStart + 1, `\n${indent}const ${destructured} = useTranslation(${nsArg})`);
|
|
122
|
+
injections.hookInjected = true;
|
|
123
|
+
}
|
|
124
|
+
// For components that already have useTranslation but need i18n added,
|
|
125
|
+
// try to upgrade `const { t } = useTranslation(...)` → `const { t, i18n } = useTranslation(...)`
|
|
126
|
+
if (componentsNeedingI18n.size > 0) {
|
|
127
|
+
const compsWithExistingHook = components.filter(c => componentsNeedingI18n.has(c.name) && c.hasUseTranslation);
|
|
128
|
+
for (const comp of compsWithExistingHook) {
|
|
129
|
+
// Search for the destructuring pattern within the component body
|
|
130
|
+
const bodyText = content.slice(comp.bodyStart, comp.bodyEnd);
|
|
131
|
+
// Skip if i18n is already destructured
|
|
132
|
+
if (/const\s*\{[^}]*\bi18n\b[^}]*\}\s*=\s*useTranslation/.test(bodyText))
|
|
133
|
+
continue;
|
|
134
|
+
// Match `{ t }` or `{ t, ... }` in `const { t } = useTranslation`
|
|
135
|
+
const hookRe = /const\s*\{\s*t\s*\}\s*=\s*useTranslation/;
|
|
136
|
+
const hookMatch = hookRe.exec(bodyText);
|
|
137
|
+
if (hookMatch) {
|
|
138
|
+
const absStart = comp.bodyStart + hookMatch.index;
|
|
139
|
+
const absEnd = absStart + hookMatch[0].length;
|
|
140
|
+
const upgraded = hookMatch[0].replace(/\{\s*t\s*\}/, '{ t, i18n }');
|
|
141
|
+
s.overwrite(absStart, absEnd, upgraded);
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
// Add import statements
|
|
147
|
+
addImportStatements(s, content, {
|
|
148
|
+
needsUseTranslation: hasComponentCandidates && options.hasReact,
|
|
149
|
+
needsI18next: hasNonComponentCandidates || !options.hasReact
|
|
150
|
+
});
|
|
151
|
+
injections.importAdded = true;
|
|
152
|
+
}
|
|
153
|
+
// Warn when i18next.t() is used in React projects (translations may not be loaded yet)
|
|
154
|
+
if (options.hasReact && hasNonComponentCandidates && transformCount > 0) {
|
|
155
|
+
warnings.push(`${file}: i18next.t() was added outside of a React component. ` +
|
|
156
|
+
'If translation resources are loaded asynchronously, i18next.t() may return the key instead of the translation. ' +
|
|
157
|
+
'Consider moving this text into a component and using the useTranslation() hook, or ensure i18next is fully initialized before this code runs. ' +
|
|
158
|
+
'See: https://www.locize.com/blog/how-to-use-i18next-t-outside-react-components/');
|
|
159
|
+
}
|
|
160
|
+
const newContent = s.toString();
|
|
161
|
+
const diff = generateDiff(content, newContent, file);
|
|
162
|
+
const totalChanges = transformCount + languageChangeCount;
|
|
163
|
+
return {
|
|
164
|
+
modified: totalChanges > 0,
|
|
165
|
+
newContent: !options.isDryRun ? newContent : undefined,
|
|
166
|
+
diff,
|
|
167
|
+
errors,
|
|
168
|
+
warnings,
|
|
169
|
+
transformCount,
|
|
170
|
+
languageChangeCount,
|
|
171
|
+
injections
|
|
172
|
+
};
|
|
173
|
+
}
|
|
174
|
+
/**
|
|
175
|
+
* Builds the replacement code for a candidate string.
|
|
176
|
+
*
|
|
177
|
+
* @param candidate - The candidate to replace
|
|
178
|
+
* @param key - The i18n key to use
|
|
179
|
+
* @param useHookStyle - If true, uses t() (from useTranslation hook); otherwise uses i18next.t()
|
|
180
|
+
* @param namespace - Optional namespace (only set when different from defaultNS)
|
|
181
|
+
*/
|
|
182
|
+
function buildReplacement(candidate, key, useHookStyle, namespace) {
|
|
183
|
+
const tFunc = useHookStyle ? 't' : 'i18next.t';
|
|
184
|
+
const escapedContent = escapeString(candidate.content);
|
|
185
|
+
// ── Plural form: t('key', { defaultValue_zero: '…', …, count: expr }) ──
|
|
186
|
+
if (candidate.pluralForms) {
|
|
187
|
+
const pf = candidate.pluralForms;
|
|
188
|
+
const optionEntries = [];
|
|
189
|
+
if (pf.zero !== undefined) {
|
|
190
|
+
optionEntries.push(`defaultValue_zero: '${escapeString(pf.zero)}'`);
|
|
191
|
+
}
|
|
192
|
+
if (pf.one !== undefined) {
|
|
193
|
+
optionEntries.push(`defaultValue_one: '${escapeString(pf.one)}'`);
|
|
194
|
+
}
|
|
195
|
+
optionEntries.push(`defaultValue_other: '${escapeString(pf.other)}'`);
|
|
196
|
+
optionEntries.push(`count: ${pf.countExpression}`);
|
|
197
|
+
if (!useHookStyle && namespace) {
|
|
198
|
+
optionEntries.push(`ns: '${namespace}'`);
|
|
199
|
+
}
|
|
200
|
+
const tCall = `${tFunc}('${key}', { ${optionEntries.join(', ')} })`;
|
|
201
|
+
switch (candidate.type) {
|
|
202
|
+
case 'jsx-text':
|
|
203
|
+
case 'jsx-attribute':
|
|
204
|
+
return `{${tCall}}`;
|
|
205
|
+
default:
|
|
206
|
+
return tCall;
|
|
207
|
+
}
|
|
208
|
+
}
|
|
209
|
+
// Build the optional third argument: { interpolationVars..., ns? }
|
|
210
|
+
const optionEntries = [];
|
|
211
|
+
if (candidate.interpolations?.length) {
|
|
212
|
+
for (const interp of candidate.interpolations) {
|
|
213
|
+
optionEntries.push(interp.name === interp.expression ? interp.name : `${interp.name}: ${interp.expression}`);
|
|
214
|
+
}
|
|
215
|
+
}
|
|
216
|
+
if (!useHookStyle && namespace) {
|
|
217
|
+
optionEntries.push(`ns: '${namespace}'`);
|
|
218
|
+
}
|
|
219
|
+
const optionsArg = optionEntries.length > 0 ? `, { ${optionEntries.join(', ')} }` : '';
|
|
220
|
+
const tCall = `${tFunc}('${key}', '${escapedContent}'${optionsArg})`;
|
|
221
|
+
switch (candidate.type) {
|
|
222
|
+
case 'jsx-text':
|
|
223
|
+
case 'jsx-attribute':
|
|
224
|
+
return `{${tCall}}`;
|
|
225
|
+
case 'jsx-mixed':
|
|
226
|
+
if (useHookStyle) {
|
|
227
|
+
return `<Trans i18nKey="${key}">${candidate.content}</Trans>`;
|
|
228
|
+
}
|
|
229
|
+
return candidate.content;
|
|
230
|
+
case 'template-literal':
|
|
231
|
+
case 'string-literal':
|
|
232
|
+
default:
|
|
233
|
+
return tCall;
|
|
234
|
+
}
|
|
235
|
+
}
|
|
236
|
+
/**
|
|
237
|
+
* Escapes a string for use in generated code.
|
|
238
|
+
*/
|
|
239
|
+
function escapeString(str) {
|
|
240
|
+
return str
|
|
241
|
+
.replace(/\\/g, '\\\\') // Escape backslashes first
|
|
242
|
+
.replace(/'/g, "\\'") // Escape single quotes
|
|
243
|
+
.replace(/\n/g, '\\n') // Escape newlines
|
|
244
|
+
.replace(/\r/g, '\\r') // Escape carriage returns
|
|
245
|
+
.replace(/\t/g, '\\t'); // Escape tabs
|
|
246
|
+
}
|
|
247
|
+
/**
|
|
248
|
+
* Checks if an import statement for a module already exists.
|
|
249
|
+
*/
|
|
250
|
+
function hasImport(content, moduleName) {
|
|
251
|
+
const importRegex = new RegExp(`import\\s+.*from\\s+['"]${moduleName}['"]`, 'g');
|
|
252
|
+
const requireRegex = new RegExp(`require\\(['"]${moduleName}['"]\\)`, 'g');
|
|
253
|
+
return importRegex.test(content) || requireRegex.test(content);
|
|
254
|
+
}
|
|
255
|
+
/**
|
|
256
|
+
* Detects the indentation level used after an opening brace.
|
|
257
|
+
* Skips blank lines and reads the whitespace of the first line that has actual content.
|
|
258
|
+
*/
|
|
259
|
+
function detectIndent(content, braceOffset) {
|
|
260
|
+
let searchFrom = braceOffset;
|
|
261
|
+
while (searchFrom < content.length) {
|
|
262
|
+
const newlinePos = content.indexOf('\n', searchFrom);
|
|
263
|
+
if (newlinePos === -1)
|
|
264
|
+
return ' ';
|
|
265
|
+
let indent = '';
|
|
266
|
+
let i = newlinePos + 1;
|
|
267
|
+
while (i < content.length && (content[i] === ' ' || content[i] === '\t')) {
|
|
268
|
+
indent += content[i];
|
|
269
|
+
i++;
|
|
270
|
+
}
|
|
271
|
+
// If this line has actual content (not just empty / blank), use its indentation
|
|
272
|
+
if (i < content.length && content[i] !== '\n' && content[i] !== '\r') {
|
|
273
|
+
return indent || ' ';
|
|
274
|
+
}
|
|
275
|
+
// Empty line — continue looking at the next line
|
|
276
|
+
searchFrom = i;
|
|
277
|
+
}
|
|
278
|
+
return ' ';
|
|
279
|
+
}
|
|
280
|
+
/**
|
|
281
|
+
* Adds necessary import statements (useTranslation and/or i18next).
|
|
282
|
+
*/
|
|
283
|
+
function addImportStatements(s, content, needs) {
|
|
284
|
+
let importStatement = '';
|
|
285
|
+
if (needs.needsUseTranslation && !hasImport(content, 'react-i18next')) {
|
|
286
|
+
importStatement += "import { useTranslation } from 'react-i18next'\n";
|
|
287
|
+
}
|
|
288
|
+
if (needs.needsI18next && !hasImport(content, 'i18next')) {
|
|
289
|
+
importStatement += "import i18next from 'i18next'\n";
|
|
290
|
+
}
|
|
291
|
+
if (!importStatement)
|
|
292
|
+
return;
|
|
293
|
+
// Insert after the last existing import statement, or at the top of the file
|
|
294
|
+
let insertPos = 0;
|
|
295
|
+
// Skip shebang if present
|
|
296
|
+
if (content.startsWith('#!')) {
|
|
297
|
+
insertPos = content.indexOf('\n') + 1;
|
|
298
|
+
}
|
|
299
|
+
// Find the end of the last import statement
|
|
300
|
+
const importRegex = /^import\s.+$/gm;
|
|
301
|
+
let match;
|
|
302
|
+
while ((match = importRegex.exec(content)) !== null) {
|
|
303
|
+
const endOfImport = match.index + match[0].length;
|
|
304
|
+
if (endOfImport > insertPos) {
|
|
305
|
+
const nextNewline = content.indexOf('\n', endOfImport);
|
|
306
|
+
insertPos = nextNewline !== -1 ? nextNewline + 1 : endOfImport;
|
|
307
|
+
}
|
|
308
|
+
}
|
|
309
|
+
s.appendRight(insertPos, importStatement);
|
|
310
|
+
}
|
|
311
|
+
/**
|
|
312
|
+
* Generates a unified diff showing what changed.
|
|
313
|
+
*/
|
|
314
|
+
function generateDiff(original, modified, filePath) {
|
|
315
|
+
if (original === modified) {
|
|
316
|
+
return '';
|
|
317
|
+
}
|
|
318
|
+
const originalLines = original.split('\n');
|
|
319
|
+
const modifiedLines = modified.split('\n');
|
|
320
|
+
let diff = `--- a/${filePath}\n`;
|
|
321
|
+
diff += `+++ b/${filePath}\n`;
|
|
322
|
+
// Simple line-by-line diff
|
|
323
|
+
for (let i = 0; i < Math.max(originalLines.length, modifiedLines.length); i++) {
|
|
324
|
+
if (originalLines[i] !== modifiedLines[i]) {
|
|
325
|
+
if (originalLines[i] !== undefined) {
|
|
326
|
+
diff += `-${originalLines[i]}\n`;
|
|
327
|
+
}
|
|
328
|
+
if (modifiedLines[i] !== undefined) {
|
|
329
|
+
diff += `+${modifiedLines[i]}\n`;
|
|
330
|
+
}
|
|
331
|
+
}
|
|
332
|
+
}
|
|
333
|
+
return diff;
|
|
334
|
+
}
|
|
335
|
+
|
|
336
|
+
export { generateDiff, transformFile };
|