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.
@@ -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,5 @@
1
+ export * from './react-codemod.js';
2
+ export * from './vue-codemod.js';
3
+ export * from './angular-codemod.js';
4
+ export * from './cdn-replacer.js';
5
+ export * from './codemod-runner.js';
@@ -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
+ }
package/tsconfig.json ADDED
@@ -0,0 +1,9 @@
1
+ {
2
+ "extends": "../../tsconfig.base.json",
3
+ "compilerOptions": {
4
+ "outDir": "./dist",
5
+ "rootDir": "./src"
6
+ },
7
+ "include": ["src/**/*"],
8
+ "exclude": ["node_modules", "dist"]
9
+ }