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