ai-localize-scanner 2.0.0 → 2.0.3

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