ai-localize-scanner 2.0.1 → 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 +21 -0
- package/dist/index.d.mts +17 -6
- package/dist/index.d.ts +17 -6
- package/dist/index.js +12 -9
- package/dist/index.mjs +13 -10
- package/package.json +3 -3
- package/src/ast-scanner.ts +123 -110
- package/src/project-scanner.ts +31 -29
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,26 @@
|
|
|
1
1
|
# ai-localize-scanner
|
|
2
2
|
|
|
3
|
+
## 2.0.3
|
|
4
|
+
|
|
5
|
+
### Patch Changes
|
|
6
|
+
|
|
7
|
+
- Updated dependencies
|
|
8
|
+
- ai-localize-shared@2.0.3
|
|
9
|
+
- ai-localize-config@2.0.3
|
|
10
|
+
|
|
11
|
+
## 2.0.2
|
|
12
|
+
|
|
13
|
+
### Minor Changes
|
|
14
|
+
|
|
15
|
+
- **AST scanner seeds translation-fn names from codemods config** — translation function names configured under `codemods.translationFunction` are now automatically recognised by the AST scanner so already-translated strings are correctly skipped
|
|
16
|
+
|
|
17
|
+
### Patch Changes
|
|
18
|
+
|
|
19
|
+
- **Bug fix — locale key prefix with absolute sourceRoot** — `normalizePath()` now calls `path.relative()` when `sourceRoot` is an absolute path; `ProjectScanner` passes the resolved absolute `sourceRoot` to `AstScanner`, preventing ancestor path segments leaking into generated locale keys
|
|
20
|
+
- Updated dependencies
|
|
21
|
+
- ai-localize-config@2.0.2
|
|
22
|
+
- ai-localize-shared@2.0.2
|
|
23
|
+
|
|
3
24
|
## 2.0.1
|
|
4
25
|
|
|
5
26
|
### Patch Changes
|
package/dist/index.d.mts
CHANGED
|
@@ -1,9 +1,20 @@
|
|
|
1
|
-
import { CodemodConfig, DetectedText, AssetReference, LegacyCdnUrl, LocalizationConfig, ScanResult } from 'ai-localize-shared';
|
|
1
|
+
import { KeyStyle, CodemodConfig, DetectedText, AssetReference, LegacyCdnUrl, LocalizationConfig, ScanResult } from 'ai-localize-shared';
|
|
2
2
|
|
|
3
3
|
interface AstScanOptions {
|
|
4
4
|
filePath: string;
|
|
5
5
|
content: string;
|
|
6
6
|
sourceRoot?: string;
|
|
7
|
+
/**
|
|
8
|
+
* Controls the format of the generated locale key for each detected text.
|
|
9
|
+
*
|
|
10
|
+
* - `"path"` (default) — hierarchical dot-notation key derived from file path + text:
|
|
11
|
+
* `settings.settings_page.save_changes`
|
|
12
|
+
*
|
|
13
|
+
* - `"screaming_snake"` — UPPER_SNAKE_CASE key derived solely from the text value:
|
|
14
|
+
* "Save Changes" → `SAVE_CHANGES`
|
|
15
|
+
* "Max Count" → `MAX_COUNT`
|
|
16
|
+
*/
|
|
17
|
+
keyStyle?: KeyStyle;
|
|
7
18
|
/**
|
|
8
19
|
* Optional codemod config from ai-localize.config.json.
|
|
9
20
|
*
|
|
@@ -13,7 +24,7 @@ interface AstScanOptions {
|
|
|
13
24
|
* importPackage — matched against import source strings. Supports:
|
|
14
25
|
* - npm package names: "react-i18next", "my-i18n-lib"
|
|
15
26
|
* - path aliases: "@/hooks/useTranslation", "@/i18n"
|
|
16
|
-
*
|
|
27
|
+
* - relative paths: "../../hooks/useTranslation"
|
|
17
28
|
* Matching is done by checking whether the import source equals the value
|
|
18
29
|
* OR ends with the last path segment(s) of the value (normalised).
|
|
19
30
|
*
|
|
@@ -37,10 +48,10 @@ declare class AstScanner {
|
|
|
37
48
|
/** Identifiers whose call/bracket expressions contain already-translated strings. */
|
|
38
49
|
private translationFunctionNames;
|
|
39
50
|
/**
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
51
|
+
* Import source matchers.
|
|
52
|
+
* Each entry is either an exact string (npm package) or a path-suffix matcher
|
|
53
|
+
* function built from a relative/alias path.
|
|
54
|
+
*/
|
|
44
55
|
private importSourceMatchers;
|
|
45
56
|
constructor(options: AstScanOptions);
|
|
46
57
|
scan(): DetectedText[];
|
package/dist/index.d.ts
CHANGED
|
@@ -1,9 +1,20 @@
|
|
|
1
|
-
import { CodemodConfig, DetectedText, AssetReference, LegacyCdnUrl, LocalizationConfig, ScanResult } from 'ai-localize-shared';
|
|
1
|
+
import { KeyStyle, CodemodConfig, DetectedText, AssetReference, LegacyCdnUrl, LocalizationConfig, ScanResult } from 'ai-localize-shared';
|
|
2
2
|
|
|
3
3
|
interface AstScanOptions {
|
|
4
4
|
filePath: string;
|
|
5
5
|
content: string;
|
|
6
6
|
sourceRoot?: string;
|
|
7
|
+
/**
|
|
8
|
+
* Controls the format of the generated locale key for each detected text.
|
|
9
|
+
*
|
|
10
|
+
* - `"path"` (default) — hierarchical dot-notation key derived from file path + text:
|
|
11
|
+
* `settings.settings_page.save_changes`
|
|
12
|
+
*
|
|
13
|
+
* - `"screaming_snake"` — UPPER_SNAKE_CASE key derived solely from the text value:
|
|
14
|
+
* "Save Changes" → `SAVE_CHANGES`
|
|
15
|
+
* "Max Count" → `MAX_COUNT`
|
|
16
|
+
*/
|
|
17
|
+
keyStyle?: KeyStyle;
|
|
7
18
|
/**
|
|
8
19
|
* Optional codemod config from ai-localize.config.json.
|
|
9
20
|
*
|
|
@@ -13,7 +24,7 @@ interface AstScanOptions {
|
|
|
13
24
|
* importPackage — matched against import source strings. Supports:
|
|
14
25
|
* - npm package names: "react-i18next", "my-i18n-lib"
|
|
15
26
|
* - path aliases: "@/hooks/useTranslation", "@/i18n"
|
|
16
|
-
*
|
|
27
|
+
* - relative paths: "../../hooks/useTranslation"
|
|
17
28
|
* Matching is done by checking whether the import source equals the value
|
|
18
29
|
* OR ends with the last path segment(s) of the value (normalised).
|
|
19
30
|
*
|
|
@@ -37,10 +48,10 @@ declare class AstScanner {
|
|
|
37
48
|
/** Identifiers whose call/bracket expressions contain already-translated strings. */
|
|
38
49
|
private translationFunctionNames;
|
|
39
50
|
/**
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
51
|
+
* Import source matchers.
|
|
52
|
+
* Each entry is either an exact string (npm package) or a path-suffix matcher
|
|
53
|
+
* function built from a relative/alias path.
|
|
54
|
+
*/
|
|
44
55
|
private importSourceMatchers;
|
|
45
56
|
constructor(options: AstScanOptions);
|
|
46
57
|
scan(): DetectedText[];
|
package/dist/index.js
CHANGED
|
@@ -55,10 +55,10 @@ var AstScanner = class {
|
|
|
55
55
|
/** Identifiers whose call/bracket expressions contain already-translated strings. */
|
|
56
56
|
translationFunctionNames;
|
|
57
57
|
/**
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
58
|
+
* Import source matchers.
|
|
59
|
+
* Each entry is either an exact string (npm package) or a path-suffix matcher
|
|
60
|
+
* function built from a relative/alias path.
|
|
61
|
+
*/
|
|
62
62
|
importSourceMatchers;
|
|
63
63
|
constructor(options) {
|
|
64
64
|
this.options = options;
|
|
@@ -221,10 +221,11 @@ var AstScanner = class {
|
|
|
221
221
|
return false;
|
|
222
222
|
}
|
|
223
223
|
addDetected(text, line, column, context, nodeType) {
|
|
224
|
-
const key = (0, import_ai_localize_shared.
|
|
224
|
+
const key = (0, import_ai_localize_shared.generateKeyByStyle)(
|
|
225
225
|
this.options.filePath,
|
|
226
226
|
text,
|
|
227
|
-
this.options.sourceRoot || "src"
|
|
227
|
+
this.options.sourceRoot || "src",
|
|
228
|
+
this.options.keyStyle || "path"
|
|
228
229
|
);
|
|
229
230
|
this.detectedTexts.push({
|
|
230
231
|
filePath: this.options.filePath,
|
|
@@ -255,10 +256,11 @@ var AstScanner = class {
|
|
|
255
256
|
while ((m = jsxTextRegex.exec(line)) !== null) {
|
|
256
257
|
const text = (0, import_ai_localize_shared.normalizeText)(m[1]);
|
|
257
258
|
if (!(0, import_ai_localize_shared.isHumanReadableText)(text)) continue;
|
|
258
|
-
const key = (0, import_ai_localize_shared.
|
|
259
|
+
const key = (0, import_ai_localize_shared.generateKeyByStyle)(
|
|
259
260
|
this.options.filePath,
|
|
260
261
|
text,
|
|
261
|
-
this.options.sourceRoot || "src"
|
|
262
|
+
this.options.sourceRoot || "src",
|
|
263
|
+
this.options.keyStyle || "path"
|
|
262
264
|
);
|
|
263
265
|
results.push({
|
|
264
266
|
filePath: this.options.filePath,
|
|
@@ -525,7 +527,8 @@ var ProjectScanner = class {
|
|
|
525
527
|
filePath,
|
|
526
528
|
content,
|
|
527
529
|
sourceRoot: this.sourceRoot,
|
|
528
|
-
codemodConfig: this.config.codemods
|
|
530
|
+
codemodConfig: this.config.codemods,
|
|
531
|
+
keyStyle: this.config.keyStyle ?? "path"
|
|
529
532
|
});
|
|
530
533
|
const texts = scanner.scan();
|
|
531
534
|
const { assets, legacyCdnUrls } = this.assetScanner.scanFile(filePath);
|
package/dist/index.mjs
CHANGED
|
@@ -6,7 +6,7 @@ import {
|
|
|
6
6
|
isHumanReadableText,
|
|
7
7
|
normalizeText,
|
|
8
8
|
TEXT_ATTRIBUTE_NAMES,
|
|
9
|
-
|
|
9
|
+
generateKeyByStyle
|
|
10
10
|
} from "ai-localize-shared";
|
|
11
11
|
var BUILTIN_TRANSLATION_IMPORT_SOURCES = /* @__PURE__ */ new Set([
|
|
12
12
|
"react-i18next",
|
|
@@ -20,10 +20,10 @@ var AstScanner = class {
|
|
|
20
20
|
/** Identifiers whose call/bracket expressions contain already-translated strings. */
|
|
21
21
|
translationFunctionNames;
|
|
22
22
|
/**
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
23
|
+
* Import source matchers.
|
|
24
|
+
* Each entry is either an exact string (npm package) or a path-suffix matcher
|
|
25
|
+
* function built from a relative/alias path.
|
|
26
|
+
*/
|
|
27
27
|
importSourceMatchers;
|
|
28
28
|
constructor(options) {
|
|
29
29
|
this.options = options;
|
|
@@ -186,10 +186,11 @@ var AstScanner = class {
|
|
|
186
186
|
return false;
|
|
187
187
|
}
|
|
188
188
|
addDetected(text, line, column, context, nodeType) {
|
|
189
|
-
const key =
|
|
189
|
+
const key = generateKeyByStyle(
|
|
190
190
|
this.options.filePath,
|
|
191
191
|
text,
|
|
192
|
-
this.options.sourceRoot || "src"
|
|
192
|
+
this.options.sourceRoot || "src",
|
|
193
|
+
this.options.keyStyle || "path"
|
|
193
194
|
);
|
|
194
195
|
this.detectedTexts.push({
|
|
195
196
|
filePath: this.options.filePath,
|
|
@@ -220,10 +221,11 @@ var AstScanner = class {
|
|
|
220
221
|
while ((m = jsxTextRegex.exec(line)) !== null) {
|
|
221
222
|
const text = normalizeText(m[1]);
|
|
222
223
|
if (!isHumanReadableText(text)) continue;
|
|
223
|
-
const key =
|
|
224
|
+
const key = generateKeyByStyle(
|
|
224
225
|
this.options.filePath,
|
|
225
226
|
text,
|
|
226
|
-
this.options.sourceRoot || "src"
|
|
227
|
+
this.options.sourceRoot || "src",
|
|
228
|
+
this.options.keyStyle || "path"
|
|
227
229
|
);
|
|
228
230
|
results.push({
|
|
229
231
|
filePath: this.options.filePath,
|
|
@@ -490,7 +492,8 @@ var ProjectScanner = class {
|
|
|
490
492
|
filePath,
|
|
491
493
|
content,
|
|
492
494
|
sourceRoot: this.sourceRoot,
|
|
493
|
-
codemodConfig: this.config.codemods
|
|
495
|
+
codemodConfig: this.config.codemods,
|
|
496
|
+
keyStyle: this.config.keyStyle ?? "path"
|
|
494
497
|
});
|
|
495
498
|
const texts = scanner.scan();
|
|
496
499
|
const { assets, legacyCdnUrls } = this.assetScanner.scanFile(filePath);
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "ai-localize-scanner",
|
|
3
|
-
"version": "2.0.
|
|
3
|
+
"version": "2.0.3",
|
|
4
4
|
"description": "AST-based hardcoded text scanner for frontend applications",
|
|
5
5
|
"main": "./dist/index.js",
|
|
6
6
|
"module": "./dist/index.mjs",
|
|
@@ -17,8 +17,8 @@
|
|
|
17
17
|
"@babel/traverse": "^7.23.9",
|
|
18
18
|
"@babel/types": "^7.23.9",
|
|
19
19
|
"glob": "^10.3.10",
|
|
20
|
-
"ai-localize-shared": "2.0.
|
|
21
|
-
"ai-localize-config": "2.0.
|
|
20
|
+
"ai-localize-shared": "2.0.3",
|
|
21
|
+
"ai-localize-config": "2.0.3"
|
|
22
22
|
},
|
|
23
23
|
"devDependencies": {
|
|
24
24
|
"@types/babel__traverse": "^7.20.5",
|
package/src/ast-scanner.ts
CHANGED
|
@@ -2,18 +2,29 @@ import * as parser from '@babel/parser';
|
|
|
2
2
|
import traverse from '@babel/traverse';
|
|
3
3
|
import * as t from '@babel/types';
|
|
4
4
|
|
|
5
|
-
import type { DetectedText, TextContext, CodemodConfig } from 'ai-localize-shared';
|
|
5
|
+
import type { DetectedText, TextContext, CodemodConfig, KeyStyle } from 'ai-localize-shared';
|
|
6
6
|
import {
|
|
7
7
|
isHumanReadableText,
|
|
8
8
|
normalizeText,
|
|
9
9
|
TEXT_ATTRIBUTE_NAMES,
|
|
10
|
-
|
|
10
|
+
generateKeyByStyle,
|
|
11
11
|
} from 'ai-localize-shared';
|
|
12
12
|
|
|
13
13
|
export interface AstScanOptions {
|
|
14
14
|
filePath: string;
|
|
15
15
|
content: string;
|
|
16
16
|
sourceRoot?: string;
|
|
17
|
+
/**
|
|
18
|
+
* Controls the format of the generated locale key for each detected text.
|
|
19
|
+
*
|
|
20
|
+
* - `"path"` (default) — hierarchical dot-notation key derived from file path + text:
|
|
21
|
+
* `settings.settings_page.save_changes`
|
|
22
|
+
*
|
|
23
|
+
* - `"screaming_snake"` — UPPER_SNAKE_CASE key derived solely from the text value:
|
|
24
|
+
* "Save Changes" → `SAVE_CHANGES`
|
|
25
|
+
* "Max Count" → `MAX_COUNT`
|
|
26
|
+
*/
|
|
27
|
+
keyStyle?: KeyStyle;
|
|
17
28
|
/**
|
|
18
29
|
* Optional codemod config from ai-localize.config.json.
|
|
19
30
|
*
|
|
@@ -23,7 +34,7 @@ export interface AstScanOptions {
|
|
|
23
34
|
* importPackage — matched against import source strings. Supports:
|
|
24
35
|
* - npm package names: "react-i18next", "my-i18n-lib"
|
|
25
36
|
* - path aliases: "@/hooks/useTranslation", "@/i18n"
|
|
26
|
-
*
|
|
37
|
+
* - relative paths: "../../hooks/useTranslation"
|
|
27
38
|
* Matching is done by checking whether the import source equals the value
|
|
28
39
|
* OR ends with the last path segment(s) of the value (normalised).
|
|
29
40
|
*
|
|
@@ -44,7 +55,7 @@ const BUILTIN_TRANSLATION_IMPORT_SOURCES = new Set([
|
|
|
44
55
|
'react-i18next',
|
|
45
56
|
'i18next',
|
|
46
57
|
'vue-i18n',
|
|
47
|
-
'@ngx-translate/core',
|
|
58
|
+
'@ngx-translate/core',
|
|
48
59
|
]);
|
|
49
60
|
|
|
50
61
|
/**
|
|
@@ -57,7 +68,7 @@ export class AstScanner {
|
|
|
57
68
|
/** Identifiers whose call/bracket expressions contain already-translated strings. */
|
|
58
69
|
private translationFunctionNames: Set<string>;
|
|
59
70
|
|
|
60
|
-
/**
|
|
71
|
+
/**
|
|
61
72
|
* Import source matchers.
|
|
62
73
|
* Each entry is either an exact string (npm package) or a path-suffix matcher
|
|
63
74
|
* function built from a relative/alias path.
|
|
@@ -84,13 +95,13 @@ export class AstScanner {
|
|
|
84
95
|
|
|
85
96
|
// hookName — seed directly so the hook identifier is always recognised
|
|
86
97
|
// regardless of how it is imported (named, default, aliased, re-exported).
|
|
87
|
-
|
|
98
|
+
if (cc.hookName) {
|
|
88
99
|
this.translationFunctionNames.add(cc.hookName);
|
|
89
100
|
}
|
|
90
101
|
|
|
91
102
|
// importPackage — build a matcher that handles:
|
|
92
103
|
// 1. exact npm package name "react-i18next"
|
|
93
|
-
// 2. path alias
|
|
104
|
+
// 2. path alias"@/hooks/useTranslation"
|
|
94
105
|
// 3. relative path "../../hooks/useTranslation"
|
|
95
106
|
if (cc.importPackage) {
|
|
96
107
|
this.importSourceMatchers.push(buildImportMatcher(cc.importPackage));
|
|
@@ -101,23 +112,23 @@ export class AstScanner {
|
|
|
101
112
|
scan(): DetectedText[] {
|
|
102
113
|
const { content } = this.options;
|
|
103
114
|
|
|
104
|
-
|
|
115
|
+
let ast: t.File;
|
|
105
116
|
try {
|
|
106
117
|
ast = parser.parse(content, {
|
|
107
|
-
|
|
118
|
+
sourceType: 'module',
|
|
108
119
|
plugins: [
|
|
109
120
|
'jsx',
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
121
|
+
'typescript',
|
|
122
|
+
'decorators-legacy',
|
|
123
|
+
'classProperties',
|
|
124
|
+
'optionalChaining',
|
|
125
|
+
'nullishCoalescingOperator',
|
|
126
|
+
'dynamicImport',
|
|
127
|
+
'exportDefaultFrom',
|
|
117
128
|
],
|
|
118
129
|
errorRecovery: true,
|
|
119
130
|
});
|
|
120
|
-
|
|
131
|
+
} catch {
|
|
121
132
|
return this.regexFallbackScan();
|
|
122
133
|
}
|
|
123
134
|
|
|
@@ -126,89 +137,89 @@ export class AstScanner {
|
|
|
126
137
|
|
|
127
138
|
traverse(ast, {
|
|
128
139
|
JSXText: (nodePath) => {
|
|
129
|
-
|
|
140
|
+
const text = normalizeText(nodePath.node.value);
|
|
130
141
|
if (!isHumanReadableText(text)) return;
|
|
131
142
|
if (this.isInsideTranslationCall(nodePath)) return;
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
143
|
+
this.addDetected(
|
|
144
|
+
text,
|
|
145
|
+
nodePath.node.loc?.start.line ?? 0,
|
|
146
|
+
nodePath.node.loc?.start.column ?? 0,
|
|
147
|
+
'jsx-text',
|
|
148
|
+
'JSXText'
|
|
149
|
+
);
|
|
139
150
|
},
|
|
140
151
|
|
|
141
|
-
|
|
152
|
+
JSXAttribute: (nodePath) => {
|
|
142
153
|
const attrName = t.isJSXIdentifier(nodePath.node.name)
|
|
143
|
-
|
|
154
|
+
? nodePath.node.name.name
|
|
144
155
|
: '';
|
|
145
156
|
if (!TEXT_ATTRIBUTE_NAMES.has(attrName.toLowerCase())) return;
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
const text = normalizeText(valueNode.value);
|
|
157
|
+
const valueNode = nodePath.node.value;
|
|
158
|
+
if (!t.isStringLiteral(valueNode)) return;
|
|
159
|
+
const text = normalizeText(valueNode.value);
|
|
149
160
|
if (!isHumanReadableText(text)) return;
|
|
150
161
|
if (this.isInsideTranslationCall(nodePath)) return;
|
|
151
162
|
const context = this.mapAttrToContext(attrName);
|
|
152
|
-
|
|
153
|
-
|
|
163
|
+
this.addDetected(
|
|
164
|
+
text,
|
|
154
165
|
valueNode.loc?.start.line ?? 0,
|
|
155
|
-
|
|
156
|
-
|
|
166
|
+
valueNode.loc?.start.column ?? 0,
|
|
167
|
+
context,
|
|
157
168
|
'JSXAttribute'
|
|
158
169
|
);
|
|
159
170
|
},
|
|
160
171
|
|
|
161
172
|
StringLiteral: (nodePath) => {
|
|
162
|
-
if (t.isImportDeclaration(nodePath.parent)) return;
|
|
173
|
+
if (t.isImportDeclaration(nodePath.parent)) return;
|
|
163
174
|
if (t.isObjectProperty(nodePath.parent) && nodePath.parent.key === nodePath.node) return;
|
|
164
175
|
|
|
165
176
|
// Ignore className and similar CSS-class attributes
|
|
166
177
|
if (t.isJSXAttribute(nodePath.parent)) {
|
|
167
178
|
const attrName = t.isJSXIdentifier(nodePath.parent.name)
|
|
168
|
-
|
|
179
|
+
? nodePath.parent.name.name.toLowerCase()
|
|
169
180
|
: '';
|
|
170
|
-
|
|
171
|
-
|
|
181
|
+
if (attrName === 'classname' || attrName === 'class') return;
|
|
182
|
+
return;
|
|
172
183
|
}
|
|
173
184
|
|
|
174
185
|
if (this.isInsideTranslationCall(nodePath)) return;
|
|
175
186
|
|
|
176
187
|
const val = nodePath.node.value;
|
|
177
188
|
if (
|
|
178
|
-
|
|
189
|
+
/^[a-z][a-z0-9_.-]*$/.test(val) ||
|
|
179
190
|
/^#?[0-9a-fA-F]+$/.test(val) ||
|
|
180
191
|
(/^[\w-]+\s[\w- ]+$/.test(val) && !val.includes(','))
|
|
181
|
-
|
|
182
|
-
|
|
192
|
+
) {
|
|
193
|
+
return; // Likely a CSS class, ID, hex colour, or plain word pair
|
|
183
194
|
}
|
|
184
195
|
|
|
185
196
|
const text = normalizeText(nodePath.node.value);
|
|
186
|
-
|
|
197
|
+
if (!isHumanReadableText(text)) return;
|
|
187
198
|
this.addDetected(
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
199
|
+
text,
|
|
200
|
+
nodePath.node.loc?.start.line ?? 0,
|
|
201
|
+
nodePath.node.loc?.start.column ?? 0,
|
|
202
|
+
'string-literal',
|
|
203
|
+
'StringLiteral'
|
|
193
204
|
);
|
|
194
205
|
},
|
|
195
206
|
|
|
196
207
|
TemplateLiteral: (nodePath) => {
|
|
197
|
-
|
|
198
|
-
if (this.isInsideTranslationCall(nodePath)) return;
|
|
208
|
+
if (nodePath.node.expressions.length > 0) return;
|
|
209
|
+
if (this.isInsideTranslationCall(nodePath)) return;
|
|
199
210
|
const text = normalizeText(nodePath.node.quasis[0]?.value.cooked ?? '');
|
|
200
211
|
if (!isHumanReadableText(text)) return;
|
|
201
212
|
this.addDetected(
|
|
202
213
|
text,
|
|
203
|
-
nodePath.node.loc?.start.line ?? 0,
|
|
204
|
-
|
|
214
|
+
nodePath.node.loc?.start.line ?? 0,
|
|
215
|
+
nodePath.node.loc?.start.column ?? 0,
|
|
205
216
|
'template-literal',
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
217
|
+
'TemplateLiteral'
|
|
218
|
+
);
|
|
219
|
+
},
|
|
209
220
|
});
|
|
210
221
|
|
|
211
|
-
|
|
222
|
+
return this.detectedTexts;
|
|
212
223
|
}
|
|
213
224
|
|
|
214
225
|
/**
|
|
@@ -218,55 +229,55 @@ nodePath.node.loc?.start.line ?? 0,
|
|
|
218
229
|
private collectTranslationImports(ast: t.File): void {
|
|
219
230
|
for (const node of ast.program.body) {
|
|
220
231
|
if (!t.isImportDeclaration(node)) continue;
|
|
221
|
-
|
|
232
|
+
const source = node.source.value;
|
|
222
233
|
if (!this.isTranslationImportSource(source)) continue;
|
|
223
234
|
for (const specifier of node.specifiers) {
|
|
224
235
|
if (t.isImportSpecifier(specifier) && t.isIdentifier(specifier.local)) {
|
|
225
236
|
this.translationFunctionNames.add(specifier.local.name);
|
|
226
|
-
}
|
|
227
|
-
|
|
237
|
+
}
|
|
238
|
+
// Default import: import useT from '../../hooks/useT'
|
|
228
239
|
if (t.isImportDefaultSpecifier(specifier) && t.isIdentifier(specifier.local)) {
|
|
229
240
|
this.translationFunctionNames.add(specifier.local.name);
|
|
230
|
-
}
|
|
231
|
-
// Namespace import: import * as i18n from '...'
|
|
232
|
-
if (t.isImportNamespaceSpecifier(specifier) && t.isIdentifier(specifier.local)) {
|
|
233
|
-
this.translationFunctionNames.add(specifier.local.name);
|
|
234
241
|
}
|
|
242
|
+
// Namespace import: import * as i18n from '...'
|
|
243
|
+
if (t.isImportNamespaceSpecifier(specifier) && t.isIdentifier(specifier.local)) {
|
|
244
|
+
this.translationFunctionNames.add(specifier.local.name);
|
|
245
|
+
}
|
|
235
246
|
}
|
|
236
247
|
}
|
|
237
248
|
}
|
|
238
249
|
|
|
239
250
|
/** Returns true if the import source string should be treated as a translation library. */
|
|
240
|
-
private isTranslationImportSource(source: string): boolean {
|
|
251
|
+
private isTranslationImportSource(source: string): boolean {
|
|
241
252
|
return this.importSourceMatchers.some((match) => match(source));
|
|
242
|
-
|
|
253
|
+
}
|
|
243
254
|
|
|
244
|
-
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
255
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
245
256
|
private isInsideTranslationCall(nodePath: any): boolean {
|
|
246
257
|
let current = nodePath.parentPath;
|
|
247
258
|
while (current) {
|
|
248
259
|
const node = current.node;
|
|
249
260
|
|
|
250
261
|
// Function-call style: t('key')
|
|
251
|
-
|
|
262
|
+
if (t.isCallExpression(node)) {
|
|
252
263
|
const callee = node.callee;
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
}
|
|
256
|
-
if (
|
|
257
|
-
t.isMemberExpression(callee) &&
|
|
258
|
-
t.isIdentifier(callee.property) &&
|
|
259
|
-
this.translationFunctionNames.has(callee.property.name)
|
|
260
|
-
) {
|
|
261
|
-
return true;
|
|
264
|
+
if (t.isIdentifier(callee) && this.translationFunctionNames.has(callee.name)) {
|
|
265
|
+
return true;
|
|
262
266
|
}
|
|
263
|
-
|
|
267
|
+
if (
|
|
268
|
+
t.isMemberExpression(callee) &&
|
|
269
|
+
t.isIdentifier(callee.property) &&
|
|
270
|
+
this.translationFunctionNames.has(callee.property.name)
|
|
271
|
+
) {
|
|
272
|
+
return true;
|
|
273
|
+
}
|
|
274
|
+
}
|
|
264
275
|
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
276
|
+
// Bracket-notation accessor: t['key'] (MemberExpression, computed=true)
|
|
277
|
+
if (t.isMemberExpression(node) && node.computed) {
|
|
278
|
+
const obj = node.object;
|
|
268
279
|
if (t.isIdentifier(obj) && this.translationFunctionNames.has(obj.name)) {
|
|
269
|
-
|
|
280
|
+
return true;
|
|
270
281
|
}
|
|
271
282
|
}
|
|
272
283
|
|
|
@@ -282,22 +293,23 @@ private isTranslationImportSource(source: string): boolean {
|
|
|
282
293
|
context: TextContext,
|
|
283
294
|
nodeType: string
|
|
284
295
|
): void {
|
|
285
|
-
const key =
|
|
286
|
-
|
|
296
|
+
const key = generateKeyByStyle(
|
|
297
|
+
this.options.filePath,
|
|
287
298
|
text,
|
|
288
|
-
|
|
299
|
+
this.options.sourceRoot || 'src',
|
|
300
|
+
this.options.keyStyle || 'path'
|
|
289
301
|
);
|
|
290
302
|
this.detectedTexts.push({
|
|
291
303
|
filePath: this.options.filePath,
|
|
292
304
|
line,
|
|
293
305
|
column,
|
|
294
|
-
|
|
306
|
+
text,
|
|
295
307
|
suggestedKey: key,
|
|
296
308
|
context,
|
|
297
309
|
nodeType,
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
}
|
|
310
|
+
alreadyTranslated: false,
|
|
311
|
+
});
|
|
312
|
+
}
|
|
301
313
|
|
|
302
314
|
private mapAttrToContext(attrName: string): TextContext {
|
|
303
315
|
const lower = attrName.toLowerCase();
|
|
@@ -313,27 +325,28 @@ private isTranslationImportSource(source: string): boolean {
|
|
|
313
325
|
const jsxTextRegex = />([^<>{}\n]+)</g;
|
|
314
326
|
const lines = this.options.content.split('\n');
|
|
315
327
|
lines.forEach((line, idx) => {
|
|
316
|
-
|
|
317
|
-
|
|
328
|
+
let m: RegExpExecArray | null;
|
|
329
|
+
jsxTextRegex.lastIndex = 0;
|
|
318
330
|
while ((m = jsxTextRegex.exec(line)) !== null) {
|
|
319
331
|
const text = normalizeText(m[1]);
|
|
320
332
|
if (!isHumanReadableText(text)) continue;
|
|
321
|
-
const key =
|
|
333
|
+
const key = generateKeyByStyle(
|
|
322
334
|
this.options.filePath,
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
335
|
+
text,
|
|
336
|
+
this.options.sourceRoot || 'src',
|
|
337
|
+
this.options.keyStyle || 'path'
|
|
338
|
+
);
|
|
326
339
|
results.push({
|
|
327
340
|
filePath: this.options.filePath,
|
|
328
|
-
|
|
341
|
+
line: idx + 1,
|
|
329
342
|
column: m.index,
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
343
|
+
text,
|
|
344
|
+
suggestedKey: key,
|
|
345
|
+
context: 'jsx-text',
|
|
333
346
|
nodeType: 'regex-fallback',
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
347
|
+
alreadyTranslated: false,
|
|
348
|
+
});
|
|
349
|
+
}
|
|
337
350
|
});
|
|
338
351
|
return results;
|
|
339
352
|
}
|
|
@@ -356,7 +369,7 @@ private isTranslationImportSource(source: string): boolean {
|
|
|
356
369
|
* comparing the **last N segments** of the import source with the
|
|
357
370
|
* last N segments of the configured value (case-insensitive, stripping
|
|
358
371
|
* leading dots and slashes). This is intentionally lenient so that
|
|
359
|
-
*
|
|
372
|
+
* `hooks/useTranslation` matches both `../../hooks/useTranslation` and
|
|
360
373
|
* `./hooks/useTranslation`.
|
|
361
374
|
*
|
|
362
375
|
* Examples:
|
|
@@ -365,8 +378,8 @@ private isTranslationImportSource(source: string): boolean {
|
|
|
365
378
|
* matches "./hooks/useTranslation" ✓ (same tail segments)
|
|
366
379
|
* matches "../hooks/useTranslation" ✓
|
|
367
380
|
* importPackage="@/i18n/hook"
|
|
368
|
-
* matches "@/i18n/hook"
|
|
369
|
-
*
|
|
381
|
+
* matches "@/i18n/hook"✓
|
|
382
|
+
* matches "i18n/hook" ✓
|
|
370
383
|
*/
|
|
371
384
|
function buildImportMatcher(importPackage: string): (source: string) => boolean {
|
|
372
385
|
// Normalise: strip leading ./ ../ @ and collapse slashes
|
|
@@ -374,20 +387,20 @@ function buildImportMatcher(importPackage: string): (source: string) => boolean
|
|
|
374
387
|
const pkgSegments = normalisedPkg.split('/').filter(Boolean);
|
|
375
388
|
|
|
376
389
|
return (source: string): boolean => {
|
|
377
|
-
|
|
378
|
-
|
|
390
|
+
// 1. Exact match (covers plain npm package names and exact alias paths)
|
|
391
|
+
if (source === importPackage) return true;
|
|
379
392
|
|
|
380
393
|
// 2. Suffix / tail-segment match
|
|
381
394
|
const normSource = normalisePath(source);
|
|
382
|
-
|
|
395
|
+
const srcSegments = normSource.split('/').filter(Boolean);
|
|
383
396
|
|
|
384
397
|
if (pkgSegments.length === 0) return false;
|
|
385
398
|
|
|
386
|
-
|
|
399
|
+
// Check if the source ends with the same N tail segments as the package
|
|
387
400
|
const n = pkgSegments.length;
|
|
388
|
-
|
|
401
|
+
if (srcSegments.length < n) return false;
|
|
389
402
|
const tail = srcSegments.slice(-n);
|
|
390
|
-
|
|
403
|
+
return tail.every((seg, i) => seg.toLowerCase() === pkgSegments[i].toLowerCase());
|
|
391
404
|
};
|
|
392
405
|
}
|
|
393
406
|
|
|
@@ -396,5 +409,5 @@ function normalisePath(p: string): string {
|
|
|
396
409
|
return p
|
|
397
410
|
.replace(/\\/g, '/')
|
|
398
411
|
.replace(/^(@\/|\.{1,2}\/)+/, '') // strip leading ./ ../ @/
|
|
399
|
-
|
|
412
|
+
.replace(/^@/, ''); // strip bare @ prefix
|
|
400
413
|
}
|
package/src/project-scanner.ts
CHANGED
|
@@ -29,10 +29,10 @@ export class ProjectScanner {
|
|
|
29
29
|
this.config = config;
|
|
30
30
|
// Resolve to absolute path so key-generator can use path.relative()
|
|
31
31
|
// and never produce keys that include ancestor directory segments.
|
|
32
|
-
|
|
32
|
+
this.sourceRoot = path.resolve(process.cwd(), config.sourceDir);
|
|
33
33
|
this.assetScanner = new AssetScanner(config.aws?.legacyCdnPattern);
|
|
34
34
|
if (config.incrementalCache) {
|
|
35
|
-
|
|
35
|
+
this.cache = new IncrementalScanCache(
|
|
36
36
|
path.join(process.cwd(), config.cacheDir || '.ai-localize-cache')
|
|
37
37
|
);
|
|
38
38
|
}
|
|
@@ -43,51 +43,51 @@ export class ProjectScanner {
|
|
|
43
43
|
let filesToScan: string[] = [];
|
|
44
44
|
|
|
45
45
|
if (options.files?.length) {
|
|
46
|
-
|
|
47
|
-
|
|
46
|
+
filesToScan = options.files;
|
|
47
|
+
} else {
|
|
48
48
|
const allFiles = collectFiles(this.sourceRoot, SOURCE_EXTENSIONS, [
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
return this.config.includePatterns!.some(pattern => {
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
49
|
+
...DEFAULT_IGNORE_DIRS,
|
|
50
|
+
...(this.config.ignorePatterns || []),
|
|
51
|
+
]);
|
|
52
|
+
|
|
53
|
+
if (this.config.includePatterns && this.config.includePatterns.length > 0) {
|
|
54
|
+
filesToScan = allFiles.filter(file => {
|
|
55
|
+
return this.config.includePatterns!.some(pattern => {
|
|
56
|
+
const regexPattern = pattern.replace(/\*/g, '.*').replace(/\?/g, '.');
|
|
57
|
+
return new RegExp(regexPattern).test(file);
|
|
58
|
+
});
|
|
59
|
+
});
|
|
60
60
|
} else {
|
|
61
|
-
|
|
61
|
+
filesToScan = allFiles;
|
|
62
62
|
}
|
|
63
63
|
}
|
|
64
64
|
|
|
65
65
|
const allTexts: DetectedText[] = [];
|
|
66
|
-
|
|
66
|
+
const allAssets: AssetReference[] = [];
|
|
67
67
|
const allLegacyUrls: LegacyCdnUrl[] = [];
|
|
68
68
|
|
|
69
69
|
const chunkSize = Math.max(
|
|
70
|
-
|
|
71
|
-
|
|
70
|
+
1,
|
|
71
|
+
Math.min(50, Math.ceil(filesToScan.length / (os.cpus().length || 4)))
|
|
72
72
|
);
|
|
73
73
|
const chunks = this.chunkArray(filesToScan, chunkSize);
|
|
74
74
|
|
|
75
75
|
for (const chunk of chunks) {
|
|
76
|
-
|
|
77
|
-
|
|
76
|
+
const results = await Promise.all(chunk.map((f) => this.scanFile(f)));
|
|
77
|
+
for (const r of results) {
|
|
78
78
|
allTexts.push(...r.texts);
|
|
79
79
|
allAssets.push(...r.assets);
|
|
80
80
|
allLegacyUrls.push(...r.legacyUrls);
|
|
81
81
|
}
|
|
82
82
|
}
|
|
83
83
|
|
|
84
|
-
|
|
84
|
+
this.cache?.persist();
|
|
85
85
|
|
|
86
86
|
return {
|
|
87
|
-
framework: this.config.framework,
|
|
88
|
-
|
|
87
|
+
framework: this.config.framework,
|
|
88
|
+
scannedFiles: filesToScan.length,
|
|
89
89
|
detectedTexts: allTexts,
|
|
90
|
-
|
|
90
|
+
assets: allAssets,
|
|
91
91
|
legacyCdnUrls: allLegacyUrls,
|
|
92
92
|
duration: Date.now() - startTime,
|
|
93
93
|
timestamp: new Date().toISOString(),
|
|
@@ -106,8 +106,8 @@ framework: this.config.framework,
|
|
|
106
106
|
|
|
107
107
|
let content: string;
|
|
108
108
|
try {
|
|
109
|
-
|
|
110
|
-
|
|
109
|
+
const { readFileSync } = await import('fs');
|
|
110
|
+
content = readFileSync(filePath, 'utf-8');
|
|
111
111
|
} catch {
|
|
112
112
|
return { texts: [], assets: [], legacyUrls: [] };
|
|
113
113
|
}
|
|
@@ -116,13 +116,15 @@ framework: this.config.framework,
|
|
|
116
116
|
// and strips only the project source directory — not ancestor segments.
|
|
117
117
|
// Also pass codemods config so the scanner recognises user-defined
|
|
118
118
|
// translation functions/hooks and skips already-translated strings.
|
|
119
|
+
// keyStyle controls whether keys are path-based (default) or SCREAMING_SNAKE.
|
|
119
120
|
const scanner = new AstScanner({
|
|
120
121
|
filePath,
|
|
121
122
|
content,
|
|
122
123
|
sourceRoot: this.sourceRoot,
|
|
123
124
|
codemodConfig: this.config.codemods,
|
|
125
|
+
keyStyle: this.config.keyStyle ?? 'path',
|
|
124
126
|
});
|
|
125
|
-
|
|
127
|
+
const texts = scanner.scan();
|
|
126
128
|
const { assets, legacyCdnUrls } = this.assetScanner.scanFile(filePath);
|
|
127
129
|
|
|
128
130
|
this.cache?.setCachedResult(filePath, texts);
|
|
@@ -133,7 +135,7 @@ framework: this.config.framework,
|
|
|
133
135
|
private chunkArray<T>(array: T[], size: number): T[][] {
|
|
134
136
|
const chunks: T[][] = [];
|
|
135
137
|
for (let i = 0; i < array.length; i += size) {
|
|
136
|
-
|
|
138
|
+
chunks.push(array.slice(i, i + size));
|
|
137
139
|
}
|
|
138
140
|
return chunks;
|
|
139
141
|
}
|