i18next-cli 1.46.4 → 1.47.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (51) hide show
  1. package/README.md +198 -3
  2. package/dist/cjs/cli.js +50 -1
  3. package/dist/cjs/extractor/core/extractor.js +27 -18
  4. package/dist/cjs/extractor/core/translation-manager.js +10 -3
  5. package/dist/cjs/extractor/parsers/call-expression-handler.js +9 -1
  6. package/dist/cjs/extractor/parsers/jsx-handler.js +10 -1
  7. package/dist/cjs/index.js +5 -0
  8. package/dist/cjs/init.js +68 -12
  9. package/dist/cjs/instrumenter/core/instrumenter.js +1633 -0
  10. package/dist/cjs/instrumenter/core/key-generator.js +71 -0
  11. package/dist/cjs/instrumenter/core/string-detector.js +290 -0
  12. package/dist/cjs/instrumenter/core/transformer.js +339 -0
  13. package/dist/cjs/linter.js +6 -7
  14. package/dist/cjs/utils/jsx-attributes.js +131 -0
  15. package/dist/esm/cli.js +50 -1
  16. package/dist/esm/extractor/core/extractor.js +27 -18
  17. package/dist/esm/extractor/core/translation-manager.js +10 -3
  18. package/dist/esm/extractor/parsers/call-expression-handler.js +9 -1
  19. package/dist/esm/extractor/parsers/jsx-handler.js +10 -1
  20. package/dist/esm/index.js +3 -0
  21. package/dist/esm/init.js +68 -12
  22. package/dist/esm/instrumenter/core/instrumenter.js +1630 -0
  23. package/dist/esm/instrumenter/core/key-generator.js +68 -0
  24. package/dist/esm/instrumenter/core/string-detector.js +288 -0
  25. package/dist/esm/instrumenter/core/transformer.js +336 -0
  26. package/dist/esm/linter.js +6 -7
  27. package/dist/esm/utils/jsx-attributes.js +121 -0
  28. package/package.json +2 -1
  29. package/types/cli.d.ts.map +1 -1
  30. package/types/extractor/core/extractor.d.ts.map +1 -1
  31. package/types/extractor/core/translation-manager.d.ts.map +1 -1
  32. package/types/extractor/parsers/call-expression-handler.d.ts.map +1 -1
  33. package/types/extractor/parsers/jsx-handler.d.ts.map +1 -1
  34. package/types/index.d.ts +2 -1
  35. package/types/index.d.ts.map +1 -1
  36. package/types/init.d.ts.map +1 -1
  37. package/types/instrumenter/core/instrumenter.d.ts +16 -0
  38. package/types/instrumenter/core/instrumenter.d.ts.map +1 -0
  39. package/types/instrumenter/core/key-generator.d.ts +30 -0
  40. package/types/instrumenter/core/key-generator.d.ts.map +1 -0
  41. package/types/instrumenter/core/string-detector.d.ts +27 -0
  42. package/types/instrumenter/core/string-detector.d.ts.map +1 -0
  43. package/types/instrumenter/core/transformer.d.ts +31 -0
  44. package/types/instrumenter/core/transformer.d.ts.map +1 -0
  45. package/types/instrumenter/index.d.ts +6 -0
  46. package/types/instrumenter/index.d.ts.map +1 -0
  47. package/types/linter.d.ts.map +1 -1
  48. package/types/types.d.ts +285 -1
  49. package/types/types.d.ts.map +1 -1
  50. package/types/utils/jsx-attributes.d.ts +68 -0
  51. package/types/utils/jsx-attributes.d.ts.map +1 -0
@@ -0,0 +1,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 };