ai-localize-codemods 1.0.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/dist/index.d.mts +105 -0
- package/dist/index.d.ts +105 -0
- package/dist/index.js +531 -0
- package/dist/index.mjs +486 -0
- package/package.json +43 -0
- package/src/angular-codemod.ts +95 -0
- package/src/cdn-replacer.ts +102 -0
- package/src/codemod-runner.ts +117 -0
- package/src/index.ts +5 -0
- package/src/react-codemod.ts +270 -0
- package/src/vue-codemod.ts +97 -0
- package/tsconfig.json +9 -0
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
import * as fs from 'fs';
|
|
2
|
+
import * as path from 'path';
|
|
3
|
+
|
|
4
|
+
import type { LegacyCdnUrl, CloudFrontAsset } from '@ai-localize/shared';
|
|
5
|
+
|
|
6
|
+
export interface CdnReplacementResult {
|
|
7
|
+
filePath: string;
|
|
8
|
+
replacedCount: number;
|
|
9
|
+
changed: boolean;
|
|
10
|
+
originalContent: string;
|
|
11
|
+
transformedContent: string;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* Replaces legacy CDN URLs with CloudFront URLs in source files.
|
|
16
|
+
* Supports: TS/JS/JSX/TSX/CSS/HTML/Vue
|
|
17
|
+
*/
|
|
18
|
+
export class CdnReplacer {
|
|
19
|
+
private assetMap: Map<string, CloudFrontAsset>;
|
|
20
|
+
|
|
21
|
+
constructor(assets: CloudFrontAsset[]) {
|
|
22
|
+
this.assetMap = new Map();
|
|
23
|
+
for (const asset of assets) {
|
|
24
|
+
this.assetMap.set(asset.localPath, asset);
|
|
25
|
+
// Also index by asset path without leading slash
|
|
26
|
+
this.assetMap.set(asset.s3Key, asset);
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
replaceInFile(
|
|
31
|
+
filePath: string,
|
|
32
|
+
legacyUrls: LegacyCdnUrl[],
|
|
33
|
+
dryRun = false
|
|
34
|
+
): CdnReplacementResult {
|
|
35
|
+
let content: string;
|
|
36
|
+
try {
|
|
37
|
+
content = fs.readFileSync(filePath, 'utf-8');
|
|
38
|
+
} catch {
|
|
39
|
+
return { filePath, replacedCount: 0, changed: false, originalContent: '', transformedContent: '' };
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
let transformedContent = content;
|
|
43
|
+
let replacedCount = 0;
|
|
44
|
+
|
|
45
|
+
for (const legacyUrl of legacyUrls) {
|
|
46
|
+
// Find the corresponding CloudFront asset
|
|
47
|
+
const asset = this.findAsset(legacyUrl.assetPath);
|
|
48
|
+
if (!asset) continue;
|
|
49
|
+
|
|
50
|
+
const escapedUrl = legacyUrl.url.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
|
51
|
+
const re = new RegExp(escapedUrl, 'g');
|
|
52
|
+
const newCount = (transformedContent.match(re) || []).length;
|
|
53
|
+
transformedContent = transformedContent.replace(re, asset.cloudfrontUrl);
|
|
54
|
+
replacedCount += newCount;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
if (replacedCount > 0 && !dryRun) {
|
|
58
|
+
fs.writeFileSync(filePath, transformedContent, 'utf-8');
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
return {
|
|
62
|
+
filePath,
|
|
63
|
+
replacedCount,
|
|
64
|
+
changed: replacedCount > 0,
|
|
65
|
+
originalContent: content,
|
|
66
|
+
transformedContent,
|
|
67
|
+
};
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
private findAsset(assetPath: string): CloudFrontAsset | undefined {
|
|
71
|
+
// Normalize path
|
|
72
|
+
const normalized = assetPath.replace(/^\//, '');
|
|
73
|
+
return (
|
|
74
|
+
this.assetMap.get(assetPath) ||
|
|
75
|
+
this.assetMap.get(normalized) ||
|
|
76
|
+
this.assetMap.get('/' + normalized) ||
|
|
77
|
+
// fuzzy match by filename
|
|
78
|
+
Array.from(this.assetMap.values()).find((a) =>
|
|
79
|
+
path.basename(a.localPath) === path.basename(assetPath)
|
|
80
|
+
)
|
|
81
|
+
);
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/**
|
|
86
|
+
* Batch replace CDN URLs across multiple files.
|
|
87
|
+
*/
|
|
88
|
+
export async function batchReplaceCdnUrls(
|
|
89
|
+
fileUrlMap: Map<string, LegacyCdnUrl[]>,
|
|
90
|
+
assets: CloudFrontAsset[],
|
|
91
|
+
dryRun = false
|
|
92
|
+
): Promise<CdnReplacementResult[]> {
|
|
93
|
+
const replacer = new CdnReplacer(assets);
|
|
94
|
+
const results: CdnReplacementResult[] = [];
|
|
95
|
+
|
|
96
|
+
for (const [filePath, legacyUrls] of fileUrlMap) {
|
|
97
|
+
const result = replacer.replaceInFile(filePath, legacyUrls, dryRun);
|
|
98
|
+
results.push(result);
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
return results;
|
|
102
|
+
}
|
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
import * as path from 'path';
|
|
2
|
+
import type { Framework, DetectedText, LocalizationConfig } from '@ai-localize/shared';
|
|
3
|
+
import type { CodemodResult } from './react-codemod.js';
|
|
4
|
+
import { applyReactCodemod } from './react-codemod.js';
|
|
5
|
+
import { applyVueCodemod } from './vue-codemod.js';
|
|
6
|
+
import { applyAngularCodemod } from './angular-codemod.js';
|
|
7
|
+
|
|
8
|
+
export interface CodemodRunOptions {
|
|
9
|
+
dryRun?: boolean;
|
|
10
|
+
onProgress?: (filePath: string, result: CodemodResult) => void;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
export interface CodemodRunSummary {
|
|
14
|
+
totalFiles: number;
|
|
15
|
+
changedFiles: number;
|
|
16
|
+
totalReplacements: number;
|
|
17
|
+
importInjections: number;
|
|
18
|
+
hookInjections: number;
|
|
19
|
+
results: CodemodResult[];
|
|
20
|
+
duration: number;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* Orchestrates codemods across all detected files.
|
|
25
|
+
* Groups detected texts by file and applies the appropriate codemod
|
|
26
|
+
* based on the project framework.
|
|
27
|
+
*/
|
|
28
|
+
export class CodemodRunner {
|
|
29
|
+
private config: LocalizationConfig;
|
|
30
|
+
|
|
31
|
+
constructor(config: LocalizationConfig) {
|
|
32
|
+
this.config = config;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
async run(
|
|
36
|
+
detectedTexts: DetectedText[],
|
|
37
|
+
options: CodemodRunOptions = {}
|
|
38
|
+
): Promise<CodemodRunSummary> {
|
|
39
|
+
const startTime = Date.now();
|
|
40
|
+
const { dryRun = false, onProgress } = options;
|
|
41
|
+
|
|
42
|
+
// Group texts by file
|
|
43
|
+
const fileTextMap = new Map<string, DetectedText[]>();
|
|
44
|
+
for (const dt of detectedTexts) {
|
|
45
|
+
const existing = fileTextMap.get(dt.filePath) || [];
|
|
46
|
+
existing.push(dt);
|
|
47
|
+
fileTextMap.set(dt.filePath, existing);
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
const results: CodemodResult[] = [];
|
|
51
|
+
let totalReplacements = 0;
|
|
52
|
+
let importInjections = 0;
|
|
53
|
+
let hookInjections = 0;
|
|
54
|
+
|
|
55
|
+
for (const [filePath, texts] of fileTextMap) {
|
|
56
|
+
let result: CodemodResult;
|
|
57
|
+
const framework = this.config.framework;
|
|
58
|
+
|
|
59
|
+
try {
|
|
60
|
+
result = this.applyCodemod(filePath, texts, framework, dryRun);
|
|
61
|
+
} catch (err) {
|
|
62
|
+
result = {
|
|
63
|
+
filePath,
|
|
64
|
+
originalContent: '',
|
|
65
|
+
transformedContent: '',
|
|
66
|
+
changed: false,
|
|
67
|
+
injectedImport: false,
|
|
68
|
+
injectedHook: false,
|
|
69
|
+
replacedTexts: 0,
|
|
70
|
+
};
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
results.push(result);
|
|
74
|
+
totalReplacements += result.replacedTexts;
|
|
75
|
+
if (result.injectedImport) importInjections++;
|
|
76
|
+
if (result.injectedHook) hookInjections++;
|
|
77
|
+
|
|
78
|
+
onProgress?.(filePath, result);
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
return {
|
|
82
|
+
totalFiles: fileTextMap.size,
|
|
83
|
+
changedFiles: results.filter((r) => r.changed).length,
|
|
84
|
+
totalReplacements,
|
|
85
|
+
importInjections,
|
|
86
|
+
hookInjections,
|
|
87
|
+
results,
|
|
88
|
+
duration: Date.now() - startTime,
|
|
89
|
+
};
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
private applyCodemod(
|
|
93
|
+
filePath: string,
|
|
94
|
+
texts: DetectedText[],
|
|
95
|
+
framework: Framework,
|
|
96
|
+
dryRun: boolean
|
|
97
|
+
): CodemodResult {
|
|
98
|
+
const ext = path.extname(filePath).toLowerCase();
|
|
99
|
+
|
|
100
|
+
// Vue SFCs
|
|
101
|
+
if (ext === '.vue') {
|
|
102
|
+
return applyVueCodemod(filePath, texts, dryRun);
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
// Angular templates or TS
|
|
106
|
+
if (
|
|
107
|
+
framework === 'angular' ||
|
|
108
|
+
framework === 'angular-ngx' ||
|
|
109
|
+
framework === 'angular-i18n'
|
|
110
|
+
) {
|
|
111
|
+
return applyAngularCodemod(filePath, texts, dryRun);
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
// React (default for .tsx, .ts, .jsx, .js)
|
|
115
|
+
return applyReactCodemod(filePath, texts, dryRun);
|
|
116
|
+
}
|
|
117
|
+
}
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,270 @@
|
|
|
1
|
+
import * as fs from 'fs';
|
|
2
|
+
import * as parser from '@babel/parser';
|
|
3
|
+
import traverse from '@babel/traverse';
|
|
4
|
+
import generate from '@babel/generator';
|
|
5
|
+
import * as t from '@babel/types';
|
|
6
|
+
|
|
7
|
+
import type { DetectedText } from '@ai-localize/shared';
|
|
8
|
+
|
|
9
|
+
export interface CodemodResult {
|
|
10
|
+
filePath: string;
|
|
11
|
+
originalContent: string;
|
|
12
|
+
transformedContent: string;
|
|
13
|
+
changed: boolean;
|
|
14
|
+
injectedImport: boolean;
|
|
15
|
+
injectedHook: boolean;
|
|
16
|
+
replacedTexts: number;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
const USE_TRANSLATION_IMPORT = 'react-i18next';
|
|
20
|
+
const USE_TRANSLATION_HOOK = 'useTranslation';
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* React i18n codemod: wraps hardcoded JSX text / string literals with t() calls.
|
|
24
|
+
* Preserves formatting, comments, and import order.
|
|
25
|
+
*/
|
|
26
|
+
export class ReactCodemod {
|
|
27
|
+
private filePath: string;
|
|
28
|
+
private content: string;
|
|
29
|
+
private texts: DetectedText[];
|
|
30
|
+
|
|
31
|
+
constructor(filePath: string, content: string, texts: DetectedText[]) {
|
|
32
|
+
this.filePath = filePath;
|
|
33
|
+
this.content = content;
|
|
34
|
+
this.texts = texts;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
transform(): CodemodResult {
|
|
38
|
+
if (this.texts.length === 0) {
|
|
39
|
+
return {
|
|
40
|
+
filePath: this.filePath,
|
|
41
|
+
originalContent: this.content,
|
|
42
|
+
transformedContent: this.content,
|
|
43
|
+
changed: false,
|
|
44
|
+
injectedImport: false,
|
|
45
|
+
injectedHook: false,
|
|
46
|
+
replacedTexts: 0,
|
|
47
|
+
};
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
let ast: t.File;
|
|
51
|
+
try {
|
|
52
|
+
ast = parser.parse(this.content, {
|
|
53
|
+
sourceType: 'module',
|
|
54
|
+
plugins: [
|
|
55
|
+
'jsx',
|
|
56
|
+
'typescript',
|
|
57
|
+
'decorators-legacy',
|
|
58
|
+
'classProperties',
|
|
59
|
+
'optionalChaining',
|
|
60
|
+
'nullishCoalescingOperator',
|
|
61
|
+
],
|
|
62
|
+
errorRecovery: true,
|
|
63
|
+
});
|
|
64
|
+
} catch {
|
|
65
|
+
return {
|
|
66
|
+
filePath: this.filePath,
|
|
67
|
+
originalContent: this.content,
|
|
68
|
+
transformedContent: this.content,
|
|
69
|
+
changed: false,
|
|
70
|
+
injectedImport: false,
|
|
71
|
+
injectedHook: false,
|
|
72
|
+
replacedTexts: 0,
|
|
73
|
+
};
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
// Build a map of text -> key for fast lookup
|
|
77
|
+
const textKeyMap = new Map<string, string>();
|
|
78
|
+
for (const dt of this.texts) {
|
|
79
|
+
textKeyMap.set(dt.text, dt.suggestedKey);
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
let replacedTexts = 0;
|
|
83
|
+
let hasUseTranslationImport = false;
|
|
84
|
+
let hasUseTranslationHook = false;
|
|
85
|
+
let injectedImport = false;
|
|
86
|
+
let injectedHook = false;
|
|
87
|
+
|
|
88
|
+
// Check existing imports
|
|
89
|
+
traverse(ast, {
|
|
90
|
+
ImportDeclaration(nodePath) {
|
|
91
|
+
if (nodePath.node.source.value === USE_TRANSLATION_IMPORT) {
|
|
92
|
+
hasUseTranslationImport = true;
|
|
93
|
+
}
|
|
94
|
+
},
|
|
95
|
+
// Check if useTranslation hook already destructured
|
|
96
|
+
VariableDeclarator(nodePath) {
|
|
97
|
+
const id = nodePath.node.id;
|
|
98
|
+
if (
|
|
99
|
+
t.isObjectPattern(id) &&
|
|
100
|
+
id.properties.some(
|
|
101
|
+
(p) =>
|
|
102
|
+
t.isObjectProperty(p) &&
|
|
103
|
+
t.isIdentifier(p.key) &&
|
|
104
|
+
p.key.name === 't'
|
|
105
|
+
)
|
|
106
|
+
) {
|
|
107
|
+
hasUseTranslationHook = true;
|
|
108
|
+
}
|
|
109
|
+
},
|
|
110
|
+
});
|
|
111
|
+
|
|
112
|
+
// Second pass: replace hardcoded texts
|
|
113
|
+
traverse(ast, {
|
|
114
|
+
JSXText(nodePath) {
|
|
115
|
+
const text = nodePath.node.value.trim();
|
|
116
|
+
if (!text) return;
|
|
117
|
+
const key = textKeyMap.get(text);
|
|
118
|
+
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);
|
|
125
|
+
replacedTexts++;
|
|
126
|
+
},
|
|
127
|
+
|
|
128
|
+
StringLiteral(nodePath) {
|
|
129
|
+
const text = nodePath.node.value;
|
|
130
|
+
const key = textKeyMap.get(text);
|
|
131
|
+
if (!key) return;
|
|
132
|
+
if (t.isImportDeclaration(nodePath.parent)) return;
|
|
133
|
+
if (t.isObjectProperty(nodePath.parent) && nodePath.parent.key === nodePath.node) return;
|
|
134
|
+
if (t.isJSXAttribute(nodePath.parent)) return;
|
|
135
|
+
|
|
136
|
+
const tCall = t.callExpression(t.identifier('t'), [t.stringLiteral(key)]);
|
|
137
|
+
nodePath.replaceWith(tCall);
|
|
138
|
+
replacedTexts++;
|
|
139
|
+
},
|
|
140
|
+
|
|
141
|
+
JSXAttribute(nodePath) {
|
|
142
|
+
const valueNode = nodePath.node.value;
|
|
143
|
+
if (!t.isStringLiteral(valueNode)) return;
|
|
144
|
+
const text = valueNode.value;
|
|
145
|
+
const key = textKeyMap.get(text);
|
|
146
|
+
if (!key) return;
|
|
147
|
+
|
|
148
|
+
// Replace string with {t('key')} expression
|
|
149
|
+
nodePath.node.value = t.jsxExpressionContainer(
|
|
150
|
+
t.callExpression(t.identifier('t'), [t.stringLiteral(key)])
|
|
151
|
+
);
|
|
152
|
+
replacedTexts++;
|
|
153
|
+
},
|
|
154
|
+
});
|
|
155
|
+
|
|
156
|
+
if (replacedTexts === 0) {
|
|
157
|
+
return {
|
|
158
|
+
filePath: this.filePath,
|
|
159
|
+
originalContent: this.content,
|
|
160
|
+
transformedContent: this.content,
|
|
161
|
+
changed: false,
|
|
162
|
+
injectedImport: false,
|
|
163
|
+
injectedHook: false,
|
|
164
|
+
replacedTexts: 0,
|
|
165
|
+
};
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
// Inject import if needed
|
|
169
|
+
if (!hasUseTranslationImport) {
|
|
170
|
+
const importDecl = t.importDeclaration(
|
|
171
|
+
[t.importSpecifier(t.identifier(USE_TRANSLATION_HOOK), t.identifier(USE_TRANSLATION_HOOK))],
|
|
172
|
+
t.stringLiteral(USE_TRANSLATION_IMPORT)
|
|
173
|
+
);
|
|
174
|
+
ast.program.body.unshift(importDecl);
|
|
175
|
+
injectedImport = true;
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
// Inject hook at the top of function components
|
|
179
|
+
if (!hasUseTranslationHook) {
|
|
180
|
+
this.injectUseTranslationHook(ast);
|
|
181
|
+
injectedHook = true;
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
// Generate output preserving formatting
|
|
185
|
+
const output = generate(
|
|
186
|
+
ast,
|
|
187
|
+
{
|
|
188
|
+
retainLines: false,
|
|
189
|
+
comments: true,
|
|
190
|
+
compact: false,
|
|
191
|
+
},
|
|
192
|
+
this.content
|
|
193
|
+
);
|
|
194
|
+
|
|
195
|
+
return {
|
|
196
|
+
filePath: this.filePath,
|
|
197
|
+
originalContent: this.content,
|
|
198
|
+
transformedContent: output.code,
|
|
199
|
+
changed: true,
|
|
200
|
+
injectedImport,
|
|
201
|
+
injectedHook,
|
|
202
|
+
replacedTexts,
|
|
203
|
+
};
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
private injectUseTranslationHook(ast: t.File): void {
|
|
207
|
+
traverse(ast, {
|
|
208
|
+
FunctionDeclaration(nodePath) {
|
|
209
|
+
if (!isReactComponent(nodePath.node.id?.name)) return;
|
|
210
|
+
injectHookIntoBody(nodePath.node.body);
|
|
211
|
+
nodePath.stop();
|
|
212
|
+
},
|
|
213
|
+
ArrowFunctionExpression(nodePath) {
|
|
214
|
+
if (!isReactComponentParent(nodePath)) return;
|
|
215
|
+
const body = nodePath.node.body;
|
|
216
|
+
if (t.isBlockStatement(body)) {
|
|
217
|
+
injectHookIntoBody(body);
|
|
218
|
+
nodePath.stop();
|
|
219
|
+
}
|
|
220
|
+
},
|
|
221
|
+
});
|
|
222
|
+
}
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
function isReactComponent(name?: string): boolean {
|
|
226
|
+
if (!name) return false;
|
|
227
|
+
return /^[A-Z]/.test(name);
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
231
|
+
function isReactComponentParent(nodePath: any): boolean {
|
|
232
|
+
const parent = nodePath.parent;
|
|
233
|
+
if (t.isVariableDeclarator(parent)) {
|
|
234
|
+
const id = parent.id;
|
|
235
|
+
if (t.isIdentifier(id) && /^[A-Z]/.test(id.name)) return true;
|
|
236
|
+
}
|
|
237
|
+
return false;
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
function injectHookIntoBody(body: t.BlockStatement): void {
|
|
241
|
+
// const { t } = useTranslation();
|
|
242
|
+
const hookDecl = t.variableDeclaration('const', [
|
|
243
|
+
t.variableDeclarator(
|
|
244
|
+
t.objectPattern([
|
|
245
|
+
t.objectProperty(t.identifier('t'), t.identifier('t'), false, true),
|
|
246
|
+
]),
|
|
247
|
+
t.callExpression(t.identifier('useTranslation'), [])
|
|
248
|
+
),
|
|
249
|
+
]);
|
|
250
|
+
body.body.unshift(hookDecl);
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
/**
|
|
254
|
+
* Applies the React codemod to a file in-place.
|
|
255
|
+
*/
|
|
256
|
+
export function applyReactCodemod(
|
|
257
|
+
filePath: string,
|
|
258
|
+
texts: DetectedText[],
|
|
259
|
+
dryRun = false
|
|
260
|
+
): CodemodResult {
|
|
261
|
+
const content = fs.readFileSync(filePath, 'utf-8');
|
|
262
|
+
const codemod = new ReactCodemod(filePath, content, texts);
|
|
263
|
+
const result = codemod.transform();
|
|
264
|
+
|
|
265
|
+
if (result.changed && !dryRun) {
|
|
266
|
+
fs.writeFileSync(filePath, result.transformedContent, 'utf-8');
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
return result;
|
|
270
|
+
}
|
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
import * as fs from 'fs';
|
|
2
|
+
|
|
3
|
+
import type { DetectedText } from '@ai-localize/shared';
|
|
4
|
+
import type { CodemodResult } from './react-codemod.js';
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Vue i18n codemod: wraps hardcoded text with $t() calls in Vue SFCs.
|
|
8
|
+
* Handles both Options API and Composition API.
|
|
9
|
+
*/
|
|
10
|
+
export class VueCodemod {
|
|
11
|
+
private filePath: string;
|
|
12
|
+
private content: string;
|
|
13
|
+
private texts: DetectedText[];
|
|
14
|
+
|
|
15
|
+
constructor(filePath: string, content: string, texts: DetectedText[]) {
|
|
16
|
+
this.filePath = filePath;
|
|
17
|
+
this.content = content;
|
|
18
|
+
this.texts = texts;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
transform(): CodemodResult {
|
|
22
|
+
if (this.texts.length === 0) {
|
|
23
|
+
return this.unchanged();
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
// Parse <template> and <script> sections separately
|
|
27
|
+
const templateMatch = this.content.match(/<template[^>]*>([\s\S]*?)<\/template>/);
|
|
28
|
+
const scriptMatch = this.content.match(/<script[^>]*>([\s\S]*?)<\/script>/);
|
|
29
|
+
|
|
30
|
+
if (!templateMatch && !scriptMatch) {
|
|
31
|
+
return this.unchanged();
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
const textKeyMap = new Map<string, string>();
|
|
35
|
+
for (const dt of this.texts) {
|
|
36
|
+
textKeyMap.set(dt.text, dt.suggestedKey);
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
let transformedContent = this.content;
|
|
40
|
+
let replacedTexts = 0;
|
|
41
|
+
|
|
42
|
+
// Replace in template: >text< => >{{ $t('key') }}<
|
|
43
|
+
if (templateMatch) {
|
|
44
|
+
let template = templateMatch[1];
|
|
45
|
+
for (const [text, key] of textKeyMap) {
|
|
46
|
+
const escaped = text.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
|
47
|
+
const re = new RegExp(`(>\\s*)${escaped}(\\s*<)`, 'g');
|
|
48
|
+
template = template.replace(re, `$1{{ $t('${key}') }}$2`);
|
|
49
|
+
// Also replace in attribute values: title="text" => :title="$t('key')"
|
|
50
|
+
const attrRe = new RegExp(`((?:title|placeholder|alt|label)=)"(${escaped})"`, 'g');
|
|
51
|
+
template = template.replace(attrRe, `:$1"$t('${key}')"`);
|
|
52
|
+
replacedTexts++;
|
|
53
|
+
}
|
|
54
|
+
transformedContent = transformedContent.replace(templateMatch[1], template);
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
if (replacedTexts === 0) {
|
|
58
|
+
return this.unchanged();
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
return {
|
|
62
|
+
filePath: this.filePath,
|
|
63
|
+
originalContent: this.content,
|
|
64
|
+
transformedContent,
|
|
65
|
+
changed: true,
|
|
66
|
+
injectedImport: false, // vue-i18n is globally injected
|
|
67
|
+
injectedHook: false,
|
|
68
|
+
replacedTexts,
|
|
69
|
+
};
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
private unchanged(): CodemodResult {
|
|
73
|
+
return {
|
|
74
|
+
filePath: this.filePath,
|
|
75
|
+
originalContent: this.content,
|
|
76
|
+
transformedContent: this.content,
|
|
77
|
+
changed: false,
|
|
78
|
+
injectedImport: false,
|
|
79
|
+
injectedHook: false,
|
|
80
|
+
replacedTexts: 0,
|
|
81
|
+
};
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
export function applyVueCodemod(
|
|
86
|
+
filePath: string,
|
|
87
|
+
texts: DetectedText[],
|
|
88
|
+
dryRun = false
|
|
89
|
+
): CodemodResult {
|
|
90
|
+
const content = fs.readFileSync(filePath, 'utf-8');
|
|
91
|
+
const codemod = new VueCodemod(filePath, content, texts);
|
|
92
|
+
const result = codemod.transform();
|
|
93
|
+
if (result.changed && !dryRun) {
|
|
94
|
+
fs.writeFileSync(filePath, result.transformedContent, 'utf-8');
|
|
95
|
+
}
|
|
96
|
+
return result;
|
|
97
|
+
}
|