ai-localize-scanner 1.0.1 → 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 +22 -0
- package/dist/index.d.mts +37 -1
- package/dist/index.d.ts +37 -1
- package/dist/index.js +104 -12
- package/dist/index.mjs +104 -12
- package/package.json +3 -3
- package/src/ast-scanner.ts +256 -81
- package/src/project-scanner.ts +44 -18
package/CHANGELOG.md
ADDED
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
# ai-localize-scanner
|
|
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
|
+
|
|
12
|
+
## 2.0.0
|
|
13
|
+
|
|
14
|
+
### Major Changes
|
|
15
|
+
|
|
16
|
+
- versoion change
|
|
17
|
+
|
|
18
|
+
### Patch Changes
|
|
19
|
+
|
|
20
|
+
- Updated dependencies
|
|
21
|
+
- ai-localize-config@2.0.0
|
|
22
|
+
- ai-localize-shared@2.0.0
|
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;
|
|
@@ -111,9 +134,16 @@ var AstScanner = class {
|
|
|
111
134
|
StringLiteral: (nodePath) => {
|
|
112
135
|
if (t.isImportDeclaration(nodePath.parent)) return;
|
|
113
136
|
if (t.isObjectProperty(nodePath.parent) && nodePath.parent.key === nodePath.node) return;
|
|
114
|
-
if (t.isJSXAttribute(nodePath.parent))
|
|
137
|
+
if (t.isJSXAttribute(nodePath.parent)) {
|
|
138
|
+
const attrName = t.isJSXIdentifier(nodePath.parent.name) ? nodePath.parent.name.name.toLowerCase() : "";
|
|
139
|
+
if (attrName === "classname" || attrName === "class") return;
|
|
140
|
+
return;
|
|
141
|
+
}
|
|
115
142
|
if (this.isInsideTranslationCall(nodePath)) return;
|
|
116
|
-
|
|
143
|
+
const val = nodePath.node.value;
|
|
144
|
+
if (/^[a-z][a-z0-9_.-]*$/.test(val) || /^#?[0-9a-fA-F]+$/.test(val) || /^[\w-]+\s[\w- ]+$/.test(val) && !val.includes(",")) {
|
|
145
|
+
return;
|
|
146
|
+
}
|
|
117
147
|
const text = (0, import_ai_localize_shared.normalizeText)(nodePath.node.value);
|
|
118
148
|
if (!(0, import_ai_localize_shared.isHumanReadableText)(text)) return;
|
|
119
149
|
this.addDetected(
|
|
@@ -140,17 +170,32 @@ var AstScanner = class {
|
|
|
140
170
|
});
|
|
141
171
|
return this.detectedTexts;
|
|
142
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
|
+
*/
|
|
143
177
|
collectTranslationImports(ast) {
|
|
144
178
|
for (const node of ast.program.body) {
|
|
145
179
|
if (!t.isImportDeclaration(node)) continue;
|
|
146
|
-
|
|
180
|
+
const source = node.source.value;
|
|
181
|
+
if (!this.isTranslationImportSource(source)) continue;
|
|
147
182
|
for (const specifier of node.specifiers) {
|
|
148
183
|
if (t.isImportSpecifier(specifier) && t.isIdentifier(specifier.local)) {
|
|
149
184
|
this.translationFunctionNames.add(specifier.local.name);
|
|
150
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
|
+
}
|
|
151
192
|
}
|
|
152
193
|
}
|
|
153
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
|
+
}
|
|
154
199
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
155
200
|
isInsideTranslationCall(nodePath) {
|
|
156
201
|
let current = nodePath.parentPath;
|
|
@@ -165,6 +210,12 @@ var AstScanner = class {
|
|
|
165
210
|
return true;
|
|
166
211
|
}
|
|
167
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
|
+
}
|
|
168
219
|
current = current.parentPath;
|
|
169
220
|
}
|
|
170
221
|
return false;
|
|
@@ -204,7 +255,11 @@ var AstScanner = class {
|
|
|
204
255
|
while ((m = jsxTextRegex.exec(line)) !== null) {
|
|
205
256
|
const text = (0, import_ai_localize_shared.normalizeText)(m[1]);
|
|
206
257
|
if (!(0, import_ai_localize_shared.isHumanReadableText)(text)) continue;
|
|
207
|
-
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
|
+
);
|
|
208
263
|
results.push({
|
|
209
264
|
filePath: this.options.filePath,
|
|
210
265
|
line: idx + 1,
|
|
@@ -220,6 +275,23 @@ var AstScanner = class {
|
|
|
220
275
|
return results;
|
|
221
276
|
}
|
|
222
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
|
+
}
|
|
223
295
|
|
|
224
296
|
// src/asset-scanner.ts
|
|
225
297
|
var fs = __toESM(require("fs"));
|
|
@@ -381,7 +453,7 @@ var ProjectScanner = class {
|
|
|
381
453
|
assetScanner;
|
|
382
454
|
constructor(config) {
|
|
383
455
|
this.config = config;
|
|
384
|
-
this.sourceRoot = path3.
|
|
456
|
+
this.sourceRoot = path3.resolve(process.cwd(), config.sourceDir);
|
|
385
457
|
this.assetScanner = new AssetScanner(config.aws?.legacyCdnPattern);
|
|
386
458
|
if (config.incrementalCache) {
|
|
387
459
|
this.cache = new IncrementalScanCache(
|
|
@@ -391,10 +463,25 @@ var ProjectScanner = class {
|
|
|
391
463
|
}
|
|
392
464
|
async scan(options = {}) {
|
|
393
465
|
const startTime = Date.now();
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
|
|
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
|
+
}
|
|
398
485
|
const allTexts = [];
|
|
399
486
|
const allAssets = [];
|
|
400
487
|
const allLegacyUrls = [];
|
|
@@ -434,7 +521,12 @@ var ProjectScanner = class {
|
|
|
434
521
|
} catch {
|
|
435
522
|
return { texts: [], assets: [], legacyUrls: [] };
|
|
436
523
|
}
|
|
437
|
-
const scanner = new AstScanner({
|
|
524
|
+
const scanner = new AstScanner({
|
|
525
|
+
filePath,
|
|
526
|
+
content,
|
|
527
|
+
sourceRoot: this.sourceRoot,
|
|
528
|
+
codemodConfig: this.config.codemods
|
|
529
|
+
});
|
|
438
530
|
const texts = scanner.scan();
|
|
439
531
|
const { assets, legacyCdnUrls } = this.assetScanner.scanFile(filePath);
|
|
440
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;
|
|
@@ -76,9 +99,16 @@ var AstScanner = class {
|
|
|
76
99
|
StringLiteral: (nodePath) => {
|
|
77
100
|
if (t.isImportDeclaration(nodePath.parent)) return;
|
|
78
101
|
if (t.isObjectProperty(nodePath.parent) && nodePath.parent.key === nodePath.node) return;
|
|
79
|
-
if (t.isJSXAttribute(nodePath.parent))
|
|
102
|
+
if (t.isJSXAttribute(nodePath.parent)) {
|
|
103
|
+
const attrName = t.isJSXIdentifier(nodePath.parent.name) ? nodePath.parent.name.name.toLowerCase() : "";
|
|
104
|
+
if (attrName === "classname" || attrName === "class") return;
|
|
105
|
+
return;
|
|
106
|
+
}
|
|
80
107
|
if (this.isInsideTranslationCall(nodePath)) return;
|
|
81
|
-
|
|
108
|
+
const val = nodePath.node.value;
|
|
109
|
+
if (/^[a-z][a-z0-9_.-]*$/.test(val) || /^#?[0-9a-fA-F]+$/.test(val) || /^[\w-]+\s[\w- ]+$/.test(val) && !val.includes(",")) {
|
|
110
|
+
return;
|
|
111
|
+
}
|
|
82
112
|
const text = normalizeText(nodePath.node.value);
|
|
83
113
|
if (!isHumanReadableText(text)) return;
|
|
84
114
|
this.addDetected(
|
|
@@ -105,17 +135,32 @@ var AstScanner = class {
|
|
|
105
135
|
});
|
|
106
136
|
return this.detectedTexts;
|
|
107
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
|
+
*/
|
|
108
142
|
collectTranslationImports(ast) {
|
|
109
143
|
for (const node of ast.program.body) {
|
|
110
144
|
if (!t.isImportDeclaration(node)) continue;
|
|
111
|
-
|
|
145
|
+
const source = node.source.value;
|
|
146
|
+
if (!this.isTranslationImportSource(source)) continue;
|
|
112
147
|
for (const specifier of node.specifiers) {
|
|
113
148
|
if (t.isImportSpecifier(specifier) && t.isIdentifier(specifier.local)) {
|
|
114
149
|
this.translationFunctionNames.add(specifier.local.name);
|
|
115
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
|
+
}
|
|
116
157
|
}
|
|
117
158
|
}
|
|
118
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
|
+
}
|
|
119
164
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
120
165
|
isInsideTranslationCall(nodePath) {
|
|
121
166
|
let current = nodePath.parentPath;
|
|
@@ -130,6 +175,12 @@ var AstScanner = class {
|
|
|
130
175
|
return true;
|
|
131
176
|
}
|
|
132
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
|
+
}
|
|
133
184
|
current = current.parentPath;
|
|
134
185
|
}
|
|
135
186
|
return false;
|
|
@@ -169,7 +220,11 @@ var AstScanner = class {
|
|
|
169
220
|
while ((m = jsxTextRegex.exec(line)) !== null) {
|
|
170
221
|
const text = normalizeText(m[1]);
|
|
171
222
|
if (!isHumanReadableText(text)) continue;
|
|
172
|
-
const key = generateLocaleKey(
|
|
223
|
+
const key = generateLocaleKey(
|
|
224
|
+
this.options.filePath,
|
|
225
|
+
text,
|
|
226
|
+
this.options.sourceRoot || "src"
|
|
227
|
+
);
|
|
173
228
|
results.push({
|
|
174
229
|
filePath: this.options.filePath,
|
|
175
230
|
line: idx + 1,
|
|
@@ -185,6 +240,23 @@ var AstScanner = class {
|
|
|
185
240
|
return results;
|
|
186
241
|
}
|
|
187
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
|
+
}
|
|
188
260
|
|
|
189
261
|
// src/asset-scanner.ts
|
|
190
262
|
import * as fs from "fs";
|
|
@@ -346,7 +418,7 @@ var ProjectScanner = class {
|
|
|
346
418
|
assetScanner;
|
|
347
419
|
constructor(config) {
|
|
348
420
|
this.config = config;
|
|
349
|
-
this.sourceRoot = path3.
|
|
421
|
+
this.sourceRoot = path3.resolve(process.cwd(), config.sourceDir);
|
|
350
422
|
this.assetScanner = new AssetScanner(config.aws?.legacyCdnPattern);
|
|
351
423
|
if (config.incrementalCache) {
|
|
352
424
|
this.cache = new IncrementalScanCache(
|
|
@@ -356,10 +428,25 @@ var ProjectScanner = class {
|
|
|
356
428
|
}
|
|
357
429
|
async scan(options = {}) {
|
|
358
430
|
const startTime = Date.now();
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
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
|
+
}
|
|
363
450
|
const allTexts = [];
|
|
364
451
|
const allAssets = [];
|
|
365
452
|
const allLegacyUrls = [];
|
|
@@ -399,7 +486,12 @@ var ProjectScanner = class {
|
|
|
399
486
|
} catch {
|
|
400
487
|
return { texts: [], assets: [], legacyUrls: [] };
|
|
401
488
|
}
|
|
402
|
-
const scanner = new AstScanner({
|
|
489
|
+
const scanner = new AstScanner({
|
|
490
|
+
filePath,
|
|
491
|
+
content,
|
|
492
|
+
sourceRoot: this.sourceRoot,
|
|
493
|
+
codemodConfig: this.config.codemods
|
|
494
|
+
});
|
|
403
495
|
const texts = scanner.scan();
|
|
404
496
|
const { assets, legacyCdnUrls } = this.assetScanner.scanFile(filePath);
|
|
405
497
|
this.cache?.setCachedResult(filePath, texts);
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "ai-localize-scanner",
|
|
3
|
-
"version": "
|
|
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": "
|
|
21
|
-
"ai-localize-config": "
|
|
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,107 +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
|
-
|
|
164
|
+
|
|
165
|
+
// Ignore className and similar CSS-class attributes
|
|
166
|
+
if (t.isJSXAttribute(nodePath.parent)) {
|
|
167
|
+
const attrName = t.isJSXIdentifier(nodePath.parent.name)
|
|
168
|
+
? nodePath.parent.name.name.toLowerCase()
|
|
169
|
+
: '';
|
|
170
|
+
if (attrName === 'classname' || attrName === 'class') return;
|
|
171
|
+
return;
|
|
172
|
+
}
|
|
173
|
+
|
|
101
174
|
if (this.isInsideTranslationCall(nodePath)) return;
|
|
102
|
-
|
|
175
|
+
|
|
176
|
+
const val = nodePath.node.value;
|
|
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
|
+
}
|
|
184
|
+
|
|
103
185
|
const text = normalizeText(nodePath.node.value);
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
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',
|
|
110
192
|
'StringLiteral'
|
|
111
193
|
);
|
|
112
194
|
},
|
|
113
195
|
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
text,
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
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 ?? '');
|
|
200
|
+
if (!isHumanReadableText(text)) return;
|
|
201
|
+
this.addDetected(
|
|
202
|
+
text,
|
|
203
|
+
nodePath.node.loc?.start.line ?? 0,
|
|
204
|
+
nodePath.node.loc?.start.column ?? 0,
|
|
205
|
+
'template-literal',
|
|
206
|
+
'TemplateLiteral'
|
|
207
|
+
);
|
|
208
|
+
},
|
|
127
209
|
});
|
|
128
210
|
|
|
129
211
|
return this.detectedTexts;
|
|
130
212
|
}
|
|
131
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
|
+
*/
|
|
132
218
|
private collectTranslationImports(ast: t.File): void {
|
|
133
219
|
for (const node of ast.program.body) {
|
|
134
220
|
if (!t.isImportDeclaration(node)) continue;
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
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)) {
|
|
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)) {
|
|
138
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);
|
|
139
234
|
}
|
|
140
235
|
}
|
|
141
236
|
}
|
|
142
237
|
}
|
|
143
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
|
+
|
|
144
244
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
145
245
|
private isInsideTranslationCall(nodePath: any): boolean {
|
|
146
246
|
let current = nodePath.parentPath;
|
|
147
247
|
while (current) {
|
|
148
248
|
const node = current.node;
|
|
249
|
+
|
|
250
|
+
// Function-call style: t('key')
|
|
149
251
|
if (t.isCallExpression(node)) {
|
|
150
252
|
const callee = node.callee;
|
|
151
253
|
if (t.isIdentifier(callee) && this.translationFunctionNames.has(callee.name)) {
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
254
|
+
return true;
|
|
255
|
+
}
|
|
256
|
+
if (
|
|
257
|
+
t.isMemberExpression(callee) &&
|
|
258
|
+
t.isIdentifier(callee.property) &&
|
|
259
|
+
this.translationFunctionNames.has(callee.property.name)
|
|
158
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)) {
|
|
159
269
|
return true;
|
|
160
270
|
}
|
|
161
|
-
|
|
271
|
+
}
|
|
272
|
+
|
|
162
273
|
current = current.parentPath;
|
|
163
274
|
}
|
|
164
275
|
return false;
|
|
@@ -174,19 +285,19 @@ text,
|
|
|
174
285
|
const key = generateLocaleKey(
|
|
175
286
|
this.options.filePath,
|
|
176
287
|
text,
|
|
177
|
-
|
|
288
|
+
this.options.sourceRoot || 'src'
|
|
178
289
|
);
|
|
179
290
|
this.detectedTexts.push({
|
|
180
291
|
filePath: this.options.filePath,
|
|
181
292
|
line,
|
|
182
293
|
column,
|
|
183
|
-
text,
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
294
|
+
text,
|
|
295
|
+
suggestedKey: key,
|
|
296
|
+
context,
|
|
297
|
+
nodeType,
|
|
298
|
+
alreadyTranslated: false,
|
|
299
|
+
});
|
|
300
|
+
}
|
|
190
301
|
|
|
191
302
|
private mapAttrToContext(attrName: string): TextContext {
|
|
192
303
|
const lower = attrName.toLowerCase();
|
|
@@ -203,23 +314,87 @@ text,
|
|
|
203
314
|
const lines = this.options.content.split('\n');
|
|
204
315
|
lines.forEach((line, idx) => {
|
|
205
316
|
let m: RegExpExecArray | null;
|
|
206
|
-
|
|
317
|
+
jsxTextRegex.lastIndex = 0;
|
|
207
318
|
while ((m = jsxTextRegex.exec(line)) !== null) {
|
|
208
319
|
const text = normalizeText(m[1]);
|
|
209
320
|
if (!isHumanReadableText(text)) continue;
|
|
210
|
-
|
|
321
|
+
const key = generateLocaleKey(
|
|
322
|
+
this.options.filePath,
|
|
323
|
+
text,
|
|
324
|
+
this.options.sourceRoot || 'src'
|
|
325
|
+
);
|
|
211
326
|
results.push({
|
|
212
|
-
|
|
213
|
-
|
|
327
|
+
filePath: this.options.filePath,
|
|
328
|
+
line: idx + 1,
|
|
214
329
|
column: m.index,
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
330
|
+
text,
|
|
331
|
+
suggestedKey: key,
|
|
332
|
+
context: 'jsx-text',
|
|
218
333
|
nodeType: 'regex-fallback',
|
|
219
334
|
alreadyTranslated: false,
|
|
220
|
-
|
|
221
|
-
|
|
335
|
+
});
|
|
336
|
+
}
|
|
222
337
|
});
|
|
223
338
|
return results;
|
|
224
339
|
}
|
|
225
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
|
}
|