ai-localize-scanner 2.0.0 → 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 +30 -0
- package/dist/index.d.mts +48 -1
- package/dist/index.d.ts +48 -1
- package/dist/index.js +101 -15
- package/dist/index.mjs +102 -16
- package/package.json +3 -3
- package/src/ast-scanner.ts +253 -80
- package/src/project-scanner.ts +50 -22
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,35 @@
|
|
|
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
|
+
|
|
24
|
+
## 2.0.1
|
|
25
|
+
|
|
26
|
+
### Patch Changes
|
|
27
|
+
|
|
28
|
+
- Fix config schema validation (aws optional/nullable, framework type), ignore className CSS values in AST scanner, add includePatterns config support for selective file scanning, add --extract-cdns flag to scan command to dump CDN URLs to a JSON file
|
|
29
|
+
- Updated dependencies
|
|
30
|
+
- ai-localize-config@2.0.1
|
|
31
|
+
- ai-localize-shared@2.0.1
|
|
32
|
+
|
|
3
33
|
## 2.0.0
|
|
4
34
|
|
|
5
35
|
### Major Changes
|
package/dist/index.d.mts
CHANGED
|
@@ -1,9 +1,43 @@
|
|
|
1
|
-
import { 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;
|
|
18
|
+
/**
|
|
19
|
+
* Optional codemod config from ai-localize.config.json.
|
|
20
|
+
*
|
|
21
|
+
* The scanner uses this to recognise already-translated strings even when
|
|
22
|
+
* the project uses a custom i18n library or a locally-defined hook:
|
|
23
|
+
*
|
|
24
|
+
* importPackage — matched against import source strings. Supports:
|
|
25
|
+
* - npm package names: "react-i18next", "my-i18n-lib"
|
|
26
|
+
* - path aliases: "@/hooks/useTranslation", "@/i18n"
|
|
27
|
+
* - relative paths: "../../hooks/useTranslation"
|
|
28
|
+
* Matching is done by checking whether the import source equals the value
|
|
29
|
+
* OR ends with the last path segment(s) of the value (normalised).
|
|
30
|
+
*
|
|
31
|
+
* hookName — the hook identifier (e.g. "useTranslation", "useI18n").
|
|
32
|
+
* Added directly to the translation-function names set regardless of
|
|
33
|
+
* how the hook is imported. This means even default imports, re-exports
|
|
34
|
+
* or barrel aliases are handled correctly:
|
|
35
|
+
* import useT from '../../hooks/useT' (default import, hookName="useT")
|
|
36
|
+
*
|
|
37
|
+
* translationFunction — the accessor returned by the hook (e.g. "t").
|
|
38
|
+
* Added directly to the translation-function names set.
|
|
39
|
+
*/
|
|
40
|
+
codemodConfig?: CodemodConfig;
|
|
7
41
|
}
|
|
8
42
|
/**
|
|
9
43
|
* Scans a JS/TS/JSX/TSX file using Babel AST to find hardcoded text.
|
|
@@ -11,10 +45,23 @@ interface AstScanOptions {
|
|
|
11
45
|
declare class AstScanner {
|
|
12
46
|
private options;
|
|
13
47
|
private detectedTexts;
|
|
48
|
+
/** Identifiers whose call/bracket expressions contain already-translated strings. */
|
|
14
49
|
private translationFunctionNames;
|
|
50
|
+
/**
|
|
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
|
+
*/
|
|
55
|
+
private importSourceMatchers;
|
|
15
56
|
constructor(options: AstScanOptions);
|
|
16
57
|
scan(): DetectedText[];
|
|
58
|
+
/**
|
|
59
|
+
* Walk import declarations; when the source matches a known translation
|
|
60
|
+
* import, collect all named/default imports as translation function names.
|
|
61
|
+
*/
|
|
17
62
|
private collectTranslationImports;
|
|
63
|
+
/** Returns true if the import source string should be treated as a translation library. */
|
|
64
|
+
private isTranslationImportSource;
|
|
18
65
|
private isInsideTranslationCall;
|
|
19
66
|
private addDetected;
|
|
20
67
|
private mapAttrToContext;
|
package/dist/index.d.ts
CHANGED
|
@@ -1,9 +1,43 @@
|
|
|
1
|
-
import { 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;
|
|
18
|
+
/**
|
|
19
|
+
* Optional codemod config from ai-localize.config.json.
|
|
20
|
+
*
|
|
21
|
+
* The scanner uses this to recognise already-translated strings even when
|
|
22
|
+
* the project uses a custom i18n library or a locally-defined hook:
|
|
23
|
+
*
|
|
24
|
+
* importPackage — matched against import source strings. Supports:
|
|
25
|
+
* - npm package names: "react-i18next", "my-i18n-lib"
|
|
26
|
+
* - path aliases: "@/hooks/useTranslation", "@/i18n"
|
|
27
|
+
* - relative paths: "../../hooks/useTranslation"
|
|
28
|
+
* Matching is done by checking whether the import source equals the value
|
|
29
|
+
* OR ends with the last path segment(s) of the value (normalised).
|
|
30
|
+
*
|
|
31
|
+
* hookName — the hook identifier (e.g. "useTranslation", "useI18n").
|
|
32
|
+
* Added directly to the translation-function names set regardless of
|
|
33
|
+
* how the hook is imported. This means even default imports, re-exports
|
|
34
|
+
* or barrel aliases are handled correctly:
|
|
35
|
+
* import useT from '../../hooks/useT' (default import, hookName="useT")
|
|
36
|
+
*
|
|
37
|
+
* translationFunction — the accessor returned by the hook (e.g. "t").
|
|
38
|
+
* Added directly to the translation-function names set.
|
|
39
|
+
*/
|
|
40
|
+
codemodConfig?: CodemodConfig;
|
|
7
41
|
}
|
|
8
42
|
/**
|
|
9
43
|
* Scans a JS/TS/JSX/TSX file using Babel AST to find hardcoded text.
|
|
@@ -11,10 +45,23 @@ interface AstScanOptions {
|
|
|
11
45
|
declare class AstScanner {
|
|
12
46
|
private options;
|
|
13
47
|
private detectedTexts;
|
|
48
|
+
/** Identifiers whose call/bracket expressions contain already-translated strings. */
|
|
14
49
|
private translationFunctionNames;
|
|
50
|
+
/**
|
|
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
|
+
*/
|
|
55
|
+
private importSourceMatchers;
|
|
15
56
|
constructor(options: AstScanOptions);
|
|
16
57
|
scan(): DetectedText[];
|
|
58
|
+
/**
|
|
59
|
+
* Walk import declarations; when the source matches a known translation
|
|
60
|
+
* import, collect all named/default imports as translation function names.
|
|
61
|
+
*/
|
|
17
62
|
private collectTranslationImports;
|
|
63
|
+
/** Returns true if the import source string should be treated as a translation library. */
|
|
64
|
+
private isTranslationImportSource;
|
|
18
65
|
private isInsideTranslationCall;
|
|
19
66
|
private addDetected;
|
|
20
67
|
private mapAttrToContext;
|
package/dist/index.js
CHANGED
|
@@ -43,7 +43,7 @@ var parser = __toESM(require("@babel/parser"));
|
|
|
43
43
|
var import_traverse = __toESM(require("@babel/traverse"));
|
|
44
44
|
var t = __toESM(require("@babel/types"));
|
|
45
45
|
var import_ai_localize_shared = require("ai-localize-shared");
|
|
46
|
-
var
|
|
46
|
+
var BUILTIN_TRANSLATION_IMPORT_SOURCES = /* @__PURE__ */ new Set([
|
|
47
47
|
"react-i18next",
|
|
48
48
|
"i18next",
|
|
49
49
|
"vue-i18n",
|
|
@@ -52,9 +52,32 @@ var TRANSLATION_IMPORT_SOURCES = /* @__PURE__ */ new Set([
|
|
|
52
52
|
var AstScanner = class {
|
|
53
53
|
options;
|
|
54
54
|
detectedTexts = [];
|
|
55
|
-
|
|
55
|
+
/** Identifiers whose call/bracket expressions contain already-translated strings. */
|
|
56
|
+
translationFunctionNames;
|
|
57
|
+
/**
|
|
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
|
+
importSourceMatchers;
|
|
56
63
|
constructor(options) {
|
|
57
64
|
this.options = options;
|
|
65
|
+
this.translationFunctionNames = /* @__PURE__ */ new Set(["t", "$t", "i18n", "translate"]);
|
|
66
|
+
this.importSourceMatchers = Array.from(BUILTIN_TRANSLATION_IMPORT_SOURCES).map(
|
|
67
|
+
(pkg) => (src) => src === pkg
|
|
68
|
+
);
|
|
69
|
+
const cc = options.codemodConfig;
|
|
70
|
+
if (cc) {
|
|
71
|
+
if (cc.translationFunction) {
|
|
72
|
+
this.translationFunctionNames.add(cc.translationFunction);
|
|
73
|
+
}
|
|
74
|
+
if (cc.hookName) {
|
|
75
|
+
this.translationFunctionNames.add(cc.hookName);
|
|
76
|
+
}
|
|
77
|
+
if (cc.importPackage) {
|
|
78
|
+
this.importSourceMatchers.push(buildImportMatcher(cc.importPackage));
|
|
79
|
+
}
|
|
80
|
+
}
|
|
58
81
|
}
|
|
59
82
|
scan() {
|
|
60
83
|
const { content } = this.options;
|
|
@@ -113,9 +136,7 @@ var AstScanner = class {
|
|
|
113
136
|
if (t.isObjectProperty(nodePath.parent) && nodePath.parent.key === nodePath.node) return;
|
|
114
137
|
if (t.isJSXAttribute(nodePath.parent)) {
|
|
115
138
|
const attrName = t.isJSXIdentifier(nodePath.parent.name) ? nodePath.parent.name.name.toLowerCase() : "";
|
|
116
|
-
if (attrName === "classname" || attrName === "class")
|
|
117
|
-
return;
|
|
118
|
-
}
|
|
139
|
+
if (attrName === "classname" || attrName === "class") return;
|
|
119
140
|
return;
|
|
120
141
|
}
|
|
121
142
|
if (this.isInsideTranslationCall(nodePath)) return;
|
|
@@ -149,17 +170,32 @@ var AstScanner = class {
|
|
|
149
170
|
});
|
|
150
171
|
return this.detectedTexts;
|
|
151
172
|
}
|
|
173
|
+
/**
|
|
174
|
+
* Walk import declarations; when the source matches a known translation
|
|
175
|
+
* import, collect all named/default imports as translation function names.
|
|
176
|
+
*/
|
|
152
177
|
collectTranslationImports(ast) {
|
|
153
178
|
for (const node of ast.program.body) {
|
|
154
179
|
if (!t.isImportDeclaration(node)) continue;
|
|
155
|
-
|
|
180
|
+
const source = node.source.value;
|
|
181
|
+
if (!this.isTranslationImportSource(source)) continue;
|
|
156
182
|
for (const specifier of node.specifiers) {
|
|
157
183
|
if (t.isImportSpecifier(specifier) && t.isIdentifier(specifier.local)) {
|
|
158
184
|
this.translationFunctionNames.add(specifier.local.name);
|
|
159
185
|
}
|
|
186
|
+
if (t.isImportDefaultSpecifier(specifier) && t.isIdentifier(specifier.local)) {
|
|
187
|
+
this.translationFunctionNames.add(specifier.local.name);
|
|
188
|
+
}
|
|
189
|
+
if (t.isImportNamespaceSpecifier(specifier) && t.isIdentifier(specifier.local)) {
|
|
190
|
+
this.translationFunctionNames.add(specifier.local.name);
|
|
191
|
+
}
|
|
160
192
|
}
|
|
161
193
|
}
|
|
162
194
|
}
|
|
195
|
+
/** Returns true if the import source string should be treated as a translation library. */
|
|
196
|
+
isTranslationImportSource(source) {
|
|
197
|
+
return this.importSourceMatchers.some((match) => match(source));
|
|
198
|
+
}
|
|
163
199
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
164
200
|
isInsideTranslationCall(nodePath) {
|
|
165
201
|
let current = nodePath.parentPath;
|
|
@@ -174,15 +210,22 @@ var AstScanner = class {
|
|
|
174
210
|
return true;
|
|
175
211
|
}
|
|
176
212
|
}
|
|
213
|
+
if (t.isMemberExpression(node) && node.computed) {
|
|
214
|
+
const obj = node.object;
|
|
215
|
+
if (t.isIdentifier(obj) && this.translationFunctionNames.has(obj.name)) {
|
|
216
|
+
return true;
|
|
217
|
+
}
|
|
218
|
+
}
|
|
177
219
|
current = current.parentPath;
|
|
178
220
|
}
|
|
179
221
|
return false;
|
|
180
222
|
}
|
|
181
223
|
addDetected(text, line, column, context, nodeType) {
|
|
182
|
-
const key = (0, import_ai_localize_shared.
|
|
224
|
+
const key = (0, import_ai_localize_shared.generateKeyByStyle)(
|
|
183
225
|
this.options.filePath,
|
|
184
226
|
text,
|
|
185
|
-
this.options.sourceRoot || "src"
|
|
227
|
+
this.options.sourceRoot || "src",
|
|
228
|
+
this.options.keyStyle || "path"
|
|
186
229
|
);
|
|
187
230
|
this.detectedTexts.push({
|
|
188
231
|
filePath: this.options.filePath,
|
|
@@ -213,7 +256,12 @@ var AstScanner = class {
|
|
|
213
256
|
while ((m = jsxTextRegex.exec(line)) !== null) {
|
|
214
257
|
const text = (0, import_ai_localize_shared.normalizeText)(m[1]);
|
|
215
258
|
if (!(0, import_ai_localize_shared.isHumanReadableText)(text)) continue;
|
|
216
|
-
const key = (0, import_ai_localize_shared.
|
|
259
|
+
const key = (0, import_ai_localize_shared.generateKeyByStyle)(
|
|
260
|
+
this.options.filePath,
|
|
261
|
+
text,
|
|
262
|
+
this.options.sourceRoot || "src",
|
|
263
|
+
this.options.keyStyle || "path"
|
|
264
|
+
);
|
|
217
265
|
results.push({
|
|
218
266
|
filePath: this.options.filePath,
|
|
219
267
|
line: idx + 1,
|
|
@@ -229,6 +277,23 @@ var AstScanner = class {
|
|
|
229
277
|
return results;
|
|
230
278
|
}
|
|
231
279
|
};
|
|
280
|
+
function buildImportMatcher(importPackage) {
|
|
281
|
+
const normalisedPkg = normalisePath(importPackage);
|
|
282
|
+
const pkgSegments = normalisedPkg.split("/").filter(Boolean);
|
|
283
|
+
return (source) => {
|
|
284
|
+
if (source === importPackage) return true;
|
|
285
|
+
const normSource = normalisePath(source);
|
|
286
|
+
const srcSegments = normSource.split("/").filter(Boolean);
|
|
287
|
+
if (pkgSegments.length === 0) return false;
|
|
288
|
+
const n = pkgSegments.length;
|
|
289
|
+
if (srcSegments.length < n) return false;
|
|
290
|
+
const tail = srcSegments.slice(-n);
|
|
291
|
+
return tail.every((seg, i) => seg.toLowerCase() === pkgSegments[i].toLowerCase());
|
|
292
|
+
};
|
|
293
|
+
}
|
|
294
|
+
function normalisePath(p) {
|
|
295
|
+
return p.replace(/\\/g, "/").replace(/^(@\/|\.{1,2}\/)+/, "").replace(/^@/, "");
|
|
296
|
+
}
|
|
232
297
|
|
|
233
298
|
// src/asset-scanner.ts
|
|
234
299
|
var fs = __toESM(require("fs"));
|
|
@@ -390,7 +455,7 @@ var ProjectScanner = class {
|
|
|
390
455
|
assetScanner;
|
|
391
456
|
constructor(config) {
|
|
392
457
|
this.config = config;
|
|
393
|
-
this.sourceRoot = path3.
|
|
458
|
+
this.sourceRoot = path3.resolve(process.cwd(), config.sourceDir);
|
|
394
459
|
this.assetScanner = new AssetScanner(config.aws?.legacyCdnPattern);
|
|
395
460
|
if (config.incrementalCache) {
|
|
396
461
|
this.cache = new IncrementalScanCache(
|
|
@@ -400,10 +465,25 @@ var ProjectScanner = class {
|
|
|
400
465
|
}
|
|
401
466
|
async scan(options = {}) {
|
|
402
467
|
const startTime = Date.now();
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
|
|
406
|
-
|
|
468
|
+
let filesToScan = [];
|
|
469
|
+
if (options.files?.length) {
|
|
470
|
+
filesToScan = options.files;
|
|
471
|
+
} else {
|
|
472
|
+
const allFiles = (0, import_ai_localize_shared4.collectFiles)(this.sourceRoot, import_ai_localize_shared4.SOURCE_EXTENSIONS, [
|
|
473
|
+
...import_ai_localize_shared4.DEFAULT_IGNORE_DIRS,
|
|
474
|
+
...this.config.ignorePatterns || []
|
|
475
|
+
]);
|
|
476
|
+
if (this.config.includePatterns && this.config.includePatterns.length > 0) {
|
|
477
|
+
filesToScan = allFiles.filter((file) => {
|
|
478
|
+
return this.config.includePatterns.some((pattern) => {
|
|
479
|
+
const regexPattern = pattern.replace(/\*/g, ".*").replace(/\?/g, ".");
|
|
480
|
+
return new RegExp(regexPattern).test(file);
|
|
481
|
+
});
|
|
482
|
+
});
|
|
483
|
+
} else {
|
|
484
|
+
filesToScan = allFiles;
|
|
485
|
+
}
|
|
486
|
+
}
|
|
407
487
|
const allTexts = [];
|
|
408
488
|
const allAssets = [];
|
|
409
489
|
const allLegacyUrls = [];
|
|
@@ -443,7 +523,13 @@ var ProjectScanner = class {
|
|
|
443
523
|
} catch {
|
|
444
524
|
return { texts: [], assets: [], legacyUrls: [] };
|
|
445
525
|
}
|
|
446
|
-
const scanner = new AstScanner({
|
|
526
|
+
const scanner = new AstScanner({
|
|
527
|
+
filePath,
|
|
528
|
+
content,
|
|
529
|
+
sourceRoot: this.sourceRoot,
|
|
530
|
+
codemodConfig: this.config.codemods,
|
|
531
|
+
keyStyle: this.config.keyStyle ?? "path"
|
|
532
|
+
});
|
|
447
533
|
const texts = scanner.scan();
|
|
448
534
|
const { assets, legacyCdnUrls } = this.assetScanner.scanFile(filePath);
|
|
449
535
|
this.cache?.setCachedResult(filePath, texts);
|
package/dist/index.mjs
CHANGED
|
@@ -6,9 +6,9 @@ import {
|
|
|
6
6
|
isHumanReadableText,
|
|
7
7
|
normalizeText,
|
|
8
8
|
TEXT_ATTRIBUTE_NAMES,
|
|
9
|
-
|
|
9
|
+
generateKeyByStyle
|
|
10
10
|
} from "ai-localize-shared";
|
|
11
|
-
var
|
|
11
|
+
var BUILTIN_TRANSLATION_IMPORT_SOURCES = /* @__PURE__ */ new Set([
|
|
12
12
|
"react-i18next",
|
|
13
13
|
"i18next",
|
|
14
14
|
"vue-i18n",
|
|
@@ -17,9 +17,32 @@ var TRANSLATION_IMPORT_SOURCES = /* @__PURE__ */ new Set([
|
|
|
17
17
|
var AstScanner = class {
|
|
18
18
|
options;
|
|
19
19
|
detectedTexts = [];
|
|
20
|
-
|
|
20
|
+
/** Identifiers whose call/bracket expressions contain already-translated strings. */
|
|
21
|
+
translationFunctionNames;
|
|
22
|
+
/**
|
|
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
|
+
importSourceMatchers;
|
|
21
28
|
constructor(options) {
|
|
22
29
|
this.options = options;
|
|
30
|
+
this.translationFunctionNames = /* @__PURE__ */ new Set(["t", "$t", "i18n", "translate"]);
|
|
31
|
+
this.importSourceMatchers = Array.from(BUILTIN_TRANSLATION_IMPORT_SOURCES).map(
|
|
32
|
+
(pkg) => (src) => src === pkg
|
|
33
|
+
);
|
|
34
|
+
const cc = options.codemodConfig;
|
|
35
|
+
if (cc) {
|
|
36
|
+
if (cc.translationFunction) {
|
|
37
|
+
this.translationFunctionNames.add(cc.translationFunction);
|
|
38
|
+
}
|
|
39
|
+
if (cc.hookName) {
|
|
40
|
+
this.translationFunctionNames.add(cc.hookName);
|
|
41
|
+
}
|
|
42
|
+
if (cc.importPackage) {
|
|
43
|
+
this.importSourceMatchers.push(buildImportMatcher(cc.importPackage));
|
|
44
|
+
}
|
|
45
|
+
}
|
|
23
46
|
}
|
|
24
47
|
scan() {
|
|
25
48
|
const { content } = this.options;
|
|
@@ -78,9 +101,7 @@ var AstScanner = class {
|
|
|
78
101
|
if (t.isObjectProperty(nodePath.parent) && nodePath.parent.key === nodePath.node) return;
|
|
79
102
|
if (t.isJSXAttribute(nodePath.parent)) {
|
|
80
103
|
const attrName = t.isJSXIdentifier(nodePath.parent.name) ? nodePath.parent.name.name.toLowerCase() : "";
|
|
81
|
-
if (attrName === "classname" || attrName === "class")
|
|
82
|
-
return;
|
|
83
|
-
}
|
|
104
|
+
if (attrName === "classname" || attrName === "class") return;
|
|
84
105
|
return;
|
|
85
106
|
}
|
|
86
107
|
if (this.isInsideTranslationCall(nodePath)) return;
|
|
@@ -114,17 +135,32 @@ var AstScanner = class {
|
|
|
114
135
|
});
|
|
115
136
|
return this.detectedTexts;
|
|
116
137
|
}
|
|
138
|
+
/**
|
|
139
|
+
* Walk import declarations; when the source matches a known translation
|
|
140
|
+
* import, collect all named/default imports as translation function names.
|
|
141
|
+
*/
|
|
117
142
|
collectTranslationImports(ast) {
|
|
118
143
|
for (const node of ast.program.body) {
|
|
119
144
|
if (!t.isImportDeclaration(node)) continue;
|
|
120
|
-
|
|
145
|
+
const source = node.source.value;
|
|
146
|
+
if (!this.isTranslationImportSource(source)) continue;
|
|
121
147
|
for (const specifier of node.specifiers) {
|
|
122
148
|
if (t.isImportSpecifier(specifier) && t.isIdentifier(specifier.local)) {
|
|
123
149
|
this.translationFunctionNames.add(specifier.local.name);
|
|
124
150
|
}
|
|
151
|
+
if (t.isImportDefaultSpecifier(specifier) && t.isIdentifier(specifier.local)) {
|
|
152
|
+
this.translationFunctionNames.add(specifier.local.name);
|
|
153
|
+
}
|
|
154
|
+
if (t.isImportNamespaceSpecifier(specifier) && t.isIdentifier(specifier.local)) {
|
|
155
|
+
this.translationFunctionNames.add(specifier.local.name);
|
|
156
|
+
}
|
|
125
157
|
}
|
|
126
158
|
}
|
|
127
159
|
}
|
|
160
|
+
/** Returns true if the import source string should be treated as a translation library. */
|
|
161
|
+
isTranslationImportSource(source) {
|
|
162
|
+
return this.importSourceMatchers.some((match) => match(source));
|
|
163
|
+
}
|
|
128
164
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
129
165
|
isInsideTranslationCall(nodePath) {
|
|
130
166
|
let current = nodePath.parentPath;
|
|
@@ -139,15 +175,22 @@ var AstScanner = class {
|
|
|
139
175
|
return true;
|
|
140
176
|
}
|
|
141
177
|
}
|
|
178
|
+
if (t.isMemberExpression(node) && node.computed) {
|
|
179
|
+
const obj = node.object;
|
|
180
|
+
if (t.isIdentifier(obj) && this.translationFunctionNames.has(obj.name)) {
|
|
181
|
+
return true;
|
|
182
|
+
}
|
|
183
|
+
}
|
|
142
184
|
current = current.parentPath;
|
|
143
185
|
}
|
|
144
186
|
return false;
|
|
145
187
|
}
|
|
146
188
|
addDetected(text, line, column, context, nodeType) {
|
|
147
|
-
const key =
|
|
189
|
+
const key = generateKeyByStyle(
|
|
148
190
|
this.options.filePath,
|
|
149
191
|
text,
|
|
150
|
-
this.options.sourceRoot || "src"
|
|
192
|
+
this.options.sourceRoot || "src",
|
|
193
|
+
this.options.keyStyle || "path"
|
|
151
194
|
);
|
|
152
195
|
this.detectedTexts.push({
|
|
153
196
|
filePath: this.options.filePath,
|
|
@@ -178,7 +221,12 @@ var AstScanner = class {
|
|
|
178
221
|
while ((m = jsxTextRegex.exec(line)) !== null) {
|
|
179
222
|
const text = normalizeText(m[1]);
|
|
180
223
|
if (!isHumanReadableText(text)) continue;
|
|
181
|
-
const key =
|
|
224
|
+
const key = generateKeyByStyle(
|
|
225
|
+
this.options.filePath,
|
|
226
|
+
text,
|
|
227
|
+
this.options.sourceRoot || "src",
|
|
228
|
+
this.options.keyStyle || "path"
|
|
229
|
+
);
|
|
182
230
|
results.push({
|
|
183
231
|
filePath: this.options.filePath,
|
|
184
232
|
line: idx + 1,
|
|
@@ -194,6 +242,23 @@ var AstScanner = class {
|
|
|
194
242
|
return results;
|
|
195
243
|
}
|
|
196
244
|
};
|
|
245
|
+
function buildImportMatcher(importPackage) {
|
|
246
|
+
const normalisedPkg = normalisePath(importPackage);
|
|
247
|
+
const pkgSegments = normalisedPkg.split("/").filter(Boolean);
|
|
248
|
+
return (source) => {
|
|
249
|
+
if (source === importPackage) return true;
|
|
250
|
+
const normSource = normalisePath(source);
|
|
251
|
+
const srcSegments = normSource.split("/").filter(Boolean);
|
|
252
|
+
if (pkgSegments.length === 0) return false;
|
|
253
|
+
const n = pkgSegments.length;
|
|
254
|
+
if (srcSegments.length < n) return false;
|
|
255
|
+
const tail = srcSegments.slice(-n);
|
|
256
|
+
return tail.every((seg, i) => seg.toLowerCase() === pkgSegments[i].toLowerCase());
|
|
257
|
+
};
|
|
258
|
+
}
|
|
259
|
+
function normalisePath(p) {
|
|
260
|
+
return p.replace(/\\/g, "/").replace(/^(@\/|\.{1,2}\/)+/, "").replace(/^@/, "");
|
|
261
|
+
}
|
|
197
262
|
|
|
198
263
|
// src/asset-scanner.ts
|
|
199
264
|
import * as fs from "fs";
|
|
@@ -355,7 +420,7 @@ var ProjectScanner = class {
|
|
|
355
420
|
assetScanner;
|
|
356
421
|
constructor(config) {
|
|
357
422
|
this.config = config;
|
|
358
|
-
this.sourceRoot = path3.
|
|
423
|
+
this.sourceRoot = path3.resolve(process.cwd(), config.sourceDir);
|
|
359
424
|
this.assetScanner = new AssetScanner(config.aws?.legacyCdnPattern);
|
|
360
425
|
if (config.incrementalCache) {
|
|
361
426
|
this.cache = new IncrementalScanCache(
|
|
@@ -365,10 +430,25 @@ var ProjectScanner = class {
|
|
|
365
430
|
}
|
|
366
431
|
async scan(options = {}) {
|
|
367
432
|
const startTime = Date.now();
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
433
|
+
let filesToScan = [];
|
|
434
|
+
if (options.files?.length) {
|
|
435
|
+
filesToScan = options.files;
|
|
436
|
+
} else {
|
|
437
|
+
const allFiles = collectFiles(this.sourceRoot, SOURCE_EXTENSIONS, [
|
|
438
|
+
...DEFAULT_IGNORE_DIRS,
|
|
439
|
+
...this.config.ignorePatterns || []
|
|
440
|
+
]);
|
|
441
|
+
if (this.config.includePatterns && this.config.includePatterns.length > 0) {
|
|
442
|
+
filesToScan = allFiles.filter((file) => {
|
|
443
|
+
return this.config.includePatterns.some((pattern) => {
|
|
444
|
+
const regexPattern = pattern.replace(/\*/g, ".*").replace(/\?/g, ".");
|
|
445
|
+
return new RegExp(regexPattern).test(file);
|
|
446
|
+
});
|
|
447
|
+
});
|
|
448
|
+
} else {
|
|
449
|
+
filesToScan = allFiles;
|
|
450
|
+
}
|
|
451
|
+
}
|
|
372
452
|
const allTexts = [];
|
|
373
453
|
const allAssets = [];
|
|
374
454
|
const allLegacyUrls = [];
|
|
@@ -408,7 +488,13 @@ var ProjectScanner = class {
|
|
|
408
488
|
} catch {
|
|
409
489
|
return { texts: [], assets: [], legacyUrls: [] };
|
|
410
490
|
}
|
|
411
|
-
const scanner = new AstScanner({
|
|
491
|
+
const scanner = new AstScanner({
|
|
492
|
+
filePath,
|
|
493
|
+
content,
|
|
494
|
+
sourceRoot: this.sourceRoot,
|
|
495
|
+
codemodConfig: this.config.codemods,
|
|
496
|
+
keyStyle: this.config.keyStyle ?? "path"
|
|
497
|
+
});
|
|
412
498
|
const texts = scanner.scan();
|
|
413
499
|
const { assets, legacyCdnUrls } = this.assetScanner.scanFile(filePath);
|
|
414
500
|
this.cache?.setCachedResult(filePath, texts);
|
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,21 +2,56 @@ 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 } 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;
|
|
28
|
+
/**
|
|
29
|
+
* Optional codemod config from ai-localize.config.json.
|
|
30
|
+
*
|
|
31
|
+
* The scanner uses this to recognise already-translated strings even when
|
|
32
|
+
* the project uses a custom i18n library or a locally-defined hook:
|
|
33
|
+
*
|
|
34
|
+
* importPackage — matched against import source strings. Supports:
|
|
35
|
+
* - npm package names: "react-i18next", "my-i18n-lib"
|
|
36
|
+
* - path aliases: "@/hooks/useTranslation", "@/i18n"
|
|
37
|
+
* - relative paths: "../../hooks/useTranslation"
|
|
38
|
+
* Matching is done by checking whether the import source equals the value
|
|
39
|
+
* OR ends with the last path segment(s) of the value (normalised).
|
|
40
|
+
*
|
|
41
|
+
* hookName — the hook identifier (e.g. "useTranslation", "useI18n").
|
|
42
|
+
* Added directly to the translation-function names set regardless of
|
|
43
|
+
* how the hook is imported. This means even default imports, re-exports
|
|
44
|
+
* or barrel aliases are handled correctly:
|
|
45
|
+
* import useT from '../../hooks/useT' (default import, hookName="useT")
|
|
46
|
+
*
|
|
47
|
+
* translationFunction — the accessor returned by the hook (e.g. "t").
|
|
48
|
+
* Added directly to the translation-function names set.
|
|
49
|
+
*/
|
|
50
|
+
codemodConfig?: CodemodConfig;
|
|
17
51
|
}
|
|
18
52
|
|
|
19
|
-
|
|
53
|
+
/** Well-known i18n library import sources (npm package names). */
|
|
54
|
+
const BUILTIN_TRANSLATION_IMPORT_SOURCES = new Set([
|
|
20
55
|
'react-i18next',
|
|
21
56
|
'i18next',
|
|
22
57
|
'vue-i18n',
|
|
@@ -29,10 +64,49 @@ const TRANSLATION_IMPORT_SOURCES = new Set([
|
|
|
29
64
|
export class AstScanner {
|
|
30
65
|
private options: AstScanOptions;
|
|
31
66
|
private detectedTexts: DetectedText[] = [];
|
|
32
|
-
|
|
67
|
+
|
|
68
|
+
/** Identifiers whose call/bracket expressions contain already-translated strings. */
|
|
69
|
+
private translationFunctionNames: Set<string>;
|
|
70
|
+
|
|
71
|
+
/**
|
|
72
|
+
* Import source matchers.
|
|
73
|
+
* Each entry is either an exact string (npm package) or a path-suffix matcher
|
|
74
|
+
* function built from a relative/alias path.
|
|
75
|
+
*/
|
|
76
|
+
private importSourceMatchers: Array<(source: string) => boolean>;
|
|
33
77
|
|
|
34
78
|
constructor(options: AstScanOptions) {
|
|
35
79
|
this.options = options;
|
|
80
|
+
|
|
81
|
+
// Seed default translation function names
|
|
82
|
+
this.translationFunctionNames = new Set<string>(['t', '$t', 'i18n', 'translate']);
|
|
83
|
+
|
|
84
|
+
// Build import-source matchers from builtin list
|
|
85
|
+
this.importSourceMatchers = Array.from(BUILTIN_TRANSLATION_IMPORT_SOURCES).map(
|
|
86
|
+
(pkg) => (src: string) => src === pkg
|
|
87
|
+
);
|
|
88
|
+
|
|
89
|
+
const cc = options.codemodConfig;
|
|
90
|
+
if (cc) {
|
|
91
|
+
// translationFunction — seed directly; no import lookup needed
|
|
92
|
+
if (cc.translationFunction) {
|
|
93
|
+
this.translationFunctionNames.add(cc.translationFunction);
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
// hookName — seed directly so the hook identifier is always recognised
|
|
97
|
+
// regardless of how it is imported (named, default, aliased, re-exported).
|
|
98
|
+
if (cc.hookName) {
|
|
99
|
+
this.translationFunctionNames.add(cc.hookName);
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
// importPackage — build a matcher that handles:
|
|
103
|
+
// 1. exact npm package name "react-i18next"
|
|
104
|
+
// 2. path alias"@/hooks/useTranslation"
|
|
105
|
+
// 3. relative path "../../hooks/useTranslation"
|
|
106
|
+
if (cc.importPackage) {
|
|
107
|
+
this.importSourceMatchers.push(buildImportMatcher(cc.importPackage));
|
|
108
|
+
}
|
|
109
|
+
}
|
|
36
110
|
}
|
|
37
111
|
|
|
38
112
|
scan(): DetectedText[] {
|
|
@@ -40,24 +114,25 @@ export class AstScanner {
|
|
|
40
114
|
|
|
41
115
|
let ast: t.File;
|
|
42
116
|
try {
|
|
43
|
-
ast = parser.parse(content, {
|
|
44
|
-
|
|
117
|
+
ast = parser.parse(content, {
|
|
118
|
+
sourceType: 'module',
|
|
45
119
|
plugins: [
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
120
|
+
'jsx',
|
|
121
|
+
'typescript',
|
|
122
|
+
'decorators-legacy',
|
|
49
123
|
'classProperties',
|
|
50
124
|
'optionalChaining',
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
125
|
+
'nullishCoalescingOperator',
|
|
126
|
+
'dynamicImport',
|
|
127
|
+
'exportDefaultFrom',
|
|
54
128
|
],
|
|
55
129
|
errorRecovery: true,
|
|
56
130
|
});
|
|
57
|
-
|
|
131
|
+
} catch {
|
|
58
132
|
return this.regexFallbackScan();
|
|
59
133
|
}
|
|
60
134
|
|
|
135
|
+
// Collect translation function names from imports before scanning text
|
|
61
136
|
this.collectTranslationImports(ast);
|
|
62
137
|
|
|
63
138
|
traverse(ast, {
|
|
@@ -66,30 +141,30 @@ ast = parser.parse(content, {
|
|
|
66
141
|
if (!isHumanReadableText(text)) return;
|
|
67
142
|
if (this.isInsideTranslationCall(nodePath)) return;
|
|
68
143
|
this.addDetected(
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
144
|
+
text,
|
|
145
|
+
nodePath.node.loc?.start.line ?? 0,
|
|
146
|
+
nodePath.node.loc?.start.column ?? 0,
|
|
72
147
|
'jsx-text',
|
|
73
|
-
'JSXText'
|
|
74
|
-
|
|
148
|
+
'JSXText'
|
|
149
|
+
);
|
|
75
150
|
},
|
|
76
151
|
|
|
77
152
|
JSXAttribute: (nodePath) => {
|
|
78
153
|
const attrName = t.isJSXIdentifier(nodePath.node.name)
|
|
79
154
|
? nodePath.node.name.name
|
|
80
|
-
|
|
81
|
-
|
|
155
|
+
: '';
|
|
156
|
+
if (!TEXT_ATTRIBUTE_NAMES.has(attrName.toLowerCase())) return;
|
|
82
157
|
const valueNode = nodePath.node.value;
|
|
83
|
-
if (!t.isStringLiteral(valueNode)) return;
|
|
84
|
-
|
|
158
|
+
if (!t.isStringLiteral(valueNode)) return;
|
|
159
|
+
const text = normalizeText(valueNode.value);
|
|
85
160
|
if (!isHumanReadableText(text)) return;
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
161
|
+
if (this.isInsideTranslationCall(nodePath)) return;
|
|
162
|
+
const context = this.mapAttrToContext(attrName);
|
|
163
|
+
this.addDetected(
|
|
164
|
+
text,
|
|
90
165
|
valueNode.loc?.start.line ?? 0,
|
|
91
|
-
|
|
92
|
-
|
|
166
|
+
valueNode.loc?.start.column ?? 0,
|
|
167
|
+
context,
|
|
93
168
|
'JSXAttribute'
|
|
94
169
|
);
|
|
95
170
|
},
|
|
@@ -97,83 +172,115 @@ if (!t.isStringLiteral(valueNode)) return;
|
|
|
97
172
|
StringLiteral: (nodePath) => {
|
|
98
173
|
if (t.isImportDeclaration(nodePath.parent)) return;
|
|
99
174
|
if (t.isObjectProperty(nodePath.parent) && nodePath.parent.key === nodePath.node) return;
|
|
100
|
-
|
|
101
|
-
// Ignore className and similar
|
|
175
|
+
|
|
176
|
+
// Ignore className and similar CSS-class attributes
|
|
102
177
|
if (t.isJSXAttribute(nodePath.parent)) {
|
|
103
|
-
const attrName = t.isJSXIdentifier(nodePath.parent.name)
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
return;
|
|
178
|
+
const attrName = t.isJSXIdentifier(nodePath.parent.name)
|
|
179
|
+
? nodePath.parent.name.name.toLowerCase()
|
|
180
|
+
: '';
|
|
181
|
+
if (attrName === 'classname' || attrName === 'class') return;
|
|
182
|
+
return;
|
|
108
183
|
}
|
|
109
184
|
|
|
110
185
|
if (this.isInsideTranslationCall(nodePath)) return;
|
|
111
|
-
|
|
112
|
-
// Improve heuristic to avoid matching CSS classes / HTML tags in standard string literals
|
|
186
|
+
|
|
113
187
|
const val = nodePath.node.value;
|
|
114
|
-
if (
|
|
115
|
-
|
|
116
|
-
|
|
188
|
+
if (
|
|
189
|
+
/^[a-z][a-z0-9_.-]*$/.test(val) ||
|
|
190
|
+
/^#?[0-9a-fA-F]+$/.test(val) ||
|
|
191
|
+
(/^[\w-]+\s[\w- ]+$/.test(val) && !val.includes(','))
|
|
192
|
+
) {
|
|
193
|
+
return; // Likely a CSS class, ID, hex colour, or plain word pair
|
|
194
|
+
}
|
|
117
195
|
|
|
118
196
|
const text = normalizeText(nodePath.node.value);
|
|
119
|
-
|
|
197
|
+
if (!isHumanReadableText(text)) return;
|
|
120
198
|
this.addDetected(
|
|
121
199
|
text,
|
|
122
|
-
|
|
123
|
-
|
|
200
|
+
nodePath.node.loc?.start.line ?? 0,
|
|
201
|
+
nodePath.node.loc?.start.column ?? 0,
|
|
124
202
|
'string-literal',
|
|
125
|
-
|
|
203
|
+
'StringLiteral'
|
|
126
204
|
);
|
|
127
205
|
},
|
|
128
206
|
|
|
129
|
-
|
|
207
|
+
TemplateLiteral: (nodePath) => {
|
|
130
208
|
if (nodePath.node.expressions.length > 0) return;
|
|
131
209
|
if (this.isInsideTranslationCall(nodePath)) return;
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
text,
|
|
210
|
+
const text = normalizeText(nodePath.node.quasis[0]?.value.cooked ?? '');
|
|
211
|
+
if (!isHumanReadableText(text)) return;
|
|
212
|
+
this.addDetected(
|
|
213
|
+
text,
|
|
136
214
|
nodePath.node.loc?.start.line ?? 0,
|
|
137
215
|
nodePath.node.loc?.start.column ?? 0,
|
|
138
|
-
|
|
216
|
+
'template-literal',
|
|
139
217
|
'TemplateLiteral'
|
|
140
|
-
|
|
141
|
-
|
|
218
|
+
);
|
|
219
|
+
},
|
|
142
220
|
});
|
|
143
221
|
|
|
144
|
-
|
|
222
|
+
return this.detectedTexts;
|
|
145
223
|
}
|
|
146
224
|
|
|
225
|
+
/**
|
|
226
|
+
* Walk import declarations; when the source matches a known translation
|
|
227
|
+
* import, collect all named/default imports as translation function names.
|
|
228
|
+
*/
|
|
147
229
|
private collectTranslationImports(ast: t.File): void {
|
|
148
230
|
for (const node of ast.program.body) {
|
|
149
231
|
if (!t.isImportDeclaration(node)) continue;
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
232
|
+
const source = node.source.value;
|
|
233
|
+
if (!this.isTranslationImportSource(source)) continue;
|
|
234
|
+
for (const specifier of node.specifiers) {
|
|
235
|
+
if (t.isImportSpecifier(specifier) && t.isIdentifier(specifier.local)) {
|
|
236
|
+
this.translationFunctionNames.add(specifier.local.name);
|
|
237
|
+
}
|
|
238
|
+
// Default import: import useT from '../../hooks/useT'
|
|
239
|
+
if (t.isImportDefaultSpecifier(specifier) && t.isIdentifier(specifier.local)) {
|
|
153
240
|
this.translationFunctionNames.add(specifier.local.name);
|
|
154
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
|
+
}
|
|
155
246
|
}
|
|
156
247
|
}
|
|
157
248
|
}
|
|
158
249
|
|
|
159
|
-
|
|
250
|
+
/** Returns true if the import source string should be treated as a translation library. */
|
|
251
|
+
private isTranslationImportSource(source: string): boolean {
|
|
252
|
+
return this.importSourceMatchers.some((match) => match(source));
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
160
256
|
private isInsideTranslationCall(nodePath: any): boolean {
|
|
161
257
|
let current = nodePath.parentPath;
|
|
162
258
|
while (current) {
|
|
163
259
|
const node = current.node;
|
|
164
|
-
|
|
260
|
+
|
|
261
|
+
// Function-call style: t('key')
|
|
262
|
+
if (t.isCallExpression(node)) {
|
|
165
263
|
const callee = node.callee;
|
|
166
|
-
|
|
167
|
-
|
|
264
|
+
if (t.isIdentifier(callee) && this.translationFunctionNames.has(callee.name)) {
|
|
265
|
+
return true;
|
|
168
266
|
}
|
|
169
|
-
|
|
170
|
-
|
|
267
|
+
if (
|
|
268
|
+
t.isMemberExpression(callee) &&
|
|
171
269
|
t.isIdentifier(callee.property) &&
|
|
172
270
|
this.translationFunctionNames.has(callee.property.name)
|
|
173
|
-
|
|
271
|
+
) {
|
|
174
272
|
return true;
|
|
175
273
|
}
|
|
176
|
-
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
// Bracket-notation accessor: t['key'] (MemberExpression, computed=true)
|
|
277
|
+
if (t.isMemberExpression(node) && node.computed) {
|
|
278
|
+
const obj = node.object;
|
|
279
|
+
if (t.isIdentifier(obj) && this.translationFunctionNames.has(obj.name)) {
|
|
280
|
+
return true;
|
|
281
|
+
}
|
|
282
|
+
}
|
|
283
|
+
|
|
177
284
|
current = current.parentPath;
|
|
178
285
|
}
|
|
179
286
|
return false;
|
|
@@ -186,20 +293,21 @@ text,
|
|
|
186
293
|
context: TextContext,
|
|
187
294
|
nodeType: string
|
|
188
295
|
): void {
|
|
189
|
-
const key =
|
|
190
|
-
|
|
296
|
+
const key = generateKeyByStyle(
|
|
297
|
+
this.options.filePath,
|
|
191
298
|
text,
|
|
192
|
-
this.options.sourceRoot || 'src'
|
|
299
|
+
this.options.sourceRoot || 'src',
|
|
300
|
+
this.options.keyStyle || 'path'
|
|
193
301
|
);
|
|
194
302
|
this.detectedTexts.push({
|
|
195
303
|
filePath: this.options.filePath,
|
|
196
304
|
line,
|
|
197
305
|
column,
|
|
198
|
-
text,
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
306
|
+
text,
|
|
307
|
+
suggestedKey: key,
|
|
308
|
+
context,
|
|
309
|
+
nodeType,
|
|
310
|
+
alreadyTranslated: false,
|
|
203
311
|
});
|
|
204
312
|
}
|
|
205
313
|
|
|
@@ -217,24 +325,89 @@ text,
|
|
|
217
325
|
const jsxTextRegex = />([^<>{}\n]+)</g;
|
|
218
326
|
const lines = this.options.content.split('\n');
|
|
219
327
|
lines.forEach((line, idx) => {
|
|
220
|
-
|
|
221
|
-
|
|
328
|
+
let m: RegExpExecArray | null;
|
|
329
|
+
jsxTextRegex.lastIndex = 0;
|
|
222
330
|
while ((m = jsxTextRegex.exec(line)) !== null) {
|
|
223
331
|
const text = normalizeText(m[1]);
|
|
224
332
|
if (!isHumanReadableText(text)) continue;
|
|
225
|
-
|
|
333
|
+
const key = generateKeyByStyle(
|
|
334
|
+
this.options.filePath,
|
|
335
|
+
text,
|
|
336
|
+
this.options.sourceRoot || 'src',
|
|
337
|
+
this.options.keyStyle || 'path'
|
|
338
|
+
);
|
|
226
339
|
results.push({
|
|
227
|
-
|
|
228
|
-
|
|
340
|
+
filePath: this.options.filePath,
|
|
341
|
+
line: idx + 1,
|
|
229
342
|
column: m.index,
|
|
230
|
-
|
|
343
|
+
text,
|
|
231
344
|
suggestedKey: key,
|
|
232
|
-
|
|
345
|
+
context: 'jsx-text',
|
|
233
346
|
nodeType: 'regex-fallback',
|
|
234
|
-
|
|
347
|
+
alreadyTranslated: false,
|
|
235
348
|
});
|
|
236
349
|
}
|
|
237
350
|
});
|
|
238
351
|
return results;
|
|
239
352
|
}
|
|
240
353
|
}
|
|
354
|
+
|
|
355
|
+
/**
|
|
356
|
+
* Builds an import-source matcher function for the given `importPackage` value.
|
|
357
|
+
*
|
|
358
|
+
* Handles three cases:
|
|
359
|
+
*
|
|
360
|
+
* 1. Exact npm package name — "react-i18next", "my-i18n-lib"
|
|
361
|
+
* Matches when the import source string equals the value exactly.
|
|
362
|
+
*
|
|
363
|
+
* 2. Path alias — "@/hooks/useTranslation", "@/i18n"
|
|
364
|
+
* Matches when the import source equals the value OR ends with the
|
|
365
|
+
* normalised path suffix (with or without the alias prefix).
|
|
366
|
+
*
|
|
367
|
+
* 3. Relative path — "../../hooks/useTranslation", "./i18n/hook"
|
|
368
|
+
* Because relative paths resolve differently per file, we match by
|
|
369
|
+
* comparing the **last N segments** of the import source with the
|
|
370
|
+
* last N segments of the configured value (case-insensitive, stripping
|
|
371
|
+
* leading dots and slashes). This is intentionally lenient so that
|
|
372
|
+
* `hooks/useTranslation` matches both `../../hooks/useTranslation` and
|
|
373
|
+
* `./hooks/useTranslation`.
|
|
374
|
+
*
|
|
375
|
+
* Examples:
|
|
376
|
+
* importPackage="../../hooks/useTranslation"
|
|
377
|
+
* matches "../../hooks/useTranslation" ✓
|
|
378
|
+
* matches "./hooks/useTranslation" ✓ (same tail segments)
|
|
379
|
+
* matches "../hooks/useTranslation" ✓
|
|
380
|
+
* importPackage="@/i18n/hook"
|
|
381
|
+
* matches "@/i18n/hook"✓
|
|
382
|
+
* matches "i18n/hook" ✓
|
|
383
|
+
*/
|
|
384
|
+
function buildImportMatcher(importPackage: string): (source: string) => boolean {
|
|
385
|
+
// Normalise: strip leading ./ ../ @ and collapse slashes
|
|
386
|
+
const normalisedPkg = normalisePath(importPackage);
|
|
387
|
+
const pkgSegments = normalisedPkg.split('/').filter(Boolean);
|
|
388
|
+
|
|
389
|
+
return (source: string): boolean => {
|
|
390
|
+
// 1. Exact match (covers plain npm package names and exact alias paths)
|
|
391
|
+
if (source === importPackage) return true;
|
|
392
|
+
|
|
393
|
+
// 2. Suffix / tail-segment match
|
|
394
|
+
const normSource = normalisePath(source);
|
|
395
|
+
const srcSegments = normSource.split('/').filter(Boolean);
|
|
396
|
+
|
|
397
|
+
if (pkgSegments.length === 0) return false;
|
|
398
|
+
|
|
399
|
+
// Check if the source ends with the same N tail segments as the package
|
|
400
|
+
const n = pkgSegments.length;
|
|
401
|
+
if (srcSegments.length < n) return false;
|
|
402
|
+
const tail = srcSegments.slice(-n);
|
|
403
|
+
return tail.every((seg, i) => seg.toLowerCase() === pkgSegments[i].toLowerCase());
|
|
404
|
+
};
|
|
405
|
+
}
|
|
406
|
+
|
|
407
|
+
/** Strips leading dots, slashes and @ from a path string for comparison purposes. */
|
|
408
|
+
function normalisePath(p: string): string {
|
|
409
|
+
return p
|
|
410
|
+
.replace(/\\/g, '/')
|
|
411
|
+
.replace(/^(@\/|\.{1,2}\/)+/, '') // strip leading ./ ../ @/
|
|
412
|
+
.replace(/^@/, ''); // strip bare @ prefix
|
|
413
|
+
}
|
package/src/project-scanner.ts
CHANGED
|
@@ -27,36 +27,53 @@ export class ProjectScanner {
|
|
|
27
27
|
|
|
28
28
|
constructor(config: LocalizationConfig) {
|
|
29
29
|
this.config = config;
|
|
30
|
-
|
|
30
|
+
// Resolve to absolute path so key-generator can use path.relative()
|
|
31
|
+
// and never produce keys that include ancestor directory segments.
|
|
32
|
+
this.sourceRoot = path.resolve(process.cwd(), config.sourceDir);
|
|
31
33
|
this.assetScanner = new AssetScanner(config.aws?.legacyCdnPattern);
|
|
32
34
|
if (config.incrementalCache) {
|
|
33
|
-
|
|
34
|
-
|
|
35
|
+
this.cache = new IncrementalScanCache(
|
|
36
|
+
path.join(process.cwd(), config.cacheDir || '.ai-localize-cache')
|
|
35
37
|
);
|
|
36
38
|
}
|
|
37
39
|
}
|
|
38
40
|
|
|
39
41
|
async scan(options: ScanOptions = {}): Promise<ScanResult> {
|
|
40
42
|
const startTime = Date.now();
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
43
|
+
let filesToScan: string[] = [];
|
|
44
|
+
|
|
45
|
+
if (options.files?.length) {
|
|
46
|
+
filesToScan = options.files;
|
|
47
|
+
} else {
|
|
48
|
+
const allFiles = collectFiles(this.sourceRoot, SOURCE_EXTENSIONS, [
|
|
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
|
+
} else {
|
|
61
|
+
filesToScan = allFiles;
|
|
62
|
+
}
|
|
63
|
+
}
|
|
47
64
|
|
|
48
65
|
const allTexts: DetectedText[] = [];
|
|
49
66
|
const allAssets: AssetReference[] = [];
|
|
50
67
|
const allLegacyUrls: LegacyCdnUrl[] = [];
|
|
51
68
|
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
69
|
+
const chunkSize = Math.max(
|
|
70
|
+
1,
|
|
71
|
+
Math.min(50, Math.ceil(filesToScan.length / (os.cpus().length || 4)))
|
|
55
72
|
);
|
|
56
73
|
const chunks = this.chunkArray(filesToScan, chunkSize);
|
|
57
74
|
|
|
58
75
|
for (const chunk of chunks) {
|
|
59
|
-
const results = await Promise.all(chunk.map((f) => this.scanFile(f)));
|
|
76
|
+
const results = await Promise.all(chunk.map((f) => this.scanFile(f)));
|
|
60
77
|
for (const r of results) {
|
|
61
78
|
allTexts.push(...r.texts);
|
|
62
79
|
allAssets.push(...r.assets);
|
|
@@ -67,12 +84,12 @@ const results = await Promise.all(chunk.map((f) => this.scanFile(f)));
|
|
|
67
84
|
this.cache?.persist();
|
|
68
85
|
|
|
69
86
|
return {
|
|
70
|
-
|
|
71
|
-
|
|
87
|
+
framework: this.config.framework,
|
|
88
|
+
scannedFiles: filesToScan.length,
|
|
72
89
|
detectedTexts: allTexts,
|
|
73
|
-
|
|
90
|
+
assets: allAssets,
|
|
74
91
|
legacyCdnUrls: allLegacyUrls,
|
|
75
|
-
|
|
92
|
+
duration: Date.now() - startTime,
|
|
76
93
|
timestamp: new Date().toISOString(),
|
|
77
94
|
};
|
|
78
95
|
}
|
|
@@ -89,14 +106,25 @@ const results = await Promise.all(chunk.map((f) => this.scanFile(f)));
|
|
|
89
106
|
|
|
90
107
|
let content: string;
|
|
91
108
|
try {
|
|
92
|
-
|
|
93
|
-
|
|
109
|
+
const { readFileSync } = await import('fs');
|
|
110
|
+
content = readFileSync(filePath, 'utf-8');
|
|
94
111
|
} catch {
|
|
95
112
|
return { texts: [], assets: [], legacyUrls: [] };
|
|
96
|
-
|
|
113
|
+
}
|
|
97
114
|
|
|
98
|
-
|
|
99
|
-
|
|
115
|
+
// Pass the absolute sourceRoot so normalizePath uses path.relative()
|
|
116
|
+
// and strips only the project source directory — not ancestor segments.
|
|
117
|
+
// Also pass codemods config so the scanner recognises user-defined
|
|
118
|
+
// translation functions/hooks and skips already-translated strings.
|
|
119
|
+
// keyStyle controls whether keys are path-based (default) or SCREAMING_SNAKE.
|
|
120
|
+
const scanner = new AstScanner({
|
|
121
|
+
filePath,
|
|
122
|
+
content,
|
|
123
|
+
sourceRoot: this.sourceRoot,
|
|
124
|
+
codemodConfig: this.config.codemods,
|
|
125
|
+
keyStyle: this.config.keyStyle ?? 'path',
|
|
126
|
+
});
|
|
127
|
+
const texts = scanner.scan();
|
|
100
128
|
const { assets, legacyCdnUrls } = this.assetScanner.scanFile(filePath);
|
|
101
129
|
|
|
102
130
|
this.cache?.setCachedResult(filePath, texts);
|