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 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 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;
@@ -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)) return;
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
- if (/^[a-z][a-z0-9_.]+$/.test(nodePath.node.value)) return;
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
- if (!TRANSLATION_IMPORT_SOURCES.has(node.source.value)) continue;
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)(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
+ );
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.join(process.cwd(), config.sourceDir);
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
- const filesToScan = options.files?.length ? options.files : (0, import_ai_localize_shared4.collectFiles)(this.sourceRoot, import_ai_localize_shared4.SOURCE_EXTENSIONS, [
395
- ...import_ai_localize_shared4.DEFAULT_IGNORE_DIRS,
396
- ...this.config.ignorePatterns || []
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({ 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
+ });
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 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;
@@ -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)) return;
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
- if (/^[a-z][a-z0-9_.]+$/.test(nodePath.node.value)) return;
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
- if (!TRANSLATION_IMPORT_SOURCES.has(node.source.value)) continue;
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(this.options.filePath, text, this.options.sourceRoot || "src");
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.join(process.cwd(), config.sourceDir);
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
- const filesToScan = options.files?.length ? options.files : collectFiles(this.sourceRoot, SOURCE_EXTENSIONS, [
360
- ...DEFAULT_IGNORE_DIRS,
361
- ...this.config.ignorePatterns || []
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({ 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
+ });
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": "1.0.1",
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": "1.0.1",
21
- "ai-localize-config": "1.0.1"
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,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
- 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
- if (t.isJSXAttribute(nodePath.parent)) return;
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
- if (/^[a-z][a-z0-9_.]+$/.test(nodePath.node.value)) return;
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
- if (!isHumanReadableText(text)) return;
105
- this.addDetected(
106
- text,
107
- nodePath.node.loc?.start.line ?? 0,
108
- nodePath.node.loc?.start.column ?? 0,
109
- 'string-literal',
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
- TemplateLiteral: (nodePath) => {
115
- if (nodePath.node.expressions.length > 0) return;
116
- if (this.isInsideTranslationCall(nodePath)) return;
117
- const text = normalizeText(nodePath.node.quasis[0]?.value.cooked ?? '');
118
- if (!isHumanReadableText(text)) return;
119
- this.addDetected(
120
- text,
121
- nodePath.node.loc?.start.line ?? 0,
122
- nodePath.node.loc?.start.column ?? 0,
123
- 'template-literal',
124
- 'TemplateLiteral'
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
- if (!TRANSLATION_IMPORT_SOURCES.has(node.source.value)) continue;
136
- for (const specifier of node.specifiers) {
137
- 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)) {
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
- return true;
153
- }
154
- if (
155
- t.isMemberExpression(callee) &&
156
- t.isIdentifier(callee.property) &&
157
- 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)
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
- this.options.sourceRoot || 'src'
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
- suggestedKey: key,
185
- context,
186
- nodeType,
187
- alreadyTranslated: false,
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
- jsxTextRegex.lastIndex = 0;
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
- 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
+ );
211
326
  results.push({
212
- filePath: this.options.filePath,
213
- line: idx + 1,
327
+ filePath: this.options.filePath,
328
+ line: idx + 1,
214
329
  column: m.index,
215
- text,
216
- suggestedKey: key,
217
- context: 'jsx-text',
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
+ }
@@ -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
  }