ai-localize-codemods 2.0.3 → 2.0.5

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/package.json CHANGED
@@ -1,10 +1,15 @@
1
1
  {
2
2
  "name": "ai-localize-codemods",
3
- "version": "2.0.3",
3
+ "version": "2.0.5",
4
4
  "description": "AST-based codemods to inject i18n wrappers into frontend source code",
5
5
  "main": "./dist/index.js",
6
6
  "module": "./dist/index.mjs",
7
7
  "types": "./dist/index.d.ts",
8
+ "files": [
9
+ "dist",
10
+ "README.md",
11
+ "CHANGELOG.md"
12
+ ],
8
13
  "exports": {
9
14
  ".": {
10
15
  "types": "./dist/index.d.ts",
@@ -12,6 +17,23 @@
12
17
  "require": "./dist/index.js"
13
18
  }
14
19
  },
20
+ "keywords": [
21
+ "i18n",
22
+ "localization",
23
+ "l10n",
24
+ "internationalization",
25
+ "ai-localize",
26
+ "codemod",
27
+ "ast",
28
+ "babel",
29
+ "jscodeshift",
30
+ "react",
31
+ "vue",
32
+ "angular"
33
+ ],
34
+ "engines": {
35
+ "node": ">=18.0.0"
36
+ },
15
37
  "dependencies": {
16
38
  "@babel/parser": "^7.23.9",
17
39
  "@babel/traverse": "^7.23.9",
@@ -19,7 +41,7 @@
19
41
  "@babel/generator": "^7.23.9",
20
42
  "recast": "^0.23.4",
21
43
  "jscodeshift": "^0.15.2",
22
- "ai-localize-shared": "2.0.3"
44
+ "ai-localize-shared": "2.0.5"
23
45
  },
24
46
  "devDependencies": {
25
47
  "@types/babel__generator": "^7.6.8",
@@ -1,112 +0,0 @@
1
- import * as fs from 'fs';
2
-
3
- import type { DetectedText, CodemodConfig } from 'ai-localize-shared';
4
- import type { CodemodResult } from './react-codemod.js';
5
-
6
- const DEFAULT_TS_SERVICE = 'this.translateService.instant';
7
- const DEFAULT_TEMPLATE_PIPE = 'translate';
8
-
9
- /**
10
- * Angular ngx-translate codemod:
11
- * - In templates: wraps text with {{ 'key' | translate }}
12
- * - In TS: replaces string literals with this.translateService.instant('key')
13
- *
14
- * The pipe name used in templates can be overridden via
15
- * `codemods.translationFunction` (default: "translate").
16
- * The TS service call can be overridden via `codemods.hookName`
17
- * (default: "this.translateService.instant").
18
- */
19
- export class AngularCodemod {
20
- private filePath: string;
21
- private content: string;
22
- private texts: DetectedText[];
23
- private templatePipe: string;
24
- private tsServiceCall: string;
25
-
26
- constructor(filePath: string, content: string, texts: DetectedText[], codemodConfig?: CodemodConfig) {
27
- this.filePath = filePath;
28
- this.content = content;
29
- this.texts = texts;
30
- // translationFunction maps to the Angular template pipe name
31
- this.templatePipe = codemodConfig?.translationFunction ?? DEFAULT_TEMPLATE_PIPE;
32
- // hookName maps to the TS service call expression
33
- this.tsServiceCall = codemodConfig?.hookName ?? DEFAULT_TS_SERVICE;
34
- }
35
-
36
- transform(): CodemodResult {
37
- if (this.texts.length === 0) return this.unchanged();
38
-
39
- const textKeyMap = new Map<string, string>();
40
- for (const dt of this.texts) {
41
- textKeyMap.set(dt.text, dt.suggestedKey);
42
- }
43
-
44
- let transformedContent = this.content;
45
- let replacedTexts = 0;
46
- const isTemplate = this.filePath.endsWith('.html');
47
- const pipe = this.templatePipe;
48
- const svc = this.tsServiceCall;
49
-
50
- for (const [text, key] of textKeyMap) {
51
- const escaped = text.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
52
- if (isTemplate) {
53
- // Template: >Some Text< => >{{ 'key' | translate }}<
54
- const re = new RegExp(`(>\\s*)${escaped}(\\s*<)`, 'g');
55
- transformedContent = transformedContent.replace(re, `$1{{ '${key}' | ${pipe} }}$2`);
56
- // attribute: [attr]="'Some Text'" => [attr]="'key' | translate"
57
- const attrRe = new RegExp(`\\[(?:placeholder|label|title|alt)\\]="'${escaped}'"`, 'g');
58
- transformedContent = transformedContent.replace(
59
- attrRe,
60
- `[placeholder]="'${key}' | ${pipe}"`
61
- );
62
- } else {
63
- // TS file: 'Some Text' => this.translateService.instant('key')
64
- const tsRe = new RegExp(`'${escaped}'`, 'g');
65
- transformedContent = transformedContent.replace(
66
- tsRe,
67
- `${svc}('${key}')`
68
- );
69
- }
70
- replacedTexts++;
71
- }
72
-
73
- if (replacedTexts === 0) return this.unchanged();
74
-
75
- return {
76
- filePath: this.filePath,
77
- originalContent: this.content,
78
- transformedContent,
79
- changed: true,
80
- injectedImport: false,
81
- injectedHook: false,
82
- replacedTexts,
83
- };
84
- }
85
-
86
- private unchanged(): CodemodResult {
87
- return {
88
- filePath: this.filePath,
89
- originalContent: this.content,
90
- transformedContent: this.content,
91
- changed: false,
92
- injectedImport: false,
93
- injectedHook: false,
94
- replacedTexts: 0,
95
- };
96
- }
97
- }
98
-
99
- export function applyAngularCodemod(
100
- filePath: string,
101
- texts: DetectedText[],
102
- dryRun = false,
103
- codemodConfig?: CodemodConfig
104
- ): CodemodResult {
105
- const content = fs.readFileSync(filePath, 'utf-8');
106
- const codemod = new AngularCodemod(filePath, content, texts, codemodConfig);
107
- const result = codemod.transform();
108
- if (result.changed && !dryRun) {
109
- fs.writeFileSync(filePath, result.transformedContent, 'utf-8');
110
- }
111
- return result;
112
- }
@@ -1,102 +0,0 @@
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
- }
@@ -1,132 +0,0 @@
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
- *
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"`
37
- */
38
- export class CodemodRunner {
39
- private config: LocalizationConfig;
40
- private cwd: string;
41
-
42
- constructor(config: LocalizationConfig, cwd = process.cwd()) {
43
- this.config = config;
44
- this.cwd = cwd;
45
- }
46
-
47
- async run(
48
- detectedTexts: DetectedText[],
49
- options: CodemodRunOptions = {}
50
- ): Promise<CodemodRunSummary> {
51
- const startTime = Date.now();
52
- const { dryRun = false, onProgress } = options;
53
-
54
- // Group texts by file
55
- const fileTextMap = new Map<string, DetectedText[]>();
56
- for (const dt of detectedTexts) {
57
- const existing = fileTextMap.get(dt.filePath) || [];
58
- existing.push(dt);
59
- fileTextMap.set(dt.filePath, existing);
60
- }
61
-
62
- const results: CodemodResult[] = [];
63
- let totalReplacements = 0;
64
- let importInjections = 0;
65
- let hookInjections = 0;
66
-
67
- for (const [filePath, texts] of fileTextMap) {
68
- let result: CodemodResult;
69
- const framework = this.config.framework;
70
-
71
- try {
72
- result = this.applyCodemod(filePath, texts, framework, dryRun);
73
- } catch (err) {
74
- result = {
75
- filePath,
76
- originalContent: '',
77
- transformedContent: '',
78
- changed: false,
79
- injectedImport: false,
80
- injectedHook: false,
81
- replacedTexts: 0,
82
- };
83
- }
84
-
85
- results.push(result);
86
- totalReplacements += result.replacedTexts;
87
- if (result.injectedImport) importInjections++;
88
- if (result.injectedHook) hookInjections++;
89
-
90
- onProgress?.(filePath, result);
91
- }
92
-
93
- return {
94
- totalFiles: fileTextMap.size,
95
- changedFiles: results.filter((r) => r.changed).length,
96
- totalReplacements,
97
- importInjections,
98
- hookInjections,
99
- results,
100
- duration: Date.now() - startTime,
101
- };
102
- }
103
-
104
- private applyCodemod(
105
- filePath: string,
106
- texts: DetectedText[],
107
- framework: Framework,
108
- dryRun: boolean
109
- ): CodemodResult {
110
- const ext = path.extname(filePath).toLowerCase();
111
- const codemodConfig = this.config.codemods;
112
-
113
- // Vue SFCs
114
- if (ext === '.vue') {
115
- return applyVueCodemod(filePath, texts, dryRun, codemodConfig);
116
- }
117
-
118
- // Angular templates or TS
119
- if (
120
- framework === 'angular' ||
121
- framework === 'angular-ngx' ||
122
- framework === 'angular-i18n'
123
- ) {
124
- return applyAngularCodemod(filePath, texts, dryRun, codemodConfig);
125
- }
126
-
127
- // React (default for .tsx, .ts, .jsx, .js)
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);
131
- }
132
- }
package/src/index.ts DELETED
@@ -1,5 +0,0 @@
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';
@@ -1,416 +0,0 @@
1
- import * as fs from 'fs';
2
- import * as nodePath from 'path';
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
-
8
- import type { DetectedText, CodemodConfig } from 'ai-localize-shared';
9
-
10
- export interface CodemodResult {
11
- filePath: string;
12
- originalContent: string;
13
- transformedContent: string;
14
- changed: boolean;
15
- injectedImport: boolean;
16
- injectedHook: boolean;
17
- replacedTexts: number;
18
- }
19
-
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';
24
-
25
- /**
26
- * React i18n codemod: wraps hardcoded JSX text / string literals with t() or t[''] calls.
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']
42
- */
43
- export class ReactCodemod {
44
- private filePath: string;
45
- private content: string;
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';
53
-
54
- constructor(
55
- filePath: string,
56
- content: string,
57
- texts: DetectedText[],
58
- codemodConfig?: CodemodConfig,
59
- cwd = process.cwd()
60
- ) {
61
- this.filePath = filePath;
62
- this.content = content;
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
- }
73
-
74
- transform(): CodemodResult {
75
- if (this.texts.length === 0) {
76
- return {
77
- filePath: this.filePath,
78
- originalContent: this.content,
79
- transformedContent: this.content,
80
- changed: false,
81
- injectedImport: false,
82
- injectedHook: false,
83
- replacedTexts: 0,
84
- };
85
- }
86
-
87
- let ast: t.File;
88
- try {
89
- ast = parser.parse(this.content, {
90
- sourceType: 'module',
91
- plugins: [
92
- 'jsx',
93
- 'typescript',
94
- 'decorators-legacy',
95
- 'classProperties',
96
- 'optionalChaining',
97
- 'nullishCoalescingOperator',
98
- ],
99
- errorRecovery: true,
100
- });
101
- } catch {
102
- return {
103
- filePath: this.filePath,
104
- originalContent: this.content,
105
- transformedContent: this.content,
106
- changed: false,
107
- injectedImport: false,
108
- injectedHook: false,
109
- replacedTexts: 0,
110
- };
111
- }
112
-
113
- // Build a map of text -> key for fast lookup
114
- const textKeyMap = new Map<string, string>();
115
- for (const dt of this.texts) {
116
- textKeyMap.set(dt.text, dt.suggestedKey);
117
- }
118
-
119
- let replacedTexts = 0;
120
- let hasImport = false;
121
- let hasHook = false;
122
- let injectedImport = false;
123
- let injectedHook = false;
124
-
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
- );
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
- }
152
- },
153
- VariableDeclarator(nodePath) {
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
- )
163
- ) {
164
- hasHook = true;
165
- }
166
- },
167
- });
168
-
169
- // Second pass: replace hardcoded texts
170
- traverse(ast, {
171
- JSXText(nodePath) {
172
- const text = nodePath.node.value.trim();
173
- if (!text) return;
174
- const key = textKeyMap.get(text);
175
- if (!key) return;
176
- nodePath.replaceWith(t.jsxExpressionContainer(buildTranslationExpr(key)));
177
- replacedTexts++;
178
- },
179
-
180
- StringLiteral(nodePath) {
181
- const text = nodePath.node.value;
182
- const key = textKeyMap.get(text);
183
- if (!key) return;
184
- if (t.isImportDeclaration(nodePath.parent)) return;
185
- if (t.isObjectProperty(nodePath.parent) && nodePath.parent.key === nodePath.node) return;
186
- if (t.isJSXAttribute(nodePath.parent)) return;
187
- nodePath.replaceWith(buildTranslationExpr(key));
188
- replacedTexts++;
189
- },
190
-
191
- JSXAttribute(nodePath) {
192
- const valueNode = nodePath.node.value;
193
- if (!t.isStringLiteral(valueNode)) return;
194
- const text = valueNode.value;
195
- const key = textKeyMap.get(text);
196
- if (!key) return;
197
- nodePath.node.value = t.jsxExpressionContainer(buildTranslationExpr(key));
198
- replacedTexts++;
199
- },
200
- });
201
-
202
- if (replacedTexts === 0) {
203
- return {
204
- filePath: this.filePath,
205
- originalContent: this.content,
206
- transformedContent: this.content,
207
- changed: false,
208
- injectedImport: false,
209
- injectedHook: false,
210
- replacedTexts: 0,
211
- };
212
- }
213
-
214
- // Inject import if needed (uses the per-file resolved import specifier)
215
- if (!hasImport) {
216
- const importDecl = t.importDeclaration(
217
- [t.importSpecifier(t.identifier(hookName), t.identifier(hookName))],
218
- t.stringLiteral(importSpecifier)
219
- );
220
- ast.program.body.unshift(importDecl);
221
- injectedImport = true;
222
- }
223
-
224
- // Inject hook at the top of function components
225
- if (!hasHook) {
226
- this.injectHook(ast);
227
- injectedHook = true;
228
- }
229
-
230
- // Generate output preserving formatting
231
- const output = generate(
232
- ast,
233
- { retainLines: false, comments: true, compact: false },
234
- this.content
235
- );
236
-
237
- return {
238
- filePath: this.filePath,
239
- originalContent: this.content,
240
- transformedContent: output.code,
241
- changed: true,
242
- injectedImport,
243
- injectedHook,
244
- replacedTexts,
245
- };
246
- }
247
-
248
- private injectHook(ast: t.File): void {
249
- const hookName = this.hookName;
250
- const translationFn = this.translationFn;
251
- const namespace = this.namespace;
252
-
253
- traverse(ast, {
254
- FunctionDeclaration(nodePath) {
255
- if (!isReactComponent(nodePath.node.id?.name)) return;
256
- injectHookIntoBody(nodePath.node.body, hookName, translationFn, namespace);
257
- nodePath.stop();
258
- },
259
- ArrowFunctionExpression(nodePath) {
260
- if (!isReactComponentParent(nodePath)) return;
261
- const body = nodePath.node.body;
262
- if (t.isBlockStatement(body)) {
263
- injectHookIntoBody(body, hookName, translationFn, namespace);
264
- nodePath.stop();
265
- }
266
- },
267
- });
268
- }
269
- }
270
-
271
- function isReactComponent(name?: string): boolean {
272
- if (!name) return false;
273
- return /^[A-Z]/.test(name);
274
- }
275
-
276
- // eslint-disable-next-line @typescript-eslint/no-explicit-any
277
- function isReactComponentParent(nodePath: any): boolean {
278
- const parent = nodePath.parent;
279
- if (t.isVariableDeclarator(parent)) {
280
- const id = parent.id;
281
- if (t.isIdentifier(id) && /^[A-Z]/.test(id.name)) return true;
282
- }
283
- return false;
284
- }
285
-
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)] : [];
293
- const hookDecl = t.variableDeclaration('const', [
294
- t.variableDeclarator(
295
- t.objectPattern([
296
- t.objectProperty(t.identifier(translationFn), t.identifier(translationFn), false, true),
297
- ]),
298
- t.callExpression(t.identifier(hookName), hookArgs)
299
- ),
300
- ]);
301
- body.body.unshift(hookDecl);
302
- }
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
-
397
- /**
398
- * Applies the React codemod to a file in-place.
399
- */
400
- export function applyReactCodemod(
401
- filePath: string,
402
- texts: DetectedText[],
403
- dryRun = false,
404
- codemodConfig?: CodemodConfig,
405
- cwd = process.cwd()
406
- ): CodemodResult {
407
- const content = fs.readFileSync(filePath, 'utf-8');
408
- const codemod = new ReactCodemod(filePath, content, texts, codemodConfig, cwd);
409
- const result = codemod.transform();
410
-
411
- if (result.changed && !dryRun) {
412
- fs.writeFileSync(filePath, result.transformedContent, 'utf-8');
413
- }
414
-
415
- return result;
416
- }
@@ -1,106 +0,0 @@
1
- import * as fs from 'fs';
2
-
3
- import type { DetectedText, CodemodConfig } from 'ai-localize-shared';
4
- import type { CodemodResult } from './react-codemod.js';
5
-
6
- const DEFAULT_TRANSLATION_FN = '$t';
7
-
8
- /**
9
- * Vue i18n codemod: wraps hardcoded text with $t() / t() calls in Vue SFCs.
10
- * Handles both Options API (global $t) and Composition API (useI18n / useTranslation).
11
- *
12
- * The translation function name can be overridden via the `codemods.translationFunction`
13
- * config field (default: "$t").
14
- */
15
- export class VueCodemod {
16
- private filePath: string;
17
- private content: string;
18
- private texts: DetectedText[];
19
- private translationFn: string;
20
-
21
- constructor(filePath: string, content: string, texts: DetectedText[], codemodConfig?: CodemodConfig) {
22
- this.filePath = filePath;
23
- this.content = content;
24
- this.texts = texts;
25
- this.translationFn = codemodConfig?.translationFunction ?? DEFAULT_TRANSLATION_FN;
26
- }
27
-
28
- transform(): CodemodResult {
29
- if (this.texts.length === 0) {
30
- return this.unchanged();
31
- }
32
-
33
- // Parse <template> and <script> sections separately
34
- const templateMatch = this.content.match(/<template[^>]*>([\s\S]*?)<\/template>/);
35
- const scriptMatch = this.content.match(/<script[^>]*>([\s\S]*?)<\/script>/);
36
-
37
- if (!templateMatch && !scriptMatch) {
38
- return this.unchanged();
39
- }
40
-
41
- const textKeyMap = new Map<string, string>();
42
- for (const dt of this.texts) {
43
- textKeyMap.set(dt.text, dt.suggestedKey);
44
- }
45
-
46
- let transformedContent = this.content;
47
- let replacedTexts = 0;
48
- const fn = this.translationFn;
49
-
50
- // Replace in template: >text< => >{{ $t('key') }}<
51
- if (templateMatch) {
52
- let template = templateMatch[1];
53
- for (const [text, key] of textKeyMap) {
54
- const escaped = text.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
55
- const re = new RegExp(`(>\\s*)${escaped}(\\s*<)`, 'g');
56
- template = template.replace(re, `$1{{ ${fn}('${key}') }}$2`);
57
- // Also replace in attribute values: title="text" => :title="$t('key')"
58
- const attrRe = new RegExp(`((?:title|placeholder|alt|label)=)"(${escaped})"`, 'g');
59
- template = template.replace(attrRe, `:$1"${fn}('${key}')"`);
60
- replacedTexts++;
61
- }
62
- transformedContent = transformedContent.replace(templateMatch[1], template);
63
- }
64
-
65
- if (replacedTexts === 0) {
66
- return this.unchanged();
67
- }
68
-
69
- return {
70
- filePath: this.filePath,
71
- originalContent: this.content,
72
- transformedContent,
73
- changed: true,
74
- injectedImport: false, // vue-i18n is globally injected
75
- injectedHook: false,
76
- replacedTexts,
77
- };
78
- }
79
-
80
- private unchanged(): CodemodResult {
81
- return {
82
- filePath: this.filePath,
83
- originalContent: this.content,
84
- transformedContent: this.content,
85
- changed: false,
86
- injectedImport: false,
87
- injectedHook: false,
88
- replacedTexts: 0,
89
- };
90
- }
91
- }
92
-
93
- export function applyVueCodemod(
94
- filePath: string,
95
- texts: DetectedText[],
96
- dryRun = false,
97
- codemodConfig?: CodemodConfig
98
- ): CodemodResult {
99
- const content = fs.readFileSync(filePath, 'utf-8');
100
- const codemod = new VueCodemod(filePath, content, texts, codemodConfig);
101
- const result = codemod.transform();
102
- if (result.changed && !dryRun) {
103
- fs.writeFileSync(filePath, result.transformedContent, 'utf-8');
104
- }
105
- return result;
106
- }
package/tsconfig.json DELETED
@@ -1,9 +0,0 @@
1
- {
2
- "extends": "../../tsconfig.base.json",
3
- "compilerOptions": {
4
- "outDir": "./dist",
5
- "rootDir": "./src"
6
- },
7
- "include": ["src/**/*"],
8
- "exclude": ["node_modules", "dist"]
9
- }