ai-localize-scanner 2.0.1 → 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,26 @@
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
+
3
24
  ## 2.0.1
4
25
 
5
26
  ### Patch Changes
package/dist/index.d.mts CHANGED
@@ -1,9 +1,20 @@
1
- import { CodemodConfig, 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;
7
18
  /**
8
19
  * Optional codemod config from ai-localize.config.json.
9
20
  *
@@ -13,7 +24,7 @@ interface AstScanOptions {
13
24
  * importPackage — matched against import source strings. Supports:
14
25
  * - npm package names: "react-i18next", "my-i18n-lib"
15
26
  * - path aliases: "@/hooks/useTranslation", "@/i18n"
16
- * - relative paths: "../../hooks/useTranslation"
27
+ * - relative paths: "../../hooks/useTranslation"
17
28
  * Matching is done by checking whether the import source equals the value
18
29
  * OR ends with the last path segment(s) of the value (normalised).
19
30
  *
@@ -37,10 +48,10 @@ declare class AstScanner {
37
48
  /** Identifiers whose call/bracket expressions contain already-translated strings. */
38
49
  private translationFunctionNames;
39
50
  /**
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
- */
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
+ */
44
55
  private importSourceMatchers;
45
56
  constructor(options: AstScanOptions);
46
57
  scan(): DetectedText[];
package/dist/index.d.ts CHANGED
@@ -1,9 +1,20 @@
1
- import { CodemodConfig, 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;
7
18
  /**
8
19
  * Optional codemod config from ai-localize.config.json.
9
20
  *
@@ -13,7 +24,7 @@ interface AstScanOptions {
13
24
  * importPackage — matched against import source strings. Supports:
14
25
  * - npm package names: "react-i18next", "my-i18n-lib"
15
26
  * - path aliases: "@/hooks/useTranslation", "@/i18n"
16
- * - relative paths: "../../hooks/useTranslation"
27
+ * - relative paths: "../../hooks/useTranslation"
17
28
  * Matching is done by checking whether the import source equals the value
18
29
  * OR ends with the last path segment(s) of the value (normalised).
19
30
  *
@@ -37,10 +48,10 @@ declare class AstScanner {
37
48
  /** Identifiers whose call/bracket expressions contain already-translated strings. */
38
49
  private translationFunctionNames;
39
50
  /**
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
- */
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
+ */
44
55
  private importSourceMatchers;
45
56
  constructor(options: AstScanOptions);
46
57
  scan(): DetectedText[];
package/dist/index.js CHANGED
@@ -55,10 +55,10 @@ var AstScanner = class {
55
55
  /** Identifiers whose call/bracket expressions contain already-translated strings. */
56
56
  translationFunctionNames;
57
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
- */
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
62
  importSourceMatchers;
63
63
  constructor(options) {
64
64
  this.options = options;
@@ -221,10 +221,11 @@ var AstScanner = class {
221
221
  return false;
222
222
  }
223
223
  addDetected(text, line, column, context, nodeType) {
224
- const key = (0, import_ai_localize_shared.generateLocaleKey)(
224
+ const key = (0, import_ai_localize_shared.generateKeyByStyle)(
225
225
  this.options.filePath,
226
226
  text,
227
- this.options.sourceRoot || "src"
227
+ this.options.sourceRoot || "src",
228
+ this.options.keyStyle || "path"
228
229
  );
229
230
  this.detectedTexts.push({
230
231
  filePath: this.options.filePath,
@@ -255,10 +256,11 @@ var AstScanner = class {
255
256
  while ((m = jsxTextRegex.exec(line)) !== null) {
256
257
  const text = (0, import_ai_localize_shared.normalizeText)(m[1]);
257
258
  if (!(0, import_ai_localize_shared.isHumanReadableText)(text)) continue;
258
- const key = (0, import_ai_localize_shared.generateLocaleKey)(
259
+ const key = (0, import_ai_localize_shared.generateKeyByStyle)(
259
260
  this.options.filePath,
260
261
  text,
261
- this.options.sourceRoot || "src"
262
+ this.options.sourceRoot || "src",
263
+ this.options.keyStyle || "path"
262
264
  );
263
265
  results.push({
264
266
  filePath: this.options.filePath,
@@ -525,7 +527,8 @@ var ProjectScanner = class {
525
527
  filePath,
526
528
  content,
527
529
  sourceRoot: this.sourceRoot,
528
- codemodConfig: this.config.codemods
530
+ codemodConfig: this.config.codemods,
531
+ keyStyle: this.config.keyStyle ?? "path"
529
532
  });
530
533
  const texts = scanner.scan();
531
534
  const { assets, legacyCdnUrls } = this.assetScanner.scanFile(filePath);
package/dist/index.mjs CHANGED
@@ -6,7 +6,7 @@ import {
6
6
  isHumanReadableText,
7
7
  normalizeText,
8
8
  TEXT_ATTRIBUTE_NAMES,
9
- generateLocaleKey
9
+ generateKeyByStyle
10
10
  } from "ai-localize-shared";
11
11
  var BUILTIN_TRANSLATION_IMPORT_SOURCES = /* @__PURE__ */ new Set([
12
12
  "react-i18next",
@@ -20,10 +20,10 @@ var AstScanner = class {
20
20
  /** Identifiers whose call/bracket expressions contain already-translated strings. */
21
21
  translationFunctionNames;
22
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
- */
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
27
  importSourceMatchers;
28
28
  constructor(options) {
29
29
  this.options = options;
@@ -186,10 +186,11 @@ var AstScanner = class {
186
186
  return false;
187
187
  }
188
188
  addDetected(text, line, column, context, nodeType) {
189
- const key = generateLocaleKey(
189
+ const key = generateKeyByStyle(
190
190
  this.options.filePath,
191
191
  text,
192
- this.options.sourceRoot || "src"
192
+ this.options.sourceRoot || "src",
193
+ this.options.keyStyle || "path"
193
194
  );
194
195
  this.detectedTexts.push({
195
196
  filePath: this.options.filePath,
@@ -220,10 +221,11 @@ var AstScanner = class {
220
221
  while ((m = jsxTextRegex.exec(line)) !== null) {
221
222
  const text = normalizeText(m[1]);
222
223
  if (!isHumanReadableText(text)) continue;
223
- const key = generateLocaleKey(
224
+ const key = generateKeyByStyle(
224
225
  this.options.filePath,
225
226
  text,
226
- this.options.sourceRoot || "src"
227
+ this.options.sourceRoot || "src",
228
+ this.options.keyStyle || "path"
227
229
  );
228
230
  results.push({
229
231
  filePath: this.options.filePath,
@@ -490,7 +492,8 @@ var ProjectScanner = class {
490
492
  filePath,
491
493
  content,
492
494
  sourceRoot: this.sourceRoot,
493
- codemodConfig: this.config.codemods
495
+ codemodConfig: this.config.codemods,
496
+ keyStyle: this.config.keyStyle ?? "path"
494
497
  });
495
498
  const texts = scanner.scan();
496
499
  const { assets, legacyCdnUrls } = this.assetScanner.scanFile(filePath);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "ai-localize-scanner",
3
- "version": "2.0.1",
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.1",
21
- "ai-localize-config": "2.0.1"
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,18 +2,29 @@ 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, CodemodConfig } 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;
17
28
  /**
18
29
  * Optional codemod config from ai-localize.config.json.
19
30
  *
@@ -23,7 +34,7 @@ export interface AstScanOptions {
23
34
  * importPackage — matched against import source strings. Supports:
24
35
  * - npm package names: "react-i18next", "my-i18n-lib"
25
36
  * - path aliases: "@/hooks/useTranslation", "@/i18n"
26
- * - relative paths: "../../hooks/useTranslation"
37
+ * - relative paths: "../../hooks/useTranslation"
27
38
  * Matching is done by checking whether the import source equals the value
28
39
  * OR ends with the last path segment(s) of the value (normalised).
29
40
  *
@@ -44,7 +55,7 @@ const BUILTIN_TRANSLATION_IMPORT_SOURCES = new Set([
44
55
  'react-i18next',
45
56
  'i18next',
46
57
  'vue-i18n',
47
- '@ngx-translate/core',
58
+ '@ngx-translate/core',
48
59
  ]);
49
60
 
50
61
  /**
@@ -57,7 +68,7 @@ export class AstScanner {
57
68
  /** Identifiers whose call/bracket expressions contain already-translated strings. */
58
69
  private translationFunctionNames: Set<string>;
59
70
 
60
- /**
71
+ /**
61
72
  * Import source matchers.
62
73
  * Each entry is either an exact string (npm package) or a path-suffix matcher
63
74
  * function built from a relative/alias path.
@@ -84,13 +95,13 @@ export class AstScanner {
84
95
 
85
96
  // hookName — seed directly so the hook identifier is always recognised
86
97
  // regardless of how it is imported (named, default, aliased, re-exported).
87
- if (cc.hookName) {
98
+ if (cc.hookName) {
88
99
  this.translationFunctionNames.add(cc.hookName);
89
100
  }
90
101
 
91
102
  // importPackage — build a matcher that handles:
92
103
  // 1. exact npm package name "react-i18next"
93
- // 2. path alias "@/hooks/useTranslation"
104
+ // 2. path alias"@/hooks/useTranslation"
94
105
  // 3. relative path "../../hooks/useTranslation"
95
106
  if (cc.importPackage) {
96
107
  this.importSourceMatchers.push(buildImportMatcher(cc.importPackage));
@@ -101,23 +112,23 @@ export class AstScanner {
101
112
  scan(): DetectedText[] {
102
113
  const { content } = this.options;
103
114
 
104
- let ast: t.File;
115
+ let ast: t.File;
105
116
  try {
106
117
  ast = parser.parse(content, {
107
- sourceType: 'module',
118
+ sourceType: 'module',
108
119
  plugins: [
109
120
  'jsx',
110
- 'typescript',
111
- 'decorators-legacy',
112
- 'classProperties',
113
- 'optionalChaining',
114
- 'nullishCoalescingOperator',
115
- 'dynamicImport',
116
- 'exportDefaultFrom',
121
+ 'typescript',
122
+ 'decorators-legacy',
123
+ 'classProperties',
124
+ 'optionalChaining',
125
+ 'nullishCoalescingOperator',
126
+ 'dynamicImport',
127
+ 'exportDefaultFrom',
117
128
  ],
118
129
  errorRecovery: true,
119
130
  });
120
- } catch {
131
+ } catch {
121
132
  return this.regexFallbackScan();
122
133
  }
123
134
 
@@ -126,89 +137,89 @@ export class AstScanner {
126
137
 
127
138
  traverse(ast, {
128
139
  JSXText: (nodePath) => {
129
- const text = normalizeText(nodePath.node.value);
140
+ const text = normalizeText(nodePath.node.value);
130
141
  if (!isHumanReadableText(text)) return;
131
142
  if (this.isInsideTranslationCall(nodePath)) return;
132
- this.addDetected(
133
- text,
134
- nodePath.node.loc?.start.line ?? 0,
135
- nodePath.node.loc?.start.column ?? 0,
136
- 'jsx-text',
137
- 'JSXText'
138
- );
143
+ this.addDetected(
144
+ text,
145
+ nodePath.node.loc?.start.line ?? 0,
146
+ nodePath.node.loc?.start.column ?? 0,
147
+ 'jsx-text',
148
+ 'JSXText'
149
+ );
139
150
  },
140
151
 
141
- JSXAttribute: (nodePath) => {
152
+ JSXAttribute: (nodePath) => {
142
153
  const attrName = t.isJSXIdentifier(nodePath.node.name)
143
- ? nodePath.node.name.name
154
+ ? nodePath.node.name.name
144
155
  : '';
145
156
  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);
157
+ const valueNode = nodePath.node.value;
158
+ if (!t.isStringLiteral(valueNode)) return;
159
+ const text = normalizeText(valueNode.value);
149
160
  if (!isHumanReadableText(text)) return;
150
161
  if (this.isInsideTranslationCall(nodePath)) return;
151
162
  const context = this.mapAttrToContext(attrName);
152
- this.addDetected(
153
- text,
163
+ this.addDetected(
164
+ text,
154
165
  valueNode.loc?.start.line ?? 0,
155
- valueNode.loc?.start.column ?? 0,
156
- context,
166
+ valueNode.loc?.start.column ?? 0,
167
+ context,
157
168
  'JSXAttribute'
158
169
  );
159
170
  },
160
171
 
161
172
  StringLiteral: (nodePath) => {
162
- if (t.isImportDeclaration(nodePath.parent)) return;
173
+ if (t.isImportDeclaration(nodePath.parent)) return;
163
174
  if (t.isObjectProperty(nodePath.parent) && nodePath.parent.key === nodePath.node) return;
164
175
 
165
176
  // Ignore className and similar CSS-class attributes
166
177
  if (t.isJSXAttribute(nodePath.parent)) {
167
178
  const attrName = t.isJSXIdentifier(nodePath.parent.name)
168
- ? nodePath.parent.name.name.toLowerCase()
179
+ ? nodePath.parent.name.name.toLowerCase()
169
180
  : '';
170
- if (attrName === 'classname' || attrName === 'class') return;
171
- return;
181
+ if (attrName === 'classname' || attrName === 'class') return;
182
+ return;
172
183
  }
173
184
 
174
185
  if (this.isInsideTranslationCall(nodePath)) return;
175
186
 
176
187
  const val = nodePath.node.value;
177
188
  if (
178
- /^[a-z][a-z0-9_.-]*$/.test(val) ||
189
+ /^[a-z][a-z0-9_.-]*$/.test(val) ||
179
190
  /^#?[0-9a-fA-F]+$/.test(val) ||
180
191
  (/^[\w-]+\s[\w- ]+$/.test(val) && !val.includes(','))
181
- ) {
182
- return; // Likely a CSS class, ID, hex colour, or plain word pair
192
+ ) {
193
+ return; // Likely a CSS class, ID, hex colour, or plain word pair
183
194
  }
184
195
 
185
196
  const text = normalizeText(nodePath.node.value);
186
- if (!isHumanReadableText(text)) return;
197
+ if (!isHumanReadableText(text)) return;
187
198
  this.addDetected(
188
- text,
189
- nodePath.node.loc?.start.line ?? 0,
190
- nodePath.node.loc?.start.column ?? 0,
191
- 'string-literal',
192
- 'StringLiteral'
199
+ text,
200
+ nodePath.node.loc?.start.line ?? 0,
201
+ nodePath.node.loc?.start.column ?? 0,
202
+ 'string-literal',
203
+ 'StringLiteral'
193
204
  );
194
205
  },
195
206
 
196
207
  TemplateLiteral: (nodePath) => {
197
- if (nodePath.node.expressions.length > 0) return;
198
- if (this.isInsideTranslationCall(nodePath)) return;
208
+ if (nodePath.node.expressions.length > 0) return;
209
+ if (this.isInsideTranslationCall(nodePath)) return;
199
210
  const text = normalizeText(nodePath.node.quasis[0]?.value.cooked ?? '');
200
211
  if (!isHumanReadableText(text)) return;
201
212
  this.addDetected(
202
213
  text,
203
- nodePath.node.loc?.start.line ?? 0,
204
- nodePath.node.loc?.start.column ?? 0,
214
+ nodePath.node.loc?.start.line ?? 0,
215
+ nodePath.node.loc?.start.column ?? 0,
205
216
  'template-literal',
206
- 'TemplateLiteral'
207
- );
208
- },
217
+ 'TemplateLiteral'
218
+ );
219
+ },
209
220
  });
210
221
 
211
- return this.detectedTexts;
222
+ return this.detectedTexts;
212
223
  }
213
224
 
214
225
  /**
@@ -218,55 +229,55 @@ nodePath.node.loc?.start.line ?? 0,
218
229
  private collectTranslationImports(ast: t.File): void {
219
230
  for (const node of ast.program.body) {
220
231
  if (!t.isImportDeclaration(node)) continue;
221
- const source = node.source.value;
232
+ const source = node.source.value;
222
233
  if (!this.isTranslationImportSource(source)) continue;
223
234
  for (const specifier of node.specifiers) {
224
235
  if (t.isImportSpecifier(specifier) && t.isIdentifier(specifier.local)) {
225
236
  this.translationFunctionNames.add(specifier.local.name);
226
- }
227
- // Default import: import useT from '../../hooks/useT'
237
+ }
238
+ // Default import: import useT from '../../hooks/useT'
228
239
  if (t.isImportDefaultSpecifier(specifier) && t.isIdentifier(specifier.local)) {
229
240
  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);
234
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
+ }
235
246
  }
236
247
  }
237
248
  }
238
249
 
239
250
  /** Returns true if the import source string should be treated as a translation library. */
240
- private isTranslationImportSource(source: string): boolean {
251
+ private isTranslationImportSource(source: string): boolean {
241
252
  return this.importSourceMatchers.some((match) => match(source));
242
- }
253
+ }
243
254
 
244
- // eslint-disable-next-line @typescript-eslint/no-explicit-any
255
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
245
256
  private isInsideTranslationCall(nodePath: any): boolean {
246
257
  let current = nodePath.parentPath;
247
258
  while (current) {
248
259
  const node = current.node;
249
260
 
250
261
  // Function-call style: t('key')
251
- if (t.isCallExpression(node)) {
262
+ if (t.isCallExpression(node)) {
252
263
  const callee = node.callee;
253
- if (t.isIdentifier(callee) && this.translationFunctionNames.has(callee.name)) {
254
- return true;
255
- }
256
- if (
257
- t.isMemberExpression(callee) &&
258
- t.isIdentifier(callee.property) &&
259
- this.translationFunctionNames.has(callee.property.name)
260
- ) {
261
- return true;
264
+ if (t.isIdentifier(callee) && this.translationFunctionNames.has(callee.name)) {
265
+ return true;
262
266
  }
263
- }
267
+ if (
268
+ t.isMemberExpression(callee) &&
269
+ t.isIdentifier(callee.property) &&
270
+ this.translationFunctionNames.has(callee.property.name)
271
+ ) {
272
+ return true;
273
+ }
274
+ }
264
275
 
265
- // Bracket-notation accessor: t['key'] (MemberExpression, computed=true)
266
- if (t.isMemberExpression(node) && node.computed) {
267
- const obj = node.object;
276
+ // Bracket-notation accessor: t['key'] (MemberExpression, computed=true)
277
+ if (t.isMemberExpression(node) && node.computed) {
278
+ const obj = node.object;
268
279
  if (t.isIdentifier(obj) && this.translationFunctionNames.has(obj.name)) {
269
- return true;
280
+ return true;
270
281
  }
271
282
  }
272
283
 
@@ -282,22 +293,23 @@ private isTranslationImportSource(source: string): boolean {
282
293
  context: TextContext,
283
294
  nodeType: string
284
295
  ): void {
285
- const key = generateLocaleKey(
286
- this.options.filePath,
296
+ const key = generateKeyByStyle(
297
+ this.options.filePath,
287
298
  text,
288
- this.options.sourceRoot || 'src'
299
+ this.options.sourceRoot || 'src',
300
+ this.options.keyStyle || 'path'
289
301
  );
290
302
  this.detectedTexts.push({
291
303
  filePath: this.options.filePath,
292
304
  line,
293
305
  column,
294
- text,
306
+ text,
295
307
  suggestedKey: key,
296
308
  context,
297
309
  nodeType,
298
- alreadyTranslated: false,
299
- });
300
- }
310
+ alreadyTranslated: false,
311
+ });
312
+ }
301
313
 
302
314
  private mapAttrToContext(attrName: string): TextContext {
303
315
  const lower = attrName.toLowerCase();
@@ -313,27 +325,28 @@ private isTranslationImportSource(source: string): boolean {
313
325
  const jsxTextRegex = />([^<>{}\n]+)</g;
314
326
  const lines = this.options.content.split('\n');
315
327
  lines.forEach((line, idx) => {
316
- let m: RegExpExecArray | null;
317
- jsxTextRegex.lastIndex = 0;
328
+ let m: RegExpExecArray | null;
329
+ jsxTextRegex.lastIndex = 0;
318
330
  while ((m = jsxTextRegex.exec(line)) !== null) {
319
331
  const text = normalizeText(m[1]);
320
332
  if (!isHumanReadableText(text)) continue;
321
- const key = generateLocaleKey(
333
+ const key = generateKeyByStyle(
322
334
  this.options.filePath,
323
- text,
324
- this.options.sourceRoot || 'src'
325
- );
335
+ text,
336
+ this.options.sourceRoot || 'src',
337
+ this.options.keyStyle || 'path'
338
+ );
326
339
  results.push({
327
340
  filePath: this.options.filePath,
328
- line: idx + 1,
341
+ line: idx + 1,
329
342
  column: m.index,
330
- text,
331
- suggestedKey: key,
332
- context: 'jsx-text',
343
+ text,
344
+ suggestedKey: key,
345
+ context: 'jsx-text',
333
346
  nodeType: 'regex-fallback',
334
- alreadyTranslated: false,
335
- });
336
- }
347
+ alreadyTranslated: false,
348
+ });
349
+ }
337
350
  });
338
351
  return results;
339
352
  }
@@ -356,7 +369,7 @@ private isTranslationImportSource(source: string): boolean {
356
369
  * comparing the **last N segments** of the import source with the
357
370
  * last N segments of the configured value (case-insensitive, stripping
358
371
  * leading dots and slashes). This is intentionally lenient so that
359
- * `hooks/useTranslation` matches both `../../hooks/useTranslation` and
372
+ * `hooks/useTranslation` matches both `../../hooks/useTranslation` and
360
373
  * `./hooks/useTranslation`.
361
374
  *
362
375
  * Examples:
@@ -365,8 +378,8 @@ private isTranslationImportSource(source: string): boolean {
365
378
  * matches "./hooks/useTranslation" ✓ (same tail segments)
366
379
  * matches "../hooks/useTranslation" ✓
367
380
  * importPackage="@/i18n/hook"
368
- * matches "@/i18n/hook"
369
- * matches "i18n/hook" ✓
381
+ * matches "@/i18n/hook"✓
382
+ * matches "i18n/hook" ✓
370
383
  */
371
384
  function buildImportMatcher(importPackage: string): (source: string) => boolean {
372
385
  // Normalise: strip leading ./ ../ @ and collapse slashes
@@ -374,20 +387,20 @@ function buildImportMatcher(importPackage: string): (source: string) => boolean
374
387
  const pkgSegments = normalisedPkg.split('/').filter(Boolean);
375
388
 
376
389
  return (source: string): boolean => {
377
- // 1. Exact match (covers plain npm package names and exact alias paths)
378
- if (source === importPackage) return true;
390
+ // 1. Exact match (covers plain npm package names and exact alias paths)
391
+ if (source === importPackage) return true;
379
392
 
380
393
  // 2. Suffix / tail-segment match
381
394
  const normSource = normalisePath(source);
382
- const srcSegments = normSource.split('/').filter(Boolean);
395
+ const srcSegments = normSource.split('/').filter(Boolean);
383
396
 
384
397
  if (pkgSegments.length === 0) return false;
385
398
 
386
- // Check if the source ends with the same N tail segments as the package
399
+ // Check if the source ends with the same N tail segments as the package
387
400
  const n = pkgSegments.length;
388
- if (srcSegments.length < n) return false;
401
+ if (srcSegments.length < n) return false;
389
402
  const tail = srcSegments.slice(-n);
390
- return tail.every((seg, i) => seg.toLowerCase() === pkgSegments[i].toLowerCase());
403
+ return tail.every((seg, i) => seg.toLowerCase() === pkgSegments[i].toLowerCase());
391
404
  };
392
405
  }
393
406
 
@@ -396,5 +409,5 @@ function normalisePath(p: string): string {
396
409
  return p
397
410
  .replace(/\\/g, '/')
398
411
  .replace(/^(@\/|\.{1,2}\/)+/, '') // strip leading ./ ../ @/
399
- .replace(/^@/, ''); // strip bare @ prefix
412
+ .replace(/^@/, ''); // strip bare @ prefix
400
413
  }
@@ -29,10 +29,10 @@ export class ProjectScanner {
29
29
  this.config = config;
30
30
  // Resolve to absolute path so key-generator can use path.relative()
31
31
  // and never produce keys that include ancestor directory segments.
32
- this.sourceRoot = path.resolve(process.cwd(), config.sourceDir);
32
+ this.sourceRoot = path.resolve(process.cwd(), config.sourceDir);
33
33
  this.assetScanner = new AssetScanner(config.aws?.legacyCdnPattern);
34
34
  if (config.incrementalCache) {
35
- this.cache = new IncrementalScanCache(
35
+ this.cache = new IncrementalScanCache(
36
36
  path.join(process.cwd(), config.cacheDir || '.ai-localize-cache')
37
37
  );
38
38
  }
@@ -43,51 +43,51 @@ export class ProjectScanner {
43
43
  let filesToScan: string[] = [];
44
44
 
45
45
  if (options.files?.length) {
46
- filesToScan = options.files;
47
- } else {
46
+ filesToScan = options.files;
47
+ } else {
48
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
- });
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
60
  } else {
61
- filesToScan = allFiles;
61
+ filesToScan = allFiles;
62
62
  }
63
63
  }
64
64
 
65
65
  const allTexts: DetectedText[] = [];
66
- const allAssets: AssetReference[] = [];
66
+ const allAssets: AssetReference[] = [];
67
67
  const allLegacyUrls: LegacyCdnUrl[] = [];
68
68
 
69
69
  const chunkSize = Math.max(
70
- 1,
71
- Math.min(50, Math.ceil(filesToScan.length / (os.cpus().length || 4)))
70
+ 1,
71
+ Math.min(50, Math.ceil(filesToScan.length / (os.cpus().length || 4)))
72
72
  );
73
73
  const chunks = this.chunkArray(filesToScan, chunkSize);
74
74
 
75
75
  for (const chunk of chunks) {
76
- const results = await Promise.all(chunk.map((f) => this.scanFile(f)));
77
- for (const r of results) {
76
+ const results = await Promise.all(chunk.map((f) => this.scanFile(f)));
77
+ for (const r of results) {
78
78
  allTexts.push(...r.texts);
79
79
  allAssets.push(...r.assets);
80
80
  allLegacyUrls.push(...r.legacyUrls);
81
81
  }
82
82
  }
83
83
 
84
- this.cache?.persist();
84
+ this.cache?.persist();
85
85
 
86
86
  return {
87
- framework: this.config.framework,
88
- scannedFiles: filesToScan.length,
87
+ framework: this.config.framework,
88
+ scannedFiles: filesToScan.length,
89
89
  detectedTexts: allTexts,
90
- assets: allAssets,
90
+ assets: allAssets,
91
91
  legacyCdnUrls: allLegacyUrls,
92
92
  duration: Date.now() - startTime,
93
93
  timestamp: new Date().toISOString(),
@@ -106,8 +106,8 @@ framework: this.config.framework,
106
106
 
107
107
  let content: string;
108
108
  try {
109
- const { readFileSync } = await import('fs');
110
- content = readFileSync(filePath, 'utf-8');
109
+ const { readFileSync } = await import('fs');
110
+ content = readFileSync(filePath, 'utf-8');
111
111
  } catch {
112
112
  return { texts: [], assets: [], legacyUrls: [] };
113
113
  }
@@ -116,13 +116,15 @@ framework: this.config.framework,
116
116
  // and strips only the project source directory — not ancestor segments.
117
117
  // Also pass codemods config so the scanner recognises user-defined
118
118
  // translation functions/hooks and skips already-translated strings.
119
+ // keyStyle controls whether keys are path-based (default) or SCREAMING_SNAKE.
119
120
  const scanner = new AstScanner({
120
121
  filePath,
121
122
  content,
122
123
  sourceRoot: this.sourceRoot,
123
124
  codemodConfig: this.config.codemods,
125
+ keyStyle: this.config.keyStyle ?? 'path',
124
126
  });
125
- const texts = scanner.scan();
127
+ const texts = scanner.scan();
126
128
  const { assets, legacyCdnUrls } = this.assetScanner.scanFile(filePath);
127
129
 
128
130
  this.cache?.setCachedResult(filePath, texts);
@@ -133,7 +135,7 @@ framework: this.config.framework,
133
135
  private chunkArray<T>(array: T[], size: number): T[][] {
134
136
  const chunks: T[][] = [];
135
137
  for (let i = 0; i < array.length; i += size) {
136
- chunks.push(array.slice(i, i + size));
138
+ chunks.push(array.slice(i, i + size));
137
139
  }
138
140
  return chunks;
139
141
  }