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.mjs ADDED
@@ -0,0 +1,486 @@
1
+ // src/react-codemod.ts
2
+ import * as fs from "fs";
3
+ import * as parser from "@babel/parser";
4
+ import traverse from "@babel/traverse";
5
+ import generate from "@babel/generator";
6
+ import * as t from "@babel/types";
7
+ var USE_TRANSLATION_IMPORT = "react-i18next";
8
+ var USE_TRANSLATION_HOOK = "useTranslation";
9
+ var ReactCodemod = class {
10
+ filePath;
11
+ content;
12
+ texts;
13
+ constructor(filePath, content, texts) {
14
+ this.filePath = filePath;
15
+ this.content = content;
16
+ this.texts = texts;
17
+ }
18
+ transform() {
19
+ if (this.texts.length === 0) {
20
+ return {
21
+ filePath: this.filePath,
22
+ originalContent: this.content,
23
+ transformedContent: this.content,
24
+ changed: false,
25
+ injectedImport: false,
26
+ injectedHook: false,
27
+ replacedTexts: 0
28
+ };
29
+ }
30
+ let ast;
31
+ try {
32
+ ast = parser.parse(this.content, {
33
+ sourceType: "module",
34
+ plugins: [
35
+ "jsx",
36
+ "typescript",
37
+ "decorators-legacy",
38
+ "classProperties",
39
+ "optionalChaining",
40
+ "nullishCoalescingOperator"
41
+ ],
42
+ errorRecovery: true
43
+ });
44
+ } catch {
45
+ return {
46
+ filePath: this.filePath,
47
+ originalContent: this.content,
48
+ transformedContent: this.content,
49
+ changed: false,
50
+ injectedImport: false,
51
+ injectedHook: false,
52
+ replacedTexts: 0
53
+ };
54
+ }
55
+ const textKeyMap = /* @__PURE__ */ new Map();
56
+ for (const dt of this.texts) {
57
+ textKeyMap.set(dt.text, dt.suggestedKey);
58
+ }
59
+ let replacedTexts = 0;
60
+ let hasUseTranslationImport = false;
61
+ let hasUseTranslationHook = false;
62
+ let injectedImport = false;
63
+ let injectedHook = false;
64
+ traverse(ast, {
65
+ ImportDeclaration(nodePath) {
66
+ if (nodePath.node.source.value === USE_TRANSLATION_IMPORT) {
67
+ hasUseTranslationImport = true;
68
+ }
69
+ },
70
+ // Check if useTranslation hook already destructured
71
+ VariableDeclarator(nodePath) {
72
+ const id = nodePath.node.id;
73
+ if (t.isObjectPattern(id) && id.properties.some(
74
+ (p) => t.isObjectProperty(p) && t.isIdentifier(p.key) && p.key.name === "t"
75
+ )) {
76
+ hasUseTranslationHook = true;
77
+ }
78
+ }
79
+ });
80
+ traverse(ast, {
81
+ JSXText(nodePath) {
82
+ const text = nodePath.node.value.trim();
83
+ if (!text) return;
84
+ const key = textKeyMap.get(text);
85
+ if (!key) return;
86
+ const tCall = t.jsxExpressionContainer(
87
+ t.callExpression(t.identifier("t"), [t.stringLiteral(key)])
88
+ );
89
+ nodePath.replaceWith(tCall);
90
+ replacedTexts++;
91
+ },
92
+ StringLiteral(nodePath) {
93
+ const text = nodePath.node.value;
94
+ const key = textKeyMap.get(text);
95
+ if (!key) return;
96
+ if (t.isImportDeclaration(nodePath.parent)) return;
97
+ if (t.isObjectProperty(nodePath.parent) && nodePath.parent.key === nodePath.node) return;
98
+ if (t.isJSXAttribute(nodePath.parent)) return;
99
+ const tCall = t.callExpression(t.identifier("t"), [t.stringLiteral(key)]);
100
+ nodePath.replaceWith(tCall);
101
+ replacedTexts++;
102
+ },
103
+ JSXAttribute(nodePath) {
104
+ const valueNode = nodePath.node.value;
105
+ if (!t.isStringLiteral(valueNode)) return;
106
+ const text = valueNode.value;
107
+ const key = textKeyMap.get(text);
108
+ if (!key) return;
109
+ nodePath.node.value = t.jsxExpressionContainer(
110
+ t.callExpression(t.identifier("t"), [t.stringLiteral(key)])
111
+ );
112
+ replacedTexts++;
113
+ }
114
+ });
115
+ if (replacedTexts === 0) {
116
+ return {
117
+ filePath: this.filePath,
118
+ originalContent: this.content,
119
+ transformedContent: this.content,
120
+ changed: false,
121
+ injectedImport: false,
122
+ injectedHook: false,
123
+ replacedTexts: 0
124
+ };
125
+ }
126
+ if (!hasUseTranslationImport) {
127
+ const importDecl = t.importDeclaration(
128
+ [t.importSpecifier(t.identifier(USE_TRANSLATION_HOOK), t.identifier(USE_TRANSLATION_HOOK))],
129
+ t.stringLiteral(USE_TRANSLATION_IMPORT)
130
+ );
131
+ ast.program.body.unshift(importDecl);
132
+ injectedImport = true;
133
+ }
134
+ if (!hasUseTranslationHook) {
135
+ this.injectUseTranslationHook(ast);
136
+ injectedHook = true;
137
+ }
138
+ const output = generate(
139
+ ast,
140
+ {
141
+ retainLines: false,
142
+ comments: true,
143
+ compact: false
144
+ },
145
+ this.content
146
+ );
147
+ return {
148
+ filePath: this.filePath,
149
+ originalContent: this.content,
150
+ transformedContent: output.code,
151
+ changed: true,
152
+ injectedImport,
153
+ injectedHook,
154
+ replacedTexts
155
+ };
156
+ }
157
+ injectUseTranslationHook(ast) {
158
+ traverse(ast, {
159
+ FunctionDeclaration(nodePath) {
160
+ if (!isReactComponent(nodePath.node.id?.name)) return;
161
+ injectHookIntoBody(nodePath.node.body);
162
+ nodePath.stop();
163
+ },
164
+ ArrowFunctionExpression(nodePath) {
165
+ if (!isReactComponentParent(nodePath)) return;
166
+ const body = nodePath.node.body;
167
+ if (t.isBlockStatement(body)) {
168
+ injectHookIntoBody(body);
169
+ nodePath.stop();
170
+ }
171
+ }
172
+ });
173
+ }
174
+ };
175
+ function isReactComponent(name) {
176
+ if (!name) return false;
177
+ return /^[A-Z]/.test(name);
178
+ }
179
+ function isReactComponentParent(nodePath) {
180
+ const parent = nodePath.parent;
181
+ if (t.isVariableDeclarator(parent)) {
182
+ const id = parent.id;
183
+ if (t.isIdentifier(id) && /^[A-Z]/.test(id.name)) return true;
184
+ }
185
+ return false;
186
+ }
187
+ function injectHookIntoBody(body) {
188
+ const hookDecl = t.variableDeclaration("const", [
189
+ t.variableDeclarator(
190
+ t.objectPattern([
191
+ t.objectProperty(t.identifier("t"), t.identifier("t"), false, true)
192
+ ]),
193
+ t.callExpression(t.identifier("useTranslation"), [])
194
+ )
195
+ ]);
196
+ body.body.unshift(hookDecl);
197
+ }
198
+ function applyReactCodemod(filePath, texts, dryRun = false) {
199
+ const content = fs.readFileSync(filePath, "utf-8");
200
+ const codemod = new ReactCodemod(filePath, content, texts);
201
+ const result = codemod.transform();
202
+ if (result.changed && !dryRun) {
203
+ fs.writeFileSync(filePath, result.transformedContent, "utf-8");
204
+ }
205
+ return result;
206
+ }
207
+
208
+ // src/vue-codemod.ts
209
+ import * as fs2 from "fs";
210
+ var VueCodemod = class {
211
+ filePath;
212
+ content;
213
+ texts;
214
+ constructor(filePath, content, texts) {
215
+ this.filePath = filePath;
216
+ this.content = content;
217
+ this.texts = texts;
218
+ }
219
+ transform() {
220
+ if (this.texts.length === 0) {
221
+ return this.unchanged();
222
+ }
223
+ const templateMatch = this.content.match(/<template[^>]*>([\s\S]*?)<\/template>/);
224
+ const scriptMatch = this.content.match(/<script[^>]*>([\s\S]*?)<\/script>/);
225
+ if (!templateMatch && !scriptMatch) {
226
+ return this.unchanged();
227
+ }
228
+ const textKeyMap = /* @__PURE__ */ new Map();
229
+ for (const dt of this.texts) {
230
+ textKeyMap.set(dt.text, dt.suggestedKey);
231
+ }
232
+ let transformedContent = this.content;
233
+ let replacedTexts = 0;
234
+ if (templateMatch) {
235
+ let template = templateMatch[1];
236
+ for (const [text, key] of textKeyMap) {
237
+ const escaped = text.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
238
+ const re = new RegExp(`(>\\s*)${escaped}(\\s*<)`, "g");
239
+ template = template.replace(re, `$1{{ $t('${key}') }}$2`);
240
+ const attrRe = new RegExp(`((?:title|placeholder|alt|label)=)"(${escaped})"`, "g");
241
+ template = template.replace(attrRe, `:$1"$t('${key}')"`);
242
+ replacedTexts++;
243
+ }
244
+ transformedContent = transformedContent.replace(templateMatch[1], template);
245
+ }
246
+ if (replacedTexts === 0) {
247
+ return this.unchanged();
248
+ }
249
+ return {
250
+ filePath: this.filePath,
251
+ originalContent: this.content,
252
+ transformedContent,
253
+ changed: true,
254
+ injectedImport: false,
255
+ // vue-i18n is globally injected
256
+ injectedHook: false,
257
+ replacedTexts
258
+ };
259
+ }
260
+ unchanged() {
261
+ return {
262
+ filePath: this.filePath,
263
+ originalContent: this.content,
264
+ transformedContent: this.content,
265
+ changed: false,
266
+ injectedImport: false,
267
+ injectedHook: false,
268
+ replacedTexts: 0
269
+ };
270
+ }
271
+ };
272
+ function applyVueCodemod(filePath, texts, dryRun = false) {
273
+ const content = fs2.readFileSync(filePath, "utf-8");
274
+ const codemod = new VueCodemod(filePath, content, texts);
275
+ const result = codemod.transform();
276
+ if (result.changed && !dryRun) {
277
+ fs2.writeFileSync(filePath, result.transformedContent, "utf-8");
278
+ }
279
+ return result;
280
+ }
281
+
282
+ // src/angular-codemod.ts
283
+ import * as fs3 from "fs";
284
+ var AngularCodemod = class {
285
+ filePath;
286
+ content;
287
+ texts;
288
+ constructor(filePath, content, texts) {
289
+ this.filePath = filePath;
290
+ this.content = content;
291
+ this.texts = texts;
292
+ }
293
+ transform() {
294
+ if (this.texts.length === 0) return this.unchanged();
295
+ const textKeyMap = /* @__PURE__ */ new Map();
296
+ for (const dt of this.texts) {
297
+ textKeyMap.set(dt.text, dt.suggestedKey);
298
+ }
299
+ let transformedContent = this.content;
300
+ let replacedTexts = 0;
301
+ const isTemplate = this.filePath.endsWith(".html");
302
+ for (const [text, key] of textKeyMap) {
303
+ const escaped = text.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
304
+ if (isTemplate) {
305
+ const re = new RegExp(`(>\\s*)${escaped}(\\s*<)`, "g");
306
+ transformedContent = transformedContent.replace(re, `$1{{ '${key}' | translate }}$2`);
307
+ const attrRe = new RegExp(`\\[(?:placeholder|label|title|alt)\\]="'${escaped}'"`, "g");
308
+ transformedContent = transformedContent.replace(
309
+ attrRe,
310
+ `[placeholder]="'${key}' | translate"`
311
+ );
312
+ } else {
313
+ const tsRe = new RegExp(`'${escaped}'`, "g");
314
+ transformedContent = transformedContent.replace(
315
+ tsRe,
316
+ `this.translateService.instant('${key}')`
317
+ );
318
+ }
319
+ replacedTexts++;
320
+ }
321
+ if (replacedTexts === 0) return this.unchanged();
322
+ return {
323
+ filePath: this.filePath,
324
+ originalContent: this.content,
325
+ transformedContent,
326
+ changed: true,
327
+ injectedImport: false,
328
+ injectedHook: false,
329
+ replacedTexts
330
+ };
331
+ }
332
+ unchanged() {
333
+ return {
334
+ filePath: this.filePath,
335
+ originalContent: this.content,
336
+ transformedContent: this.content,
337
+ changed: false,
338
+ injectedImport: false,
339
+ injectedHook: false,
340
+ replacedTexts: 0
341
+ };
342
+ }
343
+ };
344
+ function applyAngularCodemod(filePath, texts, dryRun = false) {
345
+ const content = fs3.readFileSync(filePath, "utf-8");
346
+ const codemod = new AngularCodemod(filePath, content, texts);
347
+ const result = codemod.transform();
348
+ if (result.changed && !dryRun) {
349
+ fs3.writeFileSync(filePath, result.transformedContent, "utf-8");
350
+ }
351
+ return result;
352
+ }
353
+
354
+ // src/cdn-replacer.ts
355
+ import * as fs4 from "fs";
356
+ import * as path from "path";
357
+ var CdnReplacer = class {
358
+ assetMap;
359
+ constructor(assets) {
360
+ this.assetMap = /* @__PURE__ */ new Map();
361
+ for (const asset of assets) {
362
+ this.assetMap.set(asset.localPath, asset);
363
+ this.assetMap.set(asset.s3Key, asset);
364
+ }
365
+ }
366
+ replaceInFile(filePath, legacyUrls, dryRun = false) {
367
+ let content;
368
+ try {
369
+ content = fs4.readFileSync(filePath, "utf-8");
370
+ } catch {
371
+ return { filePath, replacedCount: 0, changed: false, originalContent: "", transformedContent: "" };
372
+ }
373
+ let transformedContent = content;
374
+ let replacedCount = 0;
375
+ for (const legacyUrl of legacyUrls) {
376
+ const asset = this.findAsset(legacyUrl.assetPath);
377
+ if (!asset) continue;
378
+ const escapedUrl = legacyUrl.url.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
379
+ const re = new RegExp(escapedUrl, "g");
380
+ const newCount = (transformedContent.match(re) || []).length;
381
+ transformedContent = transformedContent.replace(re, asset.cloudfrontUrl);
382
+ replacedCount += newCount;
383
+ }
384
+ if (replacedCount > 0 && !dryRun) {
385
+ fs4.writeFileSync(filePath, transformedContent, "utf-8");
386
+ }
387
+ return {
388
+ filePath,
389
+ replacedCount,
390
+ changed: replacedCount > 0,
391
+ originalContent: content,
392
+ transformedContent
393
+ };
394
+ }
395
+ findAsset(assetPath) {
396
+ const normalized = assetPath.replace(/^\//, "");
397
+ return this.assetMap.get(assetPath) || this.assetMap.get(normalized) || this.assetMap.get("/" + normalized) || // fuzzy match by filename
398
+ Array.from(this.assetMap.values()).find(
399
+ (a) => path.basename(a.localPath) === path.basename(assetPath)
400
+ );
401
+ }
402
+ };
403
+ async function batchReplaceCdnUrls(fileUrlMap, assets, dryRun = false) {
404
+ const replacer = new CdnReplacer(assets);
405
+ const results = [];
406
+ for (const [filePath, legacyUrls] of fileUrlMap) {
407
+ const result = replacer.replaceInFile(filePath, legacyUrls, dryRun);
408
+ results.push(result);
409
+ }
410
+ return results;
411
+ }
412
+
413
+ // src/codemod-runner.ts
414
+ import * as path2 from "path";
415
+ var CodemodRunner = class {
416
+ config;
417
+ constructor(config) {
418
+ this.config = config;
419
+ }
420
+ async run(detectedTexts, options = {}) {
421
+ const startTime = Date.now();
422
+ const { dryRun = false, onProgress } = options;
423
+ const fileTextMap = /* @__PURE__ */ new Map();
424
+ for (const dt of detectedTexts) {
425
+ const existing = fileTextMap.get(dt.filePath) || [];
426
+ existing.push(dt);
427
+ fileTextMap.set(dt.filePath, existing);
428
+ }
429
+ const results = [];
430
+ let totalReplacements = 0;
431
+ let importInjections = 0;
432
+ let hookInjections = 0;
433
+ for (const [filePath, texts] of fileTextMap) {
434
+ let result;
435
+ const framework = this.config.framework;
436
+ try {
437
+ result = this.applyCodemod(filePath, texts, framework, dryRun);
438
+ } catch (err) {
439
+ result = {
440
+ filePath,
441
+ originalContent: "",
442
+ transformedContent: "",
443
+ changed: false,
444
+ injectedImport: false,
445
+ injectedHook: false,
446
+ replacedTexts: 0
447
+ };
448
+ }
449
+ results.push(result);
450
+ totalReplacements += result.replacedTexts;
451
+ if (result.injectedImport) importInjections++;
452
+ if (result.injectedHook) hookInjections++;
453
+ onProgress?.(filePath, result);
454
+ }
455
+ return {
456
+ totalFiles: fileTextMap.size,
457
+ changedFiles: results.filter((r) => r.changed).length,
458
+ totalReplacements,
459
+ importInjections,
460
+ hookInjections,
461
+ results,
462
+ duration: Date.now() - startTime
463
+ };
464
+ }
465
+ applyCodemod(filePath, texts, framework, dryRun) {
466
+ const ext = path2.extname(filePath).toLowerCase();
467
+ if (ext === ".vue") {
468
+ return applyVueCodemod(filePath, texts, dryRun);
469
+ }
470
+ if (framework === "angular" || framework === "angular-ngx" || framework === "angular-i18n") {
471
+ return applyAngularCodemod(filePath, texts, dryRun);
472
+ }
473
+ return applyReactCodemod(filePath, texts, dryRun);
474
+ }
475
+ };
476
+ export {
477
+ AngularCodemod,
478
+ CdnReplacer,
479
+ CodemodRunner,
480
+ ReactCodemod,
481
+ VueCodemod,
482
+ applyAngularCodemod,
483
+ applyReactCodemod,
484
+ applyVueCodemod,
485
+ batchReplaceCdnUrls
486
+ };
package/package.json ADDED
@@ -0,0 +1,43 @@
1
+ {
2
+ "name": "ai-localize-codemods",
3
+ "version": "1.0.0",
4
+ "description": "AST-based codemods to inject i18n wrappers into frontend source code",
5
+ "main": "./dist/index.js",
6
+ "module": "./dist/index.mjs",
7
+ "types": "./dist/index.d.ts",
8
+ "exports": {
9
+ ".": {
10
+ "types": "./dist/index.d.ts",
11
+ "import": "./dist/index.mjs",
12
+ "require": "./dist/index.js"
13
+ }
14
+ },
15
+ "dependencies": {
16
+ "@babel/parser": "^7.23.9",
17
+ "@babel/traverse": "^7.23.9",
18
+ "@babel/types": "^7.23.9",
19
+ "@babel/generator": "^7.23.9",
20
+ "recast": "^0.23.4",
21
+ "jscodeshift": "^0.15.2",
22
+ "ai-localize-shared": "1.0.0"
23
+ },
24
+ "devDependencies": {
25
+ "@types/babel__generator": "^7.6.8",
26
+ "@types/babel__traverse": "^7.20.5",
27
+ "@types/jscodeshift": "^0.11.11",
28
+ "tsup": "^8.0.1",
29
+ "typescript": "^5.3.3",
30
+ "vitest": "^1.2.1"
31
+ },
32
+ "license": "MIT",
33
+ "publishConfig": {
34
+ "access": "public"
35
+ },
36
+ "scripts": {
37
+ "build": "tsup src/index.ts --format cjs,esm --dts",
38
+ "dev": "tsup src/index.ts --format cjs,esm --dts --watch",
39
+ "typecheck": "tsc --noEmit",
40
+ "test": "vitest run",
41
+ "lint": "eslint src --ext .ts"
42
+ }
43
+ }
@@ -0,0 +1,95 @@
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
+ * Angular ngx-translate codemod:
8
+ * - In templates: wraps text with {{ 'key' | translate }}
9
+ * - In TS: replaces string literals with this.translateService.instant('key')
10
+ */
11
+ export class AngularCodemod {
12
+ private filePath: string;
13
+ private content: string;
14
+ private texts: DetectedText[];
15
+
16
+ constructor(filePath: string, content: string, texts: DetectedText[]) {
17
+ this.filePath = filePath;
18
+ this.content = content;
19
+ this.texts = texts;
20
+ }
21
+
22
+ transform(): CodemodResult {
23
+ if (this.texts.length === 0) return this.unchanged();
24
+
25
+ const textKeyMap = new Map<string, string>();
26
+ for (const dt of this.texts) {
27
+ textKeyMap.set(dt.text, dt.suggestedKey);
28
+ }
29
+
30
+ let transformedContent = this.content;
31
+ let replacedTexts = 0;
32
+ const isTemplate = this.filePath.endsWith('.html');
33
+
34
+ for (const [text, key] of textKeyMap) {
35
+ const escaped = text.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
36
+ if (isTemplate) {
37
+ // Template: >Some Text< => >{{ 'key' | translate }}<
38
+ const re = new RegExp(`(>\\s*)${escaped}(\\s*<)`, 'g');
39
+ transformedContent = transformedContent.replace(re, `$1{{ '${key}' | translate }}$2`);
40
+ // attribute: [attr]="'Some Text'" => [attr]="'key' | translate"
41
+ const attrRe = new RegExp(`\\[(?:placeholder|label|title|alt)\\]="'${escaped}'"`, 'g');
42
+ transformedContent = transformedContent.replace(
43
+ attrRe,
44
+ `[placeholder]="'${key}' | translate"`
45
+ );
46
+ } else {
47
+ // TS file: 'Some Text' => this.translateService.instant('key')
48
+ const tsRe = new RegExp(`'${escaped}'`, 'g');
49
+ transformedContent = transformedContent.replace(
50
+ tsRe,
51
+ `this.translateService.instant('${key}')`
52
+ );
53
+ }
54
+ replacedTexts++;
55
+ }
56
+
57
+ if (replacedTexts === 0) return this.unchanged();
58
+
59
+ return {
60
+ filePath: this.filePath,
61
+ originalContent: this.content,
62
+ transformedContent,
63
+ changed: true,
64
+ injectedImport: false,
65
+ injectedHook: false,
66
+ replacedTexts,
67
+ };
68
+ }
69
+
70
+ private unchanged(): CodemodResult {
71
+ return {
72
+ filePath: this.filePath,
73
+ originalContent: this.content,
74
+ transformedContent: this.content,
75
+ changed: false,
76
+ injectedImport: false,
77
+ injectedHook: false,
78
+ replacedTexts: 0,
79
+ };
80
+ }
81
+ }
82
+
83
+ export function applyAngularCodemod(
84
+ filePath: string,
85
+ texts: DetectedText[],
86
+ dryRun = false
87
+ ): CodemodResult {
88
+ const content = fs.readFileSync(filePath, 'utf-8');
89
+ const codemod = new AngularCodemod(filePath, content, texts);
90
+ const result = codemod.transform();
91
+ if (result.changed && !dryRun) {
92
+ fs.writeFileSync(filePath, result.transformedContent, 'utf-8');
93
+ }
94
+ return result;
95
+ }