ai-localize-codemods 2.0.0 → 2.0.3
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/CHANGELOG.md +27 -0
- package/dist/index.d.mts +78 -15
- package/dist/index.d.ts +78 -15
- package/dist/index.js +162 -75
- package/dist/index.mjs +160 -74
- package/package.json +2 -2
- package/src/angular-codemod.ts +39 -22
- package/src/codemod-runner.ts +43 -28
- package/src/react-codemod.ts +250 -104
- package/src/vue-codemod.ts +35 -26
package/src/codemod-runner.ts
CHANGED
|
@@ -22,14 +22,26 @@ export interface CodemodRunSummary {
|
|
|
22
22
|
|
|
23
23
|
/**
|
|
24
24
|
* Orchestrates codemods across all detected files.
|
|
25
|
-
*
|
|
26
|
-
*
|
|
25
|
+
*
|
|
26
|
+
* Groups detected texts by file and applies the appropriate codemod based on
|
|
27
|
+
* the project framework. Codemod behaviour (import package, hook name,
|
|
28
|
+
* translation function, namespace, accessor style) is driven by `config.codemods`
|
|
29
|
+
* so the user can override defaults in their ai-localize.config.json.
|
|
30
|
+
*
|
|
31
|
+
* ### Local hook import path resolution
|
|
32
|
+
* When `config.codemods.importPackage` is a project-relative path (e.g.
|
|
33
|
+
* `"src/Locales/translate"`), `cwd` is forwarded to `applyReactCodemod` so it
|
|
34
|
+
* can compute the correct relative import path for each individual file:
|
|
35
|
+
* src/components/Button.tsx → `"../../Locales/translate"`
|
|
36
|
+
* src/pages/dashboard/Header.tsx → `"../../../Locales/translate"`
|
|
27
37
|
*/
|
|
28
38
|
export class CodemodRunner {
|
|
29
39
|
private config: LocalizationConfig;
|
|
40
|
+
private cwd: string;
|
|
30
41
|
|
|
31
|
-
constructor(config: LocalizationConfig) {
|
|
42
|
+
constructor(config: LocalizationConfig, cwd = process.cwd()) {
|
|
32
43
|
this.config = config;
|
|
44
|
+
this.cwd = cwd;
|
|
33
45
|
}
|
|
34
46
|
|
|
35
47
|
async run(
|
|
@@ -37,43 +49,43 @@ export class CodemodRunner {
|
|
|
37
49
|
options: CodemodRunOptions = {}
|
|
38
50
|
): Promise<CodemodRunSummary> {
|
|
39
51
|
const startTime = Date.now();
|
|
40
|
-
|
|
52
|
+
const { dryRun = false, onProgress } = options;
|
|
41
53
|
|
|
42
54
|
// Group texts by file
|
|
43
55
|
const fileTextMap = new Map<string, DetectedText[]>();
|
|
44
56
|
for (const dt of detectedTexts) {
|
|
45
57
|
const existing = fileTextMap.get(dt.filePath) || [];
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
58
|
+
existing.push(dt);
|
|
59
|
+
fileTextMap.set(dt.filePath, existing);
|
|
60
|
+
}
|
|
49
61
|
|
|
50
62
|
const results: CodemodResult[] = [];
|
|
51
63
|
let totalReplacements = 0;
|
|
52
64
|
let importInjections = 0;
|
|
53
65
|
let hookInjections = 0;
|
|
54
66
|
|
|
55
|
-
|
|
67
|
+
for (const [filePath, texts] of fileTextMap) {
|
|
56
68
|
let result: CodemodResult;
|
|
57
|
-
|
|
69
|
+
const framework = this.config.framework;
|
|
58
70
|
|
|
59
71
|
try {
|
|
60
72
|
result = this.applyCodemod(filePath, texts, framework, dryRun);
|
|
61
73
|
} catch (err) {
|
|
62
74
|
result = {
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
75
|
+
filePath,
|
|
76
|
+
originalContent: '',
|
|
77
|
+
transformedContent: '',
|
|
66
78
|
changed: false,
|
|
67
|
-
|
|
79
|
+
injectedImport: false,
|
|
68
80
|
injectedHook: false,
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
81
|
+
replacedTexts: 0,
|
|
82
|
+
};
|
|
83
|
+
}
|
|
72
84
|
|
|
73
85
|
results.push(result);
|
|
74
|
-
totalReplacements += result.replacedTexts;
|
|
75
|
-
|
|
76
|
-
|
|
86
|
+
totalReplacements += result.replacedTexts;
|
|
87
|
+
if (result.injectedImport) importInjections++;
|
|
88
|
+
if (result.injectedHook) hookInjections++;
|
|
77
89
|
|
|
78
90
|
onProgress?.(filePath, result);
|
|
79
91
|
}
|
|
@@ -82,10 +94,10 @@ totalReplacements += result.replacedTexts;
|
|
|
82
94
|
totalFiles: fileTextMap.size,
|
|
83
95
|
changedFiles: results.filter((r) => r.changed).length,
|
|
84
96
|
totalReplacements,
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
97
|
+
importInjections,
|
|
98
|
+
hookInjections,
|
|
99
|
+
results,
|
|
100
|
+
duration: Date.now() - startTime,
|
|
89
101
|
};
|
|
90
102
|
}
|
|
91
103
|
|
|
@@ -96,22 +108,25 @@ totalReplacements += result.replacedTexts;
|
|
|
96
108
|
dryRun: boolean
|
|
97
109
|
): CodemodResult {
|
|
98
110
|
const ext = path.extname(filePath).toLowerCase();
|
|
111
|
+
const codemodConfig = this.config.codemods;
|
|
99
112
|
|
|
100
113
|
// Vue SFCs
|
|
101
114
|
if (ext === '.vue') {
|
|
102
|
-
return applyVueCodemod(filePath, texts, dryRun);
|
|
115
|
+
return applyVueCodemod(filePath, texts, dryRun, codemodConfig);
|
|
103
116
|
}
|
|
104
117
|
|
|
105
118
|
// Angular templates or TS
|
|
106
|
-
|
|
119
|
+
if (
|
|
107
120
|
framework === 'angular' ||
|
|
108
121
|
framework === 'angular-ngx' ||
|
|
109
122
|
framework === 'angular-i18n'
|
|
110
|
-
|
|
111
|
-
|
|
123
|
+
) {
|
|
124
|
+
return applyAngularCodemod(filePath, texts, dryRun, codemodConfig);
|
|
112
125
|
}
|
|
113
126
|
|
|
114
127
|
// React (default for .tsx, .ts, .jsx, .js)
|
|
115
|
-
|
|
128
|
+
// Pass cwd so the codemod can resolve per-file relative import paths
|
|
129
|
+
// for local hook files (e.g. "src/Locales/translate").
|
|
130
|
+
return applyReactCodemod(filePath, texts, dryRun, codemodConfig, this.cwd);
|
|
116
131
|
}
|
|
117
132
|
}
|
package/src/react-codemod.ts
CHANGED
|
@@ -1,10 +1,11 @@
|
|
|
1
1
|
import * as fs from 'fs';
|
|
2
|
+
import * as nodePath from 'path';
|
|
2
3
|
import * as parser from '@babel/parser';
|
|
3
4
|
import traverse from '@babel/traverse';
|
|
4
5
|
import generate from '@babel/generator';
|
|
5
6
|
import * as t from '@babel/types';
|
|
6
7
|
|
|
7
|
-
import type { DetectedText } from 'ai-localize-shared';
|
|
8
|
+
import type { DetectedText, CodemodConfig } from 'ai-localize-shared';
|
|
8
9
|
|
|
9
10
|
export interface CodemodResult {
|
|
10
11
|
filePath: string;
|
|
@@ -16,60 +17,96 @@ export interface CodemodResult {
|
|
|
16
17
|
replacedTexts: number;
|
|
17
18
|
}
|
|
18
19
|
|
|
19
|
-
const
|
|
20
|
-
const
|
|
20
|
+
const DEFAULT_IMPORT_PACKAGE = 'react-i18next';
|
|
21
|
+
const DEFAULT_HOOK_NAME = 'useTranslation';
|
|
22
|
+
const DEFAULT_TRANSLATION_FN = 't';
|
|
23
|
+
const DEFAULT_ACCESSOR_STYLE = 'function';
|
|
21
24
|
|
|
22
25
|
/**
|
|
23
|
-
* React i18n codemod: wraps hardcoded JSX text / string literals with t() calls.
|
|
26
|
+
* React i18n codemod: wraps hardcoded JSX text / string literals with t() or t[''] calls.
|
|
24
27
|
* Preserves formatting, comments, and import order.
|
|
28
|
+
*
|
|
29
|
+
* ### Local hook import paths
|
|
30
|
+
*
|
|
31
|
+
* When `codemodConfig.importPackage` is a project-relative path (e.g. `"src/Locales/translate"`),
|
|
32
|
+
* the codemod computes the correct **relative import path for each file** it transforms:
|
|
33
|
+
*
|
|
34
|
+
* src/components/Button.tsx → `"../../Locales/translate"`
|
|
35
|
+
* src/pages/dashboard/Header.tsx → `"../../../Locales/translate"`
|
|
36
|
+
*
|
|
37
|
+
* npm package names (no `/` prefix, not starting with `.`) are injected verbatim.
|
|
38
|
+
*
|
|
39
|
+
* ### Accessor styles
|
|
40
|
+
* accessorStyle "function" (default) → t('key')
|
|
41
|
+
* accessorStyle "bracket" → t['key']
|
|
25
42
|
*/
|
|
26
43
|
export class ReactCodemod {
|
|
27
44
|
private filePath: string;
|
|
28
45
|
private content: string;
|
|
29
46
|
private texts: DetectedText[];
|
|
47
|
+
/** Resolved import specifier string (may differ per file for local paths) */
|
|
48
|
+
private importSpecifier: string;
|
|
49
|
+
private hookName: string;
|
|
50
|
+
private translationFn: string;
|
|
51
|
+
private namespace?: string;
|
|
52
|
+
private accessorStyle: 'function' | 'bracket';
|
|
30
53
|
|
|
31
|
-
constructor(
|
|
54
|
+
constructor(
|
|
55
|
+
filePath: string,
|
|
56
|
+
content: string,
|
|
57
|
+
texts: DetectedText[],
|
|
58
|
+
codemodConfig?: CodemodConfig,
|
|
59
|
+
cwd = process.cwd()
|
|
60
|
+
) {
|
|
32
61
|
this.filePath = filePath;
|
|
33
62
|
this.content = content;
|
|
34
|
-
|
|
35
|
-
|
|
63
|
+
this.texts = texts;
|
|
64
|
+
this.hookName = codemodConfig?.hookName ?? DEFAULT_HOOK_NAME;
|
|
65
|
+
this.translationFn = codemodConfig?.translationFunction ?? DEFAULT_TRANSLATION_FN;
|
|
66
|
+
this.namespace = codemodConfig?.namespace;
|
|
67
|
+
this.accessorStyle = codemodConfig?.accessorStyle ?? DEFAULT_ACCESSOR_STYLE;
|
|
68
|
+
|
|
69
|
+
// Resolve import specifier: npm package → verbatim, local path → per-file relative
|
|
70
|
+
const pkg = codemodConfig?.importPackage ?? DEFAULT_IMPORT_PACKAGE;
|
|
71
|
+
this.importSpecifier = resolveImportSpecifier(pkg, filePath, cwd);
|
|
72
|
+
}
|
|
36
73
|
|
|
37
74
|
transform(): CodemodResult {
|
|
38
75
|
if (this.texts.length === 0) {
|
|
39
76
|
return {
|
|
40
|
-
|
|
77
|
+
filePath: this.filePath,
|
|
41
78
|
originalContent: this.content,
|
|
42
79
|
transformedContent: this.content,
|
|
43
|
-
|
|
44
|
-
|
|
80
|
+
changed: false,
|
|
81
|
+
injectedImport: false,
|
|
45
82
|
injectedHook: false,
|
|
46
|
-
|
|
83
|
+
replacedTexts: 0,
|
|
47
84
|
};
|
|
48
|
-
|
|
85
|
+
}
|
|
49
86
|
|
|
50
87
|
let ast: t.File;
|
|
51
88
|
try {
|
|
52
|
-
|
|
89
|
+
ast = parser.parse(this.content, {
|
|
53
90
|
sourceType: 'module',
|
|
54
91
|
plugins: [
|
|
55
|
-
|
|
56
|
-
|
|
92
|
+
'jsx',
|
|
93
|
+
'typescript',
|
|
57
94
|
'decorators-legacy',
|
|
58
|
-
|
|
59
|
-
|
|
95
|
+
'classProperties',
|
|
96
|
+
'optionalChaining',
|
|
60
97
|
'nullishCoalescingOperator',
|
|
61
|
-
|
|
98
|
+
],
|
|
62
99
|
errorRecovery: true,
|
|
63
100
|
});
|
|
64
101
|
} catch {
|
|
65
|
-
|
|
66
|
-
|
|
102
|
+
return {
|
|
103
|
+
filePath: this.filePath,
|
|
67
104
|
originalContent: this.content,
|
|
68
|
-
|
|
105
|
+
transformedContent: this.content,
|
|
69
106
|
changed: false,
|
|
70
107
|
injectedImport: false,
|
|
71
|
-
|
|
72
|
-
|
|
108
|
+
injectedHook: false,
|
|
109
|
+
replacedTexts: 0,
|
|
73
110
|
};
|
|
74
111
|
}
|
|
75
112
|
|
|
@@ -80,144 +117,153 @@ originalContent: this.content,
|
|
|
80
117
|
}
|
|
81
118
|
|
|
82
119
|
let replacedTexts = 0;
|
|
83
|
-
let
|
|
84
|
-
let
|
|
120
|
+
let hasImport = false;
|
|
121
|
+
let hasHook = false;
|
|
85
122
|
let injectedImport = false;
|
|
86
123
|
let injectedHook = false;
|
|
87
124
|
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
125
|
+
const translationFn = this.translationFn;
|
|
126
|
+
const hookName = this.hookName;
|
|
127
|
+
const importSpecifier = this.importSpecifier;
|
|
128
|
+
const accessorStyle = this.accessorStyle;
|
|
129
|
+
|
|
130
|
+
/**
|
|
131
|
+
* Builds the translation expression for a key.
|
|
132
|
+
* "function" style: t('some.key')
|
|
133
|
+
* "bracket" style: t['some.key']
|
|
134
|
+
*/
|
|
135
|
+
const buildTranslationExpr = (key: string): t.Expression => {
|
|
136
|
+
if (accessorStyle === 'bracket') {
|
|
137
|
+
return t.memberExpression(
|
|
138
|
+
t.identifier(translationFn),
|
|
139
|
+
t.stringLiteral(key),
|
|
140
|
+
true /* computed */
|
|
141
|
+
);
|
|
93
142
|
}
|
|
143
|
+
return t.callExpression(t.identifier(translationFn), [t.stringLiteral(key)]);
|
|
144
|
+
};
|
|
145
|
+
|
|
146
|
+
// First pass: detect existing imports and hook usage
|
|
147
|
+
traverse(ast, {
|
|
148
|
+
ImportDeclaration(nodePath) {
|
|
149
|
+
if (nodePath.node.source.value === importSpecifier) {
|
|
150
|
+
hasImport = true;
|
|
151
|
+
}
|
|
94
152
|
},
|
|
95
|
-
// Check if useTranslation hook already destructured
|
|
96
153
|
VariableDeclarator(nodePath) {
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
154
|
+
const id = nodePath.node.id;
|
|
155
|
+
if (
|
|
156
|
+
t.isObjectPattern(id) &&
|
|
157
|
+
id.properties.some(
|
|
158
|
+
(p) =>
|
|
159
|
+
t.isObjectProperty(p) &&
|
|
160
|
+
t.isIdentifier(p.key) &&
|
|
161
|
+
p.key.name === translationFn
|
|
162
|
+
)
|
|
106
163
|
) {
|
|
107
|
-
|
|
108
|
-
|
|
164
|
+
hasHook = true;
|
|
165
|
+
}
|
|
109
166
|
},
|
|
110
167
|
});
|
|
111
168
|
|
|
112
169
|
// Second pass: replace hardcoded texts
|
|
113
|
-
|
|
170
|
+
traverse(ast, {
|
|
114
171
|
JSXText(nodePath) {
|
|
115
172
|
const text = nodePath.node.value.trim();
|
|
116
173
|
if (!text) return;
|
|
117
174
|
const key = textKeyMap.get(text);
|
|
118
175
|
if (!key) return;
|
|
119
|
-
|
|
120
|
-
// Replace with {t('key')}
|
|
121
|
-
const tCall = t.jsxExpressionContainer(
|
|
122
|
-
t.callExpression(t.identifier('t'), [t.stringLiteral(key)])
|
|
123
|
-
);
|
|
124
|
-
nodePath.replaceWith(tCall);
|
|
176
|
+
nodePath.replaceWith(t.jsxExpressionContainer(buildTranslationExpr(key)));
|
|
125
177
|
replacedTexts++;
|
|
126
178
|
},
|
|
127
179
|
|
|
128
|
-
|
|
180
|
+
StringLiteral(nodePath) {
|
|
129
181
|
const text = nodePath.node.value;
|
|
130
|
-
|
|
182
|
+
const key = textKeyMap.get(text);
|
|
131
183
|
if (!key) return;
|
|
132
184
|
if (t.isImportDeclaration(nodePath.parent)) return;
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
const tCall = t.callExpression(t.identifier('t'), [t.stringLiteral(key)]);
|
|
137
|
-
nodePath.replaceWith(tCall);
|
|
185
|
+
if (t.isObjectProperty(nodePath.parent) && nodePath.parent.key === nodePath.node) return;
|
|
186
|
+
if (t.isJSXAttribute(nodePath.parent)) return;
|
|
187
|
+
nodePath.replaceWith(buildTranslationExpr(key));
|
|
138
188
|
replacedTexts++;
|
|
139
189
|
},
|
|
140
190
|
|
|
141
191
|
JSXAttribute(nodePath) {
|
|
142
192
|
const valueNode = nodePath.node.value;
|
|
143
|
-
|
|
144
|
-
|
|
193
|
+
if (!t.isStringLiteral(valueNode)) return;
|
|
194
|
+
const text = valueNode.value;
|
|
145
195
|
const key = textKeyMap.get(text);
|
|
146
196
|
if (!key) return;
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
nodePath.node.value = t.jsxExpressionContainer(
|
|
150
|
-
t.callExpression(t.identifier('t'), [t.stringLiteral(key)])
|
|
151
|
-
);
|
|
152
|
-
replacedTexts++;
|
|
197
|
+
nodePath.node.value = t.jsxExpressionContainer(buildTranslationExpr(key));
|
|
198
|
+
replacedTexts++;
|
|
153
199
|
},
|
|
154
200
|
});
|
|
155
201
|
|
|
156
|
-
|
|
202
|
+
if (replacedTexts === 0) {
|
|
157
203
|
return {
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
204
|
+
filePath: this.filePath,
|
|
205
|
+
originalContent: this.content,
|
|
206
|
+
transformedContent: this.content,
|
|
207
|
+
changed: false,
|
|
208
|
+
injectedImport: false,
|
|
163
209
|
injectedHook: false,
|
|
164
|
-
|
|
210
|
+
replacedTexts: 0,
|
|
165
211
|
};
|
|
166
212
|
}
|
|
167
213
|
|
|
168
|
-
|
|
169
|
-
if (!
|
|
214
|
+
// Inject import if needed (uses the per-file resolved import specifier)
|
|
215
|
+
if (!hasImport) {
|
|
170
216
|
const importDecl = t.importDeclaration(
|
|
171
|
-
|
|
172
|
-
|
|
217
|
+
[t.importSpecifier(t.identifier(hookName), t.identifier(hookName))],
|
|
218
|
+
t.stringLiteral(importSpecifier)
|
|
173
219
|
);
|
|
174
|
-
|
|
175
|
-
injectedImport = true;
|
|
220
|
+
ast.program.body.unshift(importDecl);
|
|
221
|
+
injectedImport = true;
|
|
176
222
|
}
|
|
177
223
|
|
|
178
224
|
// Inject hook at the top of function components
|
|
179
|
-
if (!
|
|
180
|
-
|
|
181
|
-
|
|
225
|
+
if (!hasHook) {
|
|
226
|
+
this.injectHook(ast);
|
|
227
|
+
injectedHook = true;
|
|
182
228
|
}
|
|
183
229
|
|
|
184
230
|
// Generate output preserving formatting
|
|
185
|
-
|
|
231
|
+
const output = generate(
|
|
186
232
|
ast,
|
|
187
|
-
{
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
compact: false,
|
|
191
|
-
},
|
|
192
|
-
this.content
|
|
193
|
-
);
|
|
233
|
+
{ retainLines: false, comments: true, compact: false },
|
|
234
|
+
this.content
|
|
235
|
+
);
|
|
194
236
|
|
|
195
237
|
return {
|
|
196
238
|
filePath: this.filePath,
|
|
197
239
|
originalContent: this.content,
|
|
198
240
|
transformedContent: output.code,
|
|
199
241
|
changed: true,
|
|
200
|
-
injectedImport,
|
|
242
|
+
injectedImport,
|
|
201
243
|
injectedHook,
|
|
202
244
|
replacedTexts,
|
|
203
245
|
};
|
|
204
246
|
}
|
|
205
247
|
|
|
206
|
-
private
|
|
248
|
+
private injectHook(ast: t.File): void {
|
|
249
|
+
const hookName = this.hookName;
|
|
250
|
+
const translationFn = this.translationFn;
|
|
251
|
+
const namespace = this.namespace;
|
|
252
|
+
|
|
207
253
|
traverse(ast, {
|
|
208
254
|
FunctionDeclaration(nodePath) {
|
|
209
255
|
if (!isReactComponent(nodePath.node.id?.name)) return;
|
|
210
|
-
|
|
256
|
+
injectHookIntoBody(nodePath.node.body, hookName, translationFn, namespace);
|
|
211
257
|
nodePath.stop();
|
|
212
258
|
},
|
|
213
|
-
|
|
259
|
+
ArrowFunctionExpression(nodePath) {
|
|
214
260
|
if (!isReactComponentParent(nodePath)) return;
|
|
215
|
-
|
|
261
|
+
const body = nodePath.node.body;
|
|
216
262
|
if (t.isBlockStatement(body)) {
|
|
217
|
-
|
|
218
|
-
|
|
263
|
+
injectHookIntoBody(body, hookName, translationFn, namespace);
|
|
264
|
+
nodePath.stop();
|
|
219
265
|
}
|
|
220
|
-
|
|
266
|
+
},
|
|
221
267
|
});
|
|
222
268
|
}
|
|
223
269
|
}
|
|
@@ -232,38 +278,138 @@ function isReactComponentParent(nodePath: any): boolean {
|
|
|
232
278
|
const parent = nodePath.parent;
|
|
233
279
|
if (t.isVariableDeclarator(parent)) {
|
|
234
280
|
const id = parent.id;
|
|
235
|
-
|
|
281
|
+
if (t.isIdentifier(id) && /^[A-Z]/.test(id.name)) return true;
|
|
236
282
|
}
|
|
237
283
|
return false;
|
|
238
284
|
}
|
|
239
285
|
|
|
240
|
-
function injectHookIntoBody(
|
|
241
|
-
|
|
286
|
+
function injectHookIntoBody(
|
|
287
|
+
body: t.BlockStatement,
|
|
288
|
+
hookName: string,
|
|
289
|
+
translationFn: string,
|
|
290
|
+
namespace?: string
|
|
291
|
+
): void {
|
|
292
|
+
const hookArgs: t.Expression[] = namespace ? [t.stringLiteral(namespace)] : [];
|
|
242
293
|
const hookDecl = t.variableDeclaration('const', [
|
|
243
294
|
t.variableDeclarator(
|
|
244
295
|
t.objectPattern([
|
|
245
|
-
|
|
296
|
+
t.objectProperty(t.identifier(translationFn), t.identifier(translationFn), false, true),
|
|
246
297
|
]),
|
|
247
|
-
t.callExpression(t.identifier(
|
|
298
|
+
t.callExpression(t.identifier(hookName), hookArgs)
|
|
248
299
|
),
|
|
249
300
|
]);
|
|
250
301
|
body.body.unshift(hookDecl);
|
|
251
302
|
}
|
|
252
303
|
|
|
304
|
+
/**
|
|
305
|
+
* Determines the import specifier string to inject for a given `importPackage`
|
|
306
|
+
* config value and the file being transformed.
|
|
307
|
+
*
|
|
308
|
+
* Rules:
|
|
309
|
+
* 1. If `importPackage` looks like an npm package name (no leading `.` or `/`
|
|
310
|
+
* and no path separators in a way that suggests a local path starting with
|
|
311
|
+
* a known directory like `src/`) → return verbatim.
|
|
312
|
+
*
|
|
313
|
+
* 2. If `importPackage` is an absolute path → compute `path.relative(fileDir, importPackage)`
|
|
314
|
+
* and ensure it starts with `./` or `../`.
|
|
315
|
+
*
|
|
316
|
+
* 3. If `importPackage` is a project-relative path (starts with `src/`, `app/`,
|
|
317
|
+
* `lib/`, `hooks/`, `utils/`, or any path without a leading `.`) that is NOT
|
|
318
|
+
* a bare npm package name → resolve it relative to `cwd` first, then compute
|
|
319
|
+
* `path.relative(fileDir, resolvedPath)`.
|
|
320
|
+
*
|
|
321
|
+
* 4. If `importPackage` starts with `./` or `../` → treat as a path relative to
|
|
322
|
+
* `cwd` (config file location), resolve it, then compute relative to each file.
|
|
323
|
+
*/
|
|
324
|
+
export function resolveImportSpecifier(
|
|
325
|
+
importPackage: string,
|
|
326
|
+
filePath: string,
|
|
327
|
+
cwd: string
|
|
328
|
+
): string {
|
|
329
|
+
// Bare npm package name — no slashes that indicate a local path, or scoped @org/pkg
|
|
330
|
+
if (isNpmPackage(importPackage)) {
|
|
331
|
+
return importPackage;
|
|
332
|
+
}
|
|
333
|
+
|
|
334
|
+
// Resolve the hook file to an absolute path
|
|
335
|
+
let absoluteHookPath: string;
|
|
336
|
+
if (nodePath.isAbsolute(importPackage)) {
|
|
337
|
+
absoluteHookPath = importPackage;
|
|
338
|
+
} else {
|
|
339
|
+
// Project-relative (e.g. "src/Locales/translate") or relative (e.g. "../../hooks/t")
|
|
340
|
+
absoluteHookPath = nodePath.resolve(cwd, importPackage);
|
|
341
|
+
}
|
|
342
|
+
|
|
343
|
+
// Compute relative path from the directory of the file being transformed
|
|
344
|
+
const fileDir = nodePath.dirname(filePath);
|
|
345
|
+
let rel = nodePath.relative(fileDir, absoluteHookPath).replace(/\\/g, '/');
|
|
346
|
+
|
|
347
|
+
// Ensure it starts with ./ or ../
|
|
348
|
+
if (!rel.startsWith('.')) {
|
|
349
|
+
rel = `./${rel}`;
|
|
350
|
+
}
|
|
351
|
+
|
|
352
|
+
return rel;
|
|
353
|
+
}
|
|
354
|
+
|
|
355
|
+
/**
|
|
356
|
+
* Returns true if the string looks like a bare npm package name rather than a
|
|
357
|
+
* local file path.
|
|
358
|
+
*
|
|
359
|
+
* npm package names:
|
|
360
|
+
* - Do not start with `.` or `/`
|
|
361
|
+
* - Scoped packages start with `@` followed by `org/pkg`
|
|
362
|
+
* - Do NOT look like project-relative source paths such as `src/...`, `app/...`
|
|
363
|
+
*
|
|
364
|
+
* We consider a string a local path if it:
|
|
365
|
+
* - Starts with `.` (relative: `./hooks/t`, `../../hooks/t`)
|
|
366
|
+
*- Starts with `/` (absolute)
|
|
367
|
+
* - Contains a `/` AND its first segment looks like a source directory
|
|
368
|
+
* (`src`, `app`, `lib`, `hooks`, `utils`, `pages`, `components`, `features`,
|
|
369
|
+
* `shared`, `common`, `locales`, `i18n`, `services`, `store`)
|
|
370
|
+
*/
|
|
371
|
+
function isNpmPackage(pkg: string): boolean {
|
|
372
|
+
if (pkg.startsWith('.') || pkg.startsWith('/')) return false;
|
|
373
|
+
|
|
374
|
+
// Scoped npm package: @scope/name — always npm
|
|
375
|
+
if (pkg.startsWith('@') && pkg.includes('/')) {
|
|
376
|
+
// @/alias paths used in bundlers (e.g. "@/hooks/useT") are NOT npm packages
|
|
377
|
+
if (pkg.startsWith('@/')) return false;
|
|
378
|
+
return true;
|
|
379
|
+
}
|
|
380
|
+
|
|
381
|
+
// If no slash at all → bare npm package name (e.g. "i18next", "react-i18next")
|
|
382
|
+
if (!pkg.includes('/')) return true;
|
|
383
|
+
|
|
384
|
+
// Has a slash — check if the first segment is a known source directory name
|
|
385
|
+
const firstSegment = pkg.split('/')[0].toLowerCase();
|
|
386
|
+
const sourceDirectories = new Set([
|
|
387
|
+
'src', 'app', 'lib', 'hooks', 'utils', 'pages', 'components',
|
|
388
|
+
'features', 'shared', 'common', 'locales', 'i18n', 'services',
|
|
389
|
+
'store', 'helpers', 'core', 'modules',
|
|
390
|
+
]);
|
|
391
|
+
if (sourceDirectories.has(firstSegment)) return false;
|
|
392
|
+
|
|
393
|
+
// Everything else (e.g. "lodash/fp", "date-fns/locale") → npm package
|
|
394
|
+
return true;
|
|
395
|
+
}
|
|
396
|
+
|
|
253
397
|
/**
|
|
254
398
|
* Applies the React codemod to a file in-place.
|
|
255
399
|
*/
|
|
256
400
|
export function applyReactCodemod(
|
|
257
401
|
filePath: string,
|
|
258
402
|
texts: DetectedText[],
|
|
259
|
-
dryRun = false
|
|
403
|
+
dryRun = false,
|
|
404
|
+
codemodConfig?: CodemodConfig,
|
|
405
|
+
cwd = process.cwd()
|
|
260
406
|
): CodemodResult {
|
|
261
407
|
const content = fs.readFileSync(filePath, 'utf-8');
|
|
262
|
-
const codemod = new ReactCodemod(filePath, content, texts);
|
|
408
|
+
const codemod = new ReactCodemod(filePath, content, texts, codemodConfig, cwd);
|
|
263
409
|
const result = codemod.transform();
|
|
264
410
|
|
|
265
411
|
if (result.changed && !dryRun) {
|
|
266
|
-
|
|
412
|
+
fs.writeFileSync(filePath, result.transformedContent, 'utf-8');
|
|
267
413
|
}
|
|
268
414
|
|
|
269
415
|
return result;
|