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 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 TRANSLATION_IMPORT_SOURCES = /* @__PURE__ */ new Set([
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
- translationFunctionNames = /* @__PURE__ */ new Set(["t", "$t", "i18n", "translate"]);
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
- if (!TRANSLATION_IMPORT_SOURCES.has(node.source.value)) continue;
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)(this.options.filePath, text, this.options.sourceRoot || "src");
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.join(process.cwd(), config.sourceDir);
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
- const filesToScan = options.files?.length ? options.files : (0, import_ai_localize_shared4.collectFiles)(this.sourceRoot, import_ai_localize_shared4.SOURCE_EXTENSIONS, [
404
- ...import_ai_localize_shared4.DEFAULT_IGNORE_DIRS,
405
- ...this.config.ignorePatterns || []
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({ filePath, content, sourceRoot: this.config.sourceDir });
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 TRANSLATION_IMPORT_SOURCES = /* @__PURE__ */ new Set([
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
- translationFunctionNames = /* @__PURE__ */ new Set(["t", "$t", "i18n", "translate"]);
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
- if (!TRANSLATION_IMPORT_SOURCES.has(node.source.value)) continue;
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(this.options.filePath, text, this.options.sourceRoot || "src");
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.join(process.cwd(), config.sourceDir);
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
- const filesToScan = options.files?.length ? options.files : collectFiles(this.sourceRoot, SOURCE_EXTENSIONS, [
369
- ...DEFAULT_IGNORE_DIRS,
370
- ...this.config.ignorePatterns || []
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({ filePath, content, sourceRoot: this.config.sourceDir });
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.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.0",
21
- "ai-localize-config": "2.0.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",
@@ -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
- const TRANSLATION_IMPORT_SOURCES = new Set([
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
- '@ngx-translate/core',
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
- private translationFunctionNames = new Set<string>(['t', '$t', 'i18n', 'translate']);
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
- let ast: t.File;
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
- 'jsx',
47
- 'typescript',
48
- 'decorators-legacy',
49
- 'classProperties',
50
- 'optionalChaining',
51
- 'nullishCoalescingOperator',
52
- 'dynamicImport',
53
- 'exportDefaultFrom',
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
- const text = normalizeText(nodePath.node.value);
129
+ const text = normalizeText(nodePath.node.value);
66
130
  if (!isHumanReadableText(text)) return;
67
131
  if (this.isInsideTranslationCall(nodePath)) return;
68
- this.addDetected(
69
- text,
70
- nodePath.node.loc?.start.line ?? 0,
132
+ this.addDetected(
133
+ text,
134
+ nodePath.node.loc?.start.line ?? 0,
71
135
  nodePath.node.loc?.start.column ?? 0,
72
- 'jsx-text',
73
- 'JSXText'
74
- );
136
+ 'jsx-text',
137
+ 'JSXText'
138
+ );
75
139
  },
76
140
 
77
- JSXAttribute: (nodePath) => {
141
+ JSXAttribute: (nodePath) => {
78
142
  const attrName = t.isJSXIdentifier(nodePath.node.name)
79
- ? nodePath.node.name.name
80
- : '';
81
- if (!TEXT_ATTRIBUTE_NAMES.has(attrName.toLowerCase())) return;
82
- const valueNode = nodePath.node.value;
83
- if (!t.isStringLiteral(valueNode)) return;
84
- const text = normalizeText(valueNode.value);
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
- if (this.isInsideTranslationCall(nodePath)) return;
87
- const context = this.mapAttrToContext(attrName);
150
+ if (this.isInsideTranslationCall(nodePath)) return;
151
+ const context = this.mapAttrToContext(attrName);
88
152
  this.addDetected(
89
- text,
153
+ text,
90
154
  valueNode.loc?.start.line ?? 0,
91
- valueNode.loc?.start.column ?? 0,
155
+ valueNode.loc?.start.column ?? 0,
92
156
  context,
93
157
  'JSXAttribute'
94
158
  );
95
159
  },
96
160
 
97
161
  StringLiteral: (nodePath) => {
98
- if (t.isImportDeclaration(nodePath.parent)) return;
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 attributes containing CSS classes
164
+
165
+ // Ignore className and similar CSS-class attributes
102
166
  if (t.isJSXAttribute(nodePath.parent)) {
103
- const attrName = t.isJSXIdentifier(nodePath.parent.name) ? nodePath.parent.name.name.toLowerCase() : '';
104
- if (attrName === 'classname' || attrName === 'class') {
105
- return;
106
- }
107
- return; // Ignore other unhandled JSX attributes here as well based on previous logic
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 (/^[a-z][a-z0-9_.-]*$/.test(val) || /^#?[0-9a-fA-F]+$/.test(val) || (/^[\w-]+\s[\w- ]+$/.test(val) && !val.includes(','))) {
115
- return; // Probably a CSS class name, ID, color hex, or just words without punctuation that are often classes
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
- nodePath.node.loc?.start.line ?? 0,
123
- nodePath.node.loc?.start.column ?? 0,
124
- 'string-literal',
125
- 'StringLiteral'
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
- if (!TRANSLATION_IMPORT_SOURCES.has(node.source.value)) continue;
151
- for (const specifier of node.specifiers) {
152
- if (t.isImportSpecifier(specifier) && t.isIdentifier(specifier.local)) {
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
- return true;
168
- }
169
- if (
170
- t.isMemberExpression(callee) &&
171
- t.isIdentifier(callee.property) &&
172
- this.translationFunctionNames.has(callee.property.name)
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
- this.options.sourceRoot || 'src'
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
- suggestedKey: key,
200
- context,
201
- nodeType,
202
- alreadyTranslated: false,
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
- jsxTextRegex.lastIndex = 0;
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
- const key = generateLocaleKey(this.options.filePath, text, this.options.sourceRoot || 'src');
321
+ const key = generateLocaleKey(
322
+ this.options.filePath,
323
+ text,
324
+ this.options.sourceRoot || 'src'
325
+ );
226
326
  results.push({
227
- filePath: this.options.filePath,
228
- line: idx + 1,
327
+ filePath: this.options.filePath,
328
+ line: idx + 1,
229
329
  column: m.index,
230
- text,
231
- suggestedKey: key,
232
- context: 'jsx-text',
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
+ }
@@ -27,52 +27,69 @@ export class ProjectScanner {
27
27
 
28
28
  constructor(config: LocalizationConfig) {
29
29
  this.config = config;
30
- this.sourceRoot = path.join(process.cwd(), config.sourceDir);
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
- path.join(process.cwd(), config.cacheDir || '.ai-localize-cache')
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
- const filesToScan = options.files?.length
42
- ? options.files
43
- : collectFiles(this.sourceRoot, SOURCE_EXTENSIONS, [
44
- ...DEFAULT_IGNORE_DIRS,
45
- ...(this.config.ignorePatterns || []),
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
- const allAssets: AssetReference[] = [];
66
+ const allAssets: AssetReference[] = [];
50
67
  const allLegacyUrls: LegacyCdnUrl[] = [];
51
68
 
52
- const chunkSize = Math.max(
53
- 1,
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
- for (const r of results) {
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
- this.cache?.persist();
84
+ this.cache?.persist();
68
85
 
69
86
  return {
70
- framework: this.config.framework,
87
+ framework: this.config.framework,
71
88
  scannedFiles: filesToScan.length,
72
89
  detectedTexts: allTexts,
73
90
  assets: allAssets,
74
91
  legacyCdnUrls: allLegacyUrls,
75
- duration: Date.now() - startTime,
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
- const scanner = new AstScanner({ filePath, content, sourceRoot: this.config.sourceDir });
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
- chunks.push(array.slice(i, i + size));
136
+ chunks.push(array.slice(i, i + size));
111
137
  }
112
138
  return chunks;
113
139
  }