ai-localize-scanner 2.0.6 → 3.0.0

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,41 @@
1
1
  # ai-localize-scanner
2
2
 
3
+ ## 3.0.0
4
+
5
+ ### Major Changes
6
+
7
+ - bug fixes
8
+
9
+ ### Patch Changes
10
+
11
+ - Updated dependencies
12
+ - ai-localize-config@3.0.0
13
+ - ai-localize-shared@3.0.0
14
+
15
+ ## 2.0.7
16
+
17
+ ### Fixed
18
+
19
+ - **`@babel/traverse` CJS/ESM interop** — Added runtime shim `(_traverse as any).default ?? _traverse` in `ast-scanner.ts` so the bundled CLI no longer throws `(0 , import_traverse.default) is not a function` when running `ai-localize scan`.
20
+
21
+ ### Minor Changes
22
+
23
+ - **CSS/utility class filtering in `AstScanner`**:
24
+ - New `NON_TRANSLATABLE_ATTR_NAMES` set: JSX attributes like `type`, `role`, `method`,
25
+ `href`, `src`, `rel`, `target`, `id`, `name`, and 20+ others are now skipped.
26
+ - New `NON_TRANSLATABLE_PROP_KEYS` set: object property values are skipped when the key
27
+ is a CSS-in-JS property (`fontFamily`, `color`, `display`, etc.) or structural prop.
28
+ - New `CSS_UTILITY_FN_NAMES` set: string literals inside calls to `clsx`, `cx`, `cn`,
29
+ `twMerge`, `twJoin`, `classnames`, `styled`, `css`, etc. are now skipped.
30
+ - `isCssClassString()` applied to every candidate string.
31
+ - Centralised `isTranslatableText()` private method applies all checks + `ignoreTextPatterns`.
32
+ - **`ignoreTextPatterns` config support** — user-defined regex patterns applied per scan.
33
+ - `regexFallbackScan()` updated to use the same `isTranslatableText()` pipeline.
34
+ - `ProjectScanner` passes `config.ignoreTextPatterns` to `AstScanner`.
35
+ - Added `repository`, `homepage`, `bugs`, `author` fields to `package.json` for npm registry display.
36
+ - Added `sideEffects: false` to enable tree-shaking in bundlers.
37
+ - Added `prepublishOnly` script to ensure the package is built before publishing.
38
+
3
39
  ## 2.0.6
4
40
 
5
41
  ### Patch Changes
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2024-2026 ai-localize-core contributors
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/dist/index.d.mts CHANGED
@@ -6,38 +6,27 @@ interface AstScanOptions {
6
6
  sourceRoot?: string;
7
7
  /**
8
8
  * Controls the format of the generated locale key for each detected text.
9
- *
9
+ *
10
10
  * - `"path"` (default) — hierarchical dot-notation key derived from file path + text:
11
11
  * `settings.settings_page.save_changes`
12
12
  *
13
13
  * - `"screaming_snake"` — UPPER_SNAKE_CASE key derived solely from the text value:
14
14
  * "Save Changes" → `SAVE_CHANGES`
15
- * "Max Count" → `MAX_COUNT`
15
+ * "Max Count" → `MAX_COUNT`
16
16
  */
17
17
  keyStyle?: KeyStyle;
18
18
  /**
19
19
  * Optional codemod config from ai-localize.config.json.
20
20
  *
21
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.
22
+ * the project uses a custom i18n library or a locally-defined hook.
39
23
  */
40
24
  codemodConfig?: CodemodConfig;
25
+ /**
26
+ * Additional regex patterns (as strings) from config.ignoreTextPatterns.
27
+ * Any scanned string matching at least one pattern is excluded.
28
+ */
29
+ ignoreTextPatterns?: string[];
41
30
  }
42
31
  /**
43
32
  * Scans a JS/TS/JSX/TSX file using Babel AST to find hardcoded text.
@@ -45,6 +34,7 @@ interface AstScanOptions {
45
34
  declare class AstScanner {
46
35
  private options;
47
36
  private detectedTexts;
37
+ private compiledIgnorePatterns;
48
38
  /** Identifiers whose call/bracket expressions contain already-translated strings. */
49
39
  private translationFunctionNames;
50
40
  /**
@@ -55,6 +45,16 @@ declare class AstScanner {
55
45
  private importSourceMatchers;
56
46
  constructor(options: AstScanOptions);
57
47
  scan(): DetectedText[];
48
+ /**
49
+ * Central check: is this text worth extracting as a locale key?
50
+ * Applies isHumanReadableText(), isCssClassString(), and user ignoreTextPatterns.
51
+ */
52
+ private isTranslatableText;
53
+ /**
54
+ * Returns true when the node path is inside a CSS utility function call:
55
+ * clsx("a", "b"), cn("x"), twMerge("foo", "bar"), styled("div"), etc.
56
+ */
57
+ private isInsideCssUtilityCall;
58
58
  /**
59
59
  * Walk import declarations; when the source matches a known translation
60
60
  * import, collect all named/default imports as translation function names.
@@ -83,7 +83,14 @@ declare class AssetScanner {
83
83
  declare class IncrementalScanCache {
84
84
  private cachePath;
85
85
  private cache;
86
- constructor(cacheDir: string);
86
+ /**
87
+ * @param cacheDir Directory where `scan-cache.json` is stored.
88
+ * @param configHash SHA-256 hash of the resolved config object.
89
+ * When this differs from the persisted value the entire
90
+ * cache is invalidated so that config changes (keyStyle,
91
+ * ignoreTextPatterns, codemods, etc.) are always reflected.
92
+ */
93
+ constructor(cacheDir: string, configHash?: string);
87
94
  private load;
88
95
  isFileChanged(filePath: string): boolean;
89
96
  getCachedResult(filePath: string): DetectedText[] | null;
@@ -103,6 +110,16 @@ declare class ProjectScanner {
103
110
  private cache?;
104
111
  private assetScanner;
105
112
  constructor(config: LocalizationConfig);
113
+ /**
114
+ * Produces a stable SHA-256 fingerprint of the config fields that affect
115
+ * scan output. When any of these change the incremental cache is fully
116
+ * invalidated so the next run re-scans every file with the new settings.
117
+ *
118
+ * Fields intentionally excluded: `incrementalCache`, `cacheDir`, `aws`,
119
+ * `plugins` — none of those influence what text the AST scanner detects
120
+ * or how keys are generated.
121
+ */
122
+ private hashConfig;
106
123
  scan(options?: ScanOptions): Promise<ScanResult>;
107
124
  private scanFile;
108
125
  private chunkArray;
package/dist/index.d.ts CHANGED
@@ -6,38 +6,27 @@ interface AstScanOptions {
6
6
  sourceRoot?: string;
7
7
  /**
8
8
  * Controls the format of the generated locale key for each detected text.
9
- *
9
+ *
10
10
  * - `"path"` (default) — hierarchical dot-notation key derived from file path + text:
11
11
  * `settings.settings_page.save_changes`
12
12
  *
13
13
  * - `"screaming_snake"` — UPPER_SNAKE_CASE key derived solely from the text value:
14
14
  * "Save Changes" → `SAVE_CHANGES`
15
- * "Max Count" → `MAX_COUNT`
15
+ * "Max Count" → `MAX_COUNT`
16
16
  */
17
17
  keyStyle?: KeyStyle;
18
18
  /**
19
19
  * Optional codemod config from ai-localize.config.json.
20
20
  *
21
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.
22
+ * the project uses a custom i18n library or a locally-defined hook.
39
23
  */
40
24
  codemodConfig?: CodemodConfig;
25
+ /**
26
+ * Additional regex patterns (as strings) from config.ignoreTextPatterns.
27
+ * Any scanned string matching at least one pattern is excluded.
28
+ */
29
+ ignoreTextPatterns?: string[];
41
30
  }
42
31
  /**
43
32
  * Scans a JS/TS/JSX/TSX file using Babel AST to find hardcoded text.
@@ -45,6 +34,7 @@ interface AstScanOptions {
45
34
  declare class AstScanner {
46
35
  private options;
47
36
  private detectedTexts;
37
+ private compiledIgnorePatterns;
48
38
  /** Identifiers whose call/bracket expressions contain already-translated strings. */
49
39
  private translationFunctionNames;
50
40
  /**
@@ -55,6 +45,16 @@ declare class AstScanner {
55
45
  private importSourceMatchers;
56
46
  constructor(options: AstScanOptions);
57
47
  scan(): DetectedText[];
48
+ /**
49
+ * Central check: is this text worth extracting as a locale key?
50
+ * Applies isHumanReadableText(), isCssClassString(), and user ignoreTextPatterns.
51
+ */
52
+ private isTranslatableText;
53
+ /**
54
+ * Returns true when the node path is inside a CSS utility function call:
55
+ * clsx("a", "b"), cn("x"), twMerge("foo", "bar"), styled("div"), etc.
56
+ */
57
+ private isInsideCssUtilityCall;
58
58
  /**
59
59
  * Walk import declarations; when the source matches a known translation
60
60
  * import, collect all named/default imports as translation function names.
@@ -83,7 +83,14 @@ declare class AssetScanner {
83
83
  declare class IncrementalScanCache {
84
84
  private cachePath;
85
85
  private cache;
86
- constructor(cacheDir: string);
86
+ /**
87
+ * @param cacheDir Directory where `scan-cache.json` is stored.
88
+ * @param configHash SHA-256 hash of the resolved config object.
89
+ * When this differs from the persisted value the entire
90
+ * cache is invalidated so that config changes (keyStyle,
91
+ * ignoreTextPatterns, codemods, etc.) are always reflected.
92
+ */
93
+ constructor(cacheDir: string, configHash?: string);
87
94
  private load;
88
95
  isFileChanged(filePath: string): boolean;
89
96
  getCachedResult(filePath: string): DetectedText[] | null;
@@ -103,6 +110,16 @@ declare class ProjectScanner {
103
110
  private cache?;
104
111
  private assetScanner;
105
112
  constructor(config: LocalizationConfig);
113
+ /**
114
+ * Produces a stable SHA-256 fingerprint of the config fields that affect
115
+ * scan output. When any of these change the incremental cache is fully
116
+ * invalidated so the next run re-scans every file with the new settings.
117
+ *
118
+ * Fields intentionally excluded: `incrementalCache`, `cacheDir`, `aws`,
119
+ * `plugins` — none of those influence what text the AST scanner detects
120
+ * or how keys are generated.
121
+ */
122
+ private hashConfig;
106
123
  scan(options?: ScanOptions): Promise<ScanResult>;
107
124
  private scanFile;
108
125
  private chunkArray;
package/dist/index.js CHANGED
@@ -43,15 +43,246 @@ 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 traverse = import_traverse.default.default ?? import_traverse.default;
46
47
  var BUILTIN_TRANSLATION_IMPORT_SOURCES = /* @__PURE__ */ new Set([
47
48
  "react-i18next",
48
49
  "i18next",
49
50
  "vue-i18n",
50
51
  "@ngx-translate/core"
51
52
  ]);
53
+ var NON_TRANSLATABLE_ATTR_NAMES = /* @__PURE__ */ new Set([
54
+ "classname",
55
+ "class",
56
+ "id",
57
+ "name",
58
+ "key",
59
+ "ref",
60
+ "type",
61
+ "method",
62
+ "action",
63
+ "enctype",
64
+ "href",
65
+ "src",
66
+ "srcset",
67
+ "action",
68
+ "to",
69
+ "as",
70
+ "path",
71
+ "url",
72
+ "rel",
73
+ "target",
74
+ "referrerpolicy",
75
+ "crossorigin",
76
+ "fetchpriority",
77
+ "loading",
78
+ "decoding",
79
+ "sizes",
80
+ "media",
81
+ "role",
82
+ "tabindex",
83
+ "autocomplete",
84
+ "inputmode",
85
+ "dir",
86
+ "lang",
87
+ "charset",
88
+ "for",
89
+ "htmlfor",
90
+ "htmlFor",
91
+ "accept",
92
+ "capture",
93
+ "pattern",
94
+ "autocorrect",
95
+ "autocapitalize",
96
+ "spellcheck",
97
+ "style",
98
+ "css",
99
+ "data-testid",
100
+ "data-cy",
101
+ "data-test",
102
+ "data-id",
103
+ "variant",
104
+ "color",
105
+ "size",
106
+ "shape",
107
+ "align",
108
+ "valign",
109
+ "justify",
110
+ "orientation",
111
+ "direction",
112
+ "placement",
113
+ "position",
114
+ "icon",
115
+ "iconname",
116
+ "lefticon",
117
+ "righticon"
118
+ ]);
119
+ var NON_TRANSLATABLE_PROP_KEYS = /* @__PURE__ */ new Set([
120
+ // HTML/JSX structural
121
+ "className",
122
+ "class",
123
+ "id",
124
+ "name",
125
+ "key",
126
+ "ref",
127
+ "type",
128
+ "method",
129
+ "action",
130
+ "href",
131
+ "src",
132
+ "rel",
133
+ "target",
134
+ "role",
135
+ "style",
136
+ "css",
137
+ "to",
138
+ "as",
139
+ "path",
140
+ "url",
141
+ "link",
142
+ "icon",
143
+ "variant",
144
+ "color",
145
+ "size",
146
+ // CSS-in-JS style properties
147
+ "fontFamily",
148
+ "fontWeight",
149
+ "fontStyle",
150
+ "fontVariant",
151
+ "fontSize",
152
+ "fontStretch",
153
+ "color",
154
+ "backgroundColor",
155
+ "borderColor",
156
+ "outlineColor",
157
+ "textDecorationColor",
158
+ "display",
159
+ "position",
160
+ "visibility",
161
+ "overflow",
162
+ "overflowX",
163
+ "overflowY",
164
+ "cursor",
165
+ "pointerEvents",
166
+ "userSelect",
167
+ "appearance",
168
+ "resize",
169
+ "textAlign",
170
+ "verticalAlign",
171
+ "textDecoration",
172
+ "textTransform",
173
+ "textOverflow",
174
+ "whiteSpace",
175
+ "wordBreak",
176
+ "wordWrap",
177
+ "overflowWrap",
178
+ "lineBreak",
179
+ "hyphens",
180
+ "flexDirection",
181
+ "flexWrap",
182
+ "alignItems",
183
+ "alignContent",
184
+ "alignSelf",
185
+ "justifyContent",
186
+ "justifyItems",
187
+ "justifySelf",
188
+ "flexFlow",
189
+ "gridAutoFlow",
190
+ "gridAutoColumns",
191
+ "gridAutoRows",
192
+ "float",
193
+ "clear",
194
+ "objectFit",
195
+ "objectPosition",
196
+ "listStyle",
197
+ "listStyleType",
198
+ "borderStyle",
199
+ "outlineStyle",
200
+ "backgroundRepeat",
201
+ "backgroundAttachment",
202
+ "backgroundPosition",
203
+ "backgroundSize",
204
+ "backgroundBlendMode",
205
+ "mixBlendMode",
206
+ "isolation",
207
+ "boxSizing",
208
+ "tableLayout",
209
+ "captionSide",
210
+ "borderCollapse",
211
+ "imageRendering",
212
+ "shapeOutside",
213
+ "shapeBox",
214
+ "writingMode",
215
+ "direction",
216
+ "speak",
217
+ "contentVisibility",
218
+ // Animation / transition
219
+ "animationName",
220
+ "animationTimingFunction",
221
+ "animationFillMode",
222
+ "animationDirection",
223
+ "animationPlayState",
224
+ "transitionTimingFunction",
225
+ "transitionProperty",
226
+ "transformOrigin",
227
+ "transformBox",
228
+ "transformStyle",
229
+ // Event handler patterns
230
+ "onChange",
231
+ "onClick",
232
+ "onSubmit",
233
+ "onFocus",
234
+ "onBlur",
235
+ "onKeyDown",
236
+ "onKeyUp",
237
+ "onKeyPress",
238
+ "onMouseEnter",
239
+ "onMouseLeave",
240
+ "onMouseOver",
241
+ "onMouseOut",
242
+ "onMouseDown",
243
+ "onMouseUp",
244
+ "onDragStart",
245
+ "onDrop",
246
+ "onTouchStart",
247
+ "onTouchEnd",
248
+ "onTouchMove",
249
+ "onScroll",
250
+ "onResize",
251
+ "onLoad",
252
+ "onError",
253
+ "onAbort",
254
+ "onContextMenu",
255
+ "onDoubleClick",
256
+ "onSelect",
257
+ "onInput",
258
+ "onPaste",
259
+ "onCopy",
260
+ "onCut",
261
+ "onWheel"
262
+ ]);
263
+ var CSS_UTILITY_FN_NAMES = /* @__PURE__ */ new Set([
264
+ "clsx",
265
+ "cx",
266
+ "classnames",
267
+ "classNames",
268
+ "cn",
269
+ "cc",
270
+ "twMerge",
271
+ "twJoin",
272
+ "tw",
273
+ "cva",
274
+ "ctl",
275
+ "classes",
276
+ "makeClasses",
277
+ "styled",
278
+ // styled-components / @emotion tag
279
+ "css"
280
+ // @emotion/css or linaria
281
+ ]);
52
282
  var AstScanner = class {
53
283
  options;
54
284
  detectedTexts = [];
285
+ compiledIgnorePatterns = [];
55
286
  /** Identifiers whose call/bracket expressions contain already-translated strings. */
56
287
  translationFunctionNames;
57
288
  /**
@@ -62,6 +293,13 @@ var AstScanner = class {
62
293
  importSourceMatchers;
63
294
  constructor(options) {
64
295
  this.options = options;
296
+ this.compiledIgnorePatterns = (options.ignoreTextPatterns ?? []).flatMap((p) => {
297
+ try {
298
+ return [new RegExp(p)];
299
+ } catch {
300
+ return [];
301
+ }
302
+ });
65
303
  this.translationFunctionNames = /* @__PURE__ */ new Set(["t", "$t", "i18n", "translate"]);
66
304
  this.importSourceMatchers = Array.from(BUILTIN_TRANSLATION_IMPORT_SOURCES).map(
67
305
  (pkg) => (src) => src === pkg
@@ -101,10 +339,11 @@ var AstScanner = class {
101
339
  return this.regexFallbackScan();
102
340
  }
103
341
  this.collectTranslationImports(ast);
104
- (0, import_traverse.default)(ast, {
342
+ traverse(ast, {
343
+ // ── JSX text nodes: <h1>Welcome</h1> ───────────────────────────────────
105
344
  JSXText: (nodePath) => {
106
345
  const text = (0, import_ai_localize_shared.normalizeText)(nodePath.node.value);
107
- if (!(0, import_ai_localize_shared.isHumanReadableText)(text)) return;
346
+ if (!this.isTranslatableText(text)) return;
108
347
  if (this.isInsideTranslationCall(nodePath)) return;
109
348
  this.addDetected(
110
349
  text,
@@ -114,13 +353,15 @@ var AstScanner = class {
114
353
  "JSXText"
115
354
  );
116
355
  },
356
+ // ── JSX text-content attributes: placeholder="...", alt="..." ──────────
117
357
  JSXAttribute: (nodePath) => {
118
358
  const attrName = t.isJSXIdentifier(nodePath.node.name) ? nodePath.node.name.name : "";
119
359
  if (!import_ai_localize_shared.TEXT_ATTRIBUTE_NAMES.has(attrName.toLowerCase())) return;
360
+ if (NON_TRANSLATABLE_ATTR_NAMES.has(attrName.toLowerCase())) return;
120
361
  const valueNode = nodePath.node.value;
121
362
  if (!t.isStringLiteral(valueNode)) return;
122
363
  const text = (0, import_ai_localize_shared.normalizeText)(valueNode.value);
123
- if (!(0, import_ai_localize_shared.isHumanReadableText)(text)) return;
364
+ if (!this.isTranslatableText(text)) return;
124
365
  if (this.isInsideTranslationCall(nodePath)) return;
125
366
  const context = this.mapAttrToContext(attrName);
126
367
  this.addDetected(
@@ -131,21 +372,23 @@ var AstScanner = class {
131
372
  "JSXAttribute"
132
373
  );
133
374
  },
375
+ // ── StringLiteral in JS/TS expressions ─────────────────────────────────
134
376
  StringLiteral: (nodePath) => {
135
377
  if (t.isImportDeclaration(nodePath.parent)) return;
136
378
  if (t.isObjectProperty(nodePath.parent) && nodePath.parent.key === nodePath.node) 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;
379
+ if (t.isJSXAttribute(nodePath.parent)) return;
380
+ if (t.isObjectProperty(nodePath.parent) && t.isIdentifier(nodePath.parent.key)) {
381
+ if (NON_TRANSLATABLE_PROP_KEYS.has(nodePath.parent.key.name)) return;
141
382
  }
383
+ if (this.isInsideCssUtilityCall(nodePath)) return;
142
384
  if (this.isInsideTranslationCall(nodePath)) return;
143
385
  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(",")) {
386
+ if (/^[a-z][a-z0-9_.\-]*$/.test(val) || // all-lowercase token (CSS class / id / key)
387
+ /^#?[0-9a-fA-F]+$/.test(val)) {
145
388
  return;
146
389
  }
147
- const text = (0, import_ai_localize_shared.normalizeText)(nodePath.node.value);
148
- if (!(0, import_ai_localize_shared.isHumanReadableText)(text)) return;
390
+ const text = (0, import_ai_localize_shared.normalizeText)(val);
391
+ if (!this.isTranslatableText(text)) return;
149
392
  this.addDetected(
150
393
  text,
151
394
  nodePath.node.loc?.start.line ?? 0,
@@ -154,11 +397,12 @@ var AstScanner = class {
154
397
  "StringLiteral"
155
398
  );
156
399
  },
400
+ // ── Template literals with no expressions: `Hello world` ───────────────
157
401
  TemplateLiteral: (nodePath) => {
158
402
  if (nodePath.node.expressions.length > 0) return;
159
403
  if (this.isInsideTranslationCall(nodePath)) return;
160
404
  const text = (0, import_ai_localize_shared.normalizeText)(nodePath.node.quasis[0]?.value.cooked ?? "");
161
- if (!(0, import_ai_localize_shared.isHumanReadableText)(text)) return;
405
+ if (!this.isTranslatableText(text)) return;
162
406
  this.addDetected(
163
407
  text,
164
408
  nodePath.node.loc?.start.line ?? 0,
@@ -170,6 +414,39 @@ var AstScanner = class {
170
414
  });
171
415
  return this.detectedTexts;
172
416
  }
417
+ /**
418
+ * Central check: is this text worth extracting as a locale key?
419
+ * Applies isHumanReadableText(), isCssClassString(), and user ignoreTextPatterns.
420
+ */
421
+ isTranslatableText(text) {
422
+ if (!(0, import_ai_localize_shared.isHumanReadableText)(text)) return false;
423
+ if ((0, import_ai_localize_shared.isCssClassString)(text)) return false;
424
+ if (this.compiledIgnorePatterns.some((re) => re.test(text))) return false;
425
+ return true;
426
+ }
427
+ /**
428
+ * Returns true when the node path is inside a CSS utility function call:
429
+ * clsx("a", "b"), cn("x"), twMerge("foo", "bar"), styled("div"), etc.
430
+ */
431
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
432
+ isInsideCssUtilityCall(nodePath) {
433
+ let current = nodePath.parentPath;
434
+ while (current) {
435
+ const node = current.node;
436
+ if (t.isCallExpression(node)) {
437
+ const callee = node.callee;
438
+ if (t.isIdentifier(callee) && CSS_UTILITY_FN_NAMES.has(callee.name)) return true;
439
+ if (t.isMemberExpression(callee) && t.isIdentifier(callee.property) && CSS_UTILITY_FN_NAMES.has(callee.property.name)) return true;
440
+ }
441
+ if (t.isTaggedTemplateExpression(node)) {
442
+ const tag = node.tag;
443
+ if (t.isIdentifier(tag) && CSS_UTILITY_FN_NAMES.has(tag.name)) return true;
444
+ if (t.isMemberExpression(tag) && t.isIdentifier(tag.object) && CSS_UTILITY_FN_NAMES.has(tag.object.name)) return true;
445
+ }
446
+ current = current.parentPath;
447
+ }
448
+ return false;
449
+ }
173
450
  /**
174
451
  * Walk import declarations; when the source matches a known translation
175
452
  * import, collect all named/default imports as translation function names.
@@ -255,7 +532,7 @@ var AstScanner = class {
255
532
  jsxTextRegex.lastIndex = 0;
256
533
  while ((m = jsxTextRegex.exec(line)) !== null) {
257
534
  const text = (0, import_ai_localize_shared.normalizeText)(m[1]);
258
- if (!(0, import_ai_localize_shared.isHumanReadableText)(text)) continue;
535
+ if (!this.isTranslatableText(text)) continue;
259
536
  const key = (0, import_ai_localize_shared.generateKeyByStyle)(
260
537
  this.options.filePath,
261
538
  text,
@@ -278,22 +555,22 @@ var AstScanner = class {
278
555
  }
279
556
  };
280
557
  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());
558
+ if (!importPackage.startsWith(".") && !importPackage.startsWith("/") && !importPackage.startsWith("@/")) {
559
+ return (src) => src === importPackage;
560
+ }
561
+ const normalise = (s) => s.replace(/^[@./]+/, "").replace(/\\/g, "/").toLowerCase();
562
+ const normTarget = normalise(importPackage);
563
+ const targetSegments = normTarget.split("/").filter(Boolean);
564
+ const segCount = targetSegments.length;
565
+ return (src) => {
566
+ if (src === importPackage) return true;
567
+ const normSrc = normalise(src);
568
+ const srcSegments = normSrc.split("/").filter(Boolean);
569
+ if (srcSegments.length < segCount) return false;
570
+ const tail = srcSegments.slice(-segCount);
571
+ return tail.join("/") === targetSegments.join("/");
292
572
  };
293
573
  }
294
- function normalisePath(p) {
295
- return p.replace(/\\/g, "/").replace(/^(@\/|\.{1,2}\/)+/, "").replace(/^@/, "");
296
- }
297
574
 
298
575
  // src/asset-scanner.ts
299
576
  var fs = __toESM(require("fs"));
@@ -403,15 +680,27 @@ var import_ai_localize_shared3 = require("ai-localize-shared");
403
680
  var IncrementalScanCache = class {
404
681
  cachePath;
405
682
  cache;
406
- constructor(cacheDir) {
683
+ /**
684
+ * @param cacheDir Directory where `scan-cache.json` is stored.
685
+ * @param configHash SHA-256 hash of the resolved config object.
686
+ * When this differs from the persisted value the entire
687
+ * cache is invalidated so that config changes (keyStyle,
688
+ * ignoreTextPatterns, codemods, etc.) are always reflected.
689
+ */
690
+ constructor(cacheDir, configHash) {
407
691
  (0, import_ai_localize_shared3.ensureDir)(cacheDir);
408
692
  this.cachePath = path2.join(cacheDir, "scan-cache.json");
409
- this.cache = this.load();
693
+ this.cache = this.load(configHash);
410
694
  }
411
- load() {
695
+ load(configHash) {
412
696
  const existing = (0, import_ai_localize_shared3.readJsonSafe)(this.cachePath);
413
- if (existing?.version === "1") return existing;
414
- return { version: "1", lastRun: (/* @__PURE__ */ new Date()).toISOString(), fileHashes: {}, processedFiles: {} };
697
+ if (existing?.version === "1") {
698
+ if (configHash && existing.configHash !== configHash) {
699
+ return { version: "1", lastRun: (/* @__PURE__ */ new Date()).toISOString(), configHash, fileHashes: {}, processedFiles: {} };
700
+ }
701
+ return existing;
702
+ }
703
+ return { version: "1", lastRun: (/* @__PURE__ */ new Date()).toISOString(), configHash, fileHashes: {}, processedFiles: {} };
415
704
  }
416
705
  isFileChanged(filePath) {
417
706
  return this.hashFile(filePath) !== this.cache.fileHashes[filePath];
@@ -439,7 +728,13 @@ var IncrementalScanCache = class {
439
728
  }
440
729
  }
441
730
  clear() {
442
- this.cache = { version: "1", lastRun: (/* @__PURE__ */ new Date()).toISOString(), fileHashes: {}, processedFiles: {} };
731
+ this.cache = {
732
+ version: "1",
733
+ lastRun: (/* @__PURE__ */ new Date()).toISOString(),
734
+ configHash: this.cache.configHash,
735
+ fileHashes: {},
736
+ processedFiles: {}
737
+ };
443
738
  this.persist();
444
739
  }
445
740
  };
@@ -447,6 +742,7 @@ var IncrementalScanCache = class {
447
742
  // src/project-scanner.ts
448
743
  var path3 = __toESM(require("path"));
449
744
  var os = __toESM(require("os"));
745
+ var crypto2 = __toESM(require("crypto"));
450
746
  var import_ai_localize_shared4 = require("ai-localize-shared");
451
747
  var ProjectScanner = class {
452
748
  config;
@@ -459,10 +755,39 @@ var ProjectScanner = class {
459
755
  this.assetScanner = new AssetScanner(config.aws?.legacyCdnPattern);
460
756
  if (config.incrementalCache) {
461
757
  this.cache = new IncrementalScanCache(
462
- path3.join(process.cwd(), config.cacheDir || ".ai-localize-cache")
758
+ path3.join(process.cwd(), config.cacheDir || ".ai-localize-cache"),
759
+ this.hashConfig(config)
463
760
  );
464
761
  }
465
762
  }
763
+ /**
764
+ * Produces a stable SHA-256 fingerprint of the config fields that affect
765
+ * scan output. When any of these change the incremental cache is fully
766
+ * invalidated so the next run re-scans every file with the new settings.
767
+ *
768
+ * Fields intentionally excluded: `incrementalCache`, `cacheDir`, `aws`,
769
+ * `plugins` — none of those influence what text the AST scanner detects
770
+ * or how keys are generated.
771
+ */
772
+ hashConfig(config) {
773
+ const relevant = {
774
+ framework: config.framework,
775
+ defaultLanguage: config.defaultLanguage,
776
+ targetLanguages: config.targetLanguages,
777
+ sourceDir: config.sourceDir,
778
+ localesDir: config.localesDir,
779
+ keyPrefix: config.keyPrefix,
780
+ namespaces: config.namespaces,
781
+ ignorePatterns: config.ignorePatterns,
782
+ includePatterns: config.includePatterns,
783
+ localeStructure: config.localeStructure,
784
+ keyStyle: config.keyStyle,
785
+ staticKeys: config.staticKeys,
786
+ ignoreTextPatterns: config.ignoreTextPatterns,
787
+ codemods: config.codemods
788
+ };
789
+ return crypto2.createHash("sha256").update(JSON.stringify(relevant)).digest("hex");
790
+ }
466
791
  async scan(options = {}) {
467
792
  const startTime = Date.now();
468
793
  let filesToScan = [];
@@ -528,7 +853,8 @@ var ProjectScanner = class {
528
853
  content,
529
854
  sourceRoot: this.sourceRoot,
530
855
  codemodConfig: this.config.codemods,
531
- keyStyle: this.config.keyStyle ?? "path"
856
+ keyStyle: this.config.keyStyle ?? "path",
857
+ ignoreTextPatterns: this.config.ignoreTextPatterns ?? []
532
858
  });
533
859
  const texts = scanner.scan();
534
860
  const { assets, legacyCdnUrls } = this.assetScanner.scanFile(filePath);
package/dist/index.mjs CHANGED
@@ -1,22 +1,254 @@
1
1
  // src/ast-scanner.ts
2
2
  import * as parser from "@babel/parser";
3
- import traverse from "@babel/traverse";
3
+ import _traverse from "@babel/traverse";
4
4
  import * as t from "@babel/types";
5
5
  import {
6
6
  isHumanReadableText,
7
+ isCssClassString,
7
8
  normalizeText,
8
9
  TEXT_ATTRIBUTE_NAMES,
9
10
  generateKeyByStyle
10
11
  } from "ai-localize-shared";
12
+ var traverse = _traverse.default ?? _traverse;
11
13
  var BUILTIN_TRANSLATION_IMPORT_SOURCES = /* @__PURE__ */ new Set([
12
14
  "react-i18next",
13
15
  "i18next",
14
16
  "vue-i18n",
15
17
  "@ngx-translate/core"
16
18
  ]);
19
+ var NON_TRANSLATABLE_ATTR_NAMES = /* @__PURE__ */ new Set([
20
+ "classname",
21
+ "class",
22
+ "id",
23
+ "name",
24
+ "key",
25
+ "ref",
26
+ "type",
27
+ "method",
28
+ "action",
29
+ "enctype",
30
+ "href",
31
+ "src",
32
+ "srcset",
33
+ "action",
34
+ "to",
35
+ "as",
36
+ "path",
37
+ "url",
38
+ "rel",
39
+ "target",
40
+ "referrerpolicy",
41
+ "crossorigin",
42
+ "fetchpriority",
43
+ "loading",
44
+ "decoding",
45
+ "sizes",
46
+ "media",
47
+ "role",
48
+ "tabindex",
49
+ "autocomplete",
50
+ "inputmode",
51
+ "dir",
52
+ "lang",
53
+ "charset",
54
+ "for",
55
+ "htmlfor",
56
+ "htmlFor",
57
+ "accept",
58
+ "capture",
59
+ "pattern",
60
+ "autocorrect",
61
+ "autocapitalize",
62
+ "spellcheck",
63
+ "style",
64
+ "css",
65
+ "data-testid",
66
+ "data-cy",
67
+ "data-test",
68
+ "data-id",
69
+ "variant",
70
+ "color",
71
+ "size",
72
+ "shape",
73
+ "align",
74
+ "valign",
75
+ "justify",
76
+ "orientation",
77
+ "direction",
78
+ "placement",
79
+ "position",
80
+ "icon",
81
+ "iconname",
82
+ "lefticon",
83
+ "righticon"
84
+ ]);
85
+ var NON_TRANSLATABLE_PROP_KEYS = /* @__PURE__ */ new Set([
86
+ // HTML/JSX structural
87
+ "className",
88
+ "class",
89
+ "id",
90
+ "name",
91
+ "key",
92
+ "ref",
93
+ "type",
94
+ "method",
95
+ "action",
96
+ "href",
97
+ "src",
98
+ "rel",
99
+ "target",
100
+ "role",
101
+ "style",
102
+ "css",
103
+ "to",
104
+ "as",
105
+ "path",
106
+ "url",
107
+ "link",
108
+ "icon",
109
+ "variant",
110
+ "color",
111
+ "size",
112
+ // CSS-in-JS style properties
113
+ "fontFamily",
114
+ "fontWeight",
115
+ "fontStyle",
116
+ "fontVariant",
117
+ "fontSize",
118
+ "fontStretch",
119
+ "color",
120
+ "backgroundColor",
121
+ "borderColor",
122
+ "outlineColor",
123
+ "textDecorationColor",
124
+ "display",
125
+ "position",
126
+ "visibility",
127
+ "overflow",
128
+ "overflowX",
129
+ "overflowY",
130
+ "cursor",
131
+ "pointerEvents",
132
+ "userSelect",
133
+ "appearance",
134
+ "resize",
135
+ "textAlign",
136
+ "verticalAlign",
137
+ "textDecoration",
138
+ "textTransform",
139
+ "textOverflow",
140
+ "whiteSpace",
141
+ "wordBreak",
142
+ "wordWrap",
143
+ "overflowWrap",
144
+ "lineBreak",
145
+ "hyphens",
146
+ "flexDirection",
147
+ "flexWrap",
148
+ "alignItems",
149
+ "alignContent",
150
+ "alignSelf",
151
+ "justifyContent",
152
+ "justifyItems",
153
+ "justifySelf",
154
+ "flexFlow",
155
+ "gridAutoFlow",
156
+ "gridAutoColumns",
157
+ "gridAutoRows",
158
+ "float",
159
+ "clear",
160
+ "objectFit",
161
+ "objectPosition",
162
+ "listStyle",
163
+ "listStyleType",
164
+ "borderStyle",
165
+ "outlineStyle",
166
+ "backgroundRepeat",
167
+ "backgroundAttachment",
168
+ "backgroundPosition",
169
+ "backgroundSize",
170
+ "backgroundBlendMode",
171
+ "mixBlendMode",
172
+ "isolation",
173
+ "boxSizing",
174
+ "tableLayout",
175
+ "captionSide",
176
+ "borderCollapse",
177
+ "imageRendering",
178
+ "shapeOutside",
179
+ "shapeBox",
180
+ "writingMode",
181
+ "direction",
182
+ "speak",
183
+ "contentVisibility",
184
+ // Animation / transition
185
+ "animationName",
186
+ "animationTimingFunction",
187
+ "animationFillMode",
188
+ "animationDirection",
189
+ "animationPlayState",
190
+ "transitionTimingFunction",
191
+ "transitionProperty",
192
+ "transformOrigin",
193
+ "transformBox",
194
+ "transformStyle",
195
+ // Event handler patterns
196
+ "onChange",
197
+ "onClick",
198
+ "onSubmit",
199
+ "onFocus",
200
+ "onBlur",
201
+ "onKeyDown",
202
+ "onKeyUp",
203
+ "onKeyPress",
204
+ "onMouseEnter",
205
+ "onMouseLeave",
206
+ "onMouseOver",
207
+ "onMouseOut",
208
+ "onMouseDown",
209
+ "onMouseUp",
210
+ "onDragStart",
211
+ "onDrop",
212
+ "onTouchStart",
213
+ "onTouchEnd",
214
+ "onTouchMove",
215
+ "onScroll",
216
+ "onResize",
217
+ "onLoad",
218
+ "onError",
219
+ "onAbort",
220
+ "onContextMenu",
221
+ "onDoubleClick",
222
+ "onSelect",
223
+ "onInput",
224
+ "onPaste",
225
+ "onCopy",
226
+ "onCut",
227
+ "onWheel"
228
+ ]);
229
+ var CSS_UTILITY_FN_NAMES = /* @__PURE__ */ new Set([
230
+ "clsx",
231
+ "cx",
232
+ "classnames",
233
+ "classNames",
234
+ "cn",
235
+ "cc",
236
+ "twMerge",
237
+ "twJoin",
238
+ "tw",
239
+ "cva",
240
+ "ctl",
241
+ "classes",
242
+ "makeClasses",
243
+ "styled",
244
+ // styled-components / @emotion tag
245
+ "css"
246
+ // @emotion/css or linaria
247
+ ]);
17
248
  var AstScanner = class {
18
249
  options;
19
250
  detectedTexts = [];
251
+ compiledIgnorePatterns = [];
20
252
  /** Identifiers whose call/bracket expressions contain already-translated strings. */
21
253
  translationFunctionNames;
22
254
  /**
@@ -27,6 +259,13 @@ var AstScanner = class {
27
259
  importSourceMatchers;
28
260
  constructor(options) {
29
261
  this.options = options;
262
+ this.compiledIgnorePatterns = (options.ignoreTextPatterns ?? []).flatMap((p) => {
263
+ try {
264
+ return [new RegExp(p)];
265
+ } catch {
266
+ return [];
267
+ }
268
+ });
30
269
  this.translationFunctionNames = /* @__PURE__ */ new Set(["t", "$t", "i18n", "translate"]);
31
270
  this.importSourceMatchers = Array.from(BUILTIN_TRANSLATION_IMPORT_SOURCES).map(
32
271
  (pkg) => (src) => src === pkg
@@ -67,9 +306,10 @@ var AstScanner = class {
67
306
  }
68
307
  this.collectTranslationImports(ast);
69
308
  traverse(ast, {
309
+ // ── JSX text nodes: <h1>Welcome</h1> ───────────────────────────────────
70
310
  JSXText: (nodePath) => {
71
311
  const text = normalizeText(nodePath.node.value);
72
- if (!isHumanReadableText(text)) return;
312
+ if (!this.isTranslatableText(text)) return;
73
313
  if (this.isInsideTranslationCall(nodePath)) return;
74
314
  this.addDetected(
75
315
  text,
@@ -79,13 +319,15 @@ var AstScanner = class {
79
319
  "JSXText"
80
320
  );
81
321
  },
322
+ // ── JSX text-content attributes: placeholder="...", alt="..." ──────────
82
323
  JSXAttribute: (nodePath) => {
83
324
  const attrName = t.isJSXIdentifier(nodePath.node.name) ? nodePath.node.name.name : "";
84
325
  if (!TEXT_ATTRIBUTE_NAMES.has(attrName.toLowerCase())) return;
326
+ if (NON_TRANSLATABLE_ATTR_NAMES.has(attrName.toLowerCase())) return;
85
327
  const valueNode = nodePath.node.value;
86
328
  if (!t.isStringLiteral(valueNode)) return;
87
329
  const text = normalizeText(valueNode.value);
88
- if (!isHumanReadableText(text)) return;
330
+ if (!this.isTranslatableText(text)) return;
89
331
  if (this.isInsideTranslationCall(nodePath)) return;
90
332
  const context = this.mapAttrToContext(attrName);
91
333
  this.addDetected(
@@ -96,21 +338,23 @@ var AstScanner = class {
96
338
  "JSXAttribute"
97
339
  );
98
340
  },
341
+ // ── StringLiteral in JS/TS expressions ─────────────────────────────────
99
342
  StringLiteral: (nodePath) => {
100
343
  if (t.isImportDeclaration(nodePath.parent)) return;
101
344
  if (t.isObjectProperty(nodePath.parent) && nodePath.parent.key === nodePath.node) 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;
345
+ if (t.isJSXAttribute(nodePath.parent)) return;
346
+ if (t.isObjectProperty(nodePath.parent) && t.isIdentifier(nodePath.parent.key)) {
347
+ if (NON_TRANSLATABLE_PROP_KEYS.has(nodePath.parent.key.name)) return;
106
348
  }
349
+ if (this.isInsideCssUtilityCall(nodePath)) return;
107
350
  if (this.isInsideTranslationCall(nodePath)) return;
108
351
  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(",")) {
352
+ if (/^[a-z][a-z0-9_.\-]*$/.test(val) || // all-lowercase token (CSS class / id / key)
353
+ /^#?[0-9a-fA-F]+$/.test(val)) {
110
354
  return;
111
355
  }
112
- const text = normalizeText(nodePath.node.value);
113
- if (!isHumanReadableText(text)) return;
356
+ const text = normalizeText(val);
357
+ if (!this.isTranslatableText(text)) return;
114
358
  this.addDetected(
115
359
  text,
116
360
  nodePath.node.loc?.start.line ?? 0,
@@ -119,11 +363,12 @@ var AstScanner = class {
119
363
  "StringLiteral"
120
364
  );
121
365
  },
366
+ // ── Template literals with no expressions: `Hello world` ───────────────
122
367
  TemplateLiteral: (nodePath) => {
123
368
  if (nodePath.node.expressions.length > 0) return;
124
369
  if (this.isInsideTranslationCall(nodePath)) return;
125
370
  const text = normalizeText(nodePath.node.quasis[0]?.value.cooked ?? "");
126
- if (!isHumanReadableText(text)) return;
371
+ if (!this.isTranslatableText(text)) return;
127
372
  this.addDetected(
128
373
  text,
129
374
  nodePath.node.loc?.start.line ?? 0,
@@ -135,6 +380,39 @@ var AstScanner = class {
135
380
  });
136
381
  return this.detectedTexts;
137
382
  }
383
+ /**
384
+ * Central check: is this text worth extracting as a locale key?
385
+ * Applies isHumanReadableText(), isCssClassString(), and user ignoreTextPatterns.
386
+ */
387
+ isTranslatableText(text) {
388
+ if (!isHumanReadableText(text)) return false;
389
+ if (isCssClassString(text)) return false;
390
+ if (this.compiledIgnorePatterns.some((re) => re.test(text))) return false;
391
+ return true;
392
+ }
393
+ /**
394
+ * Returns true when the node path is inside a CSS utility function call:
395
+ * clsx("a", "b"), cn("x"), twMerge("foo", "bar"), styled("div"), etc.
396
+ */
397
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
398
+ isInsideCssUtilityCall(nodePath) {
399
+ let current = nodePath.parentPath;
400
+ while (current) {
401
+ const node = current.node;
402
+ if (t.isCallExpression(node)) {
403
+ const callee = node.callee;
404
+ if (t.isIdentifier(callee) && CSS_UTILITY_FN_NAMES.has(callee.name)) return true;
405
+ if (t.isMemberExpression(callee) && t.isIdentifier(callee.property) && CSS_UTILITY_FN_NAMES.has(callee.property.name)) return true;
406
+ }
407
+ if (t.isTaggedTemplateExpression(node)) {
408
+ const tag = node.tag;
409
+ if (t.isIdentifier(tag) && CSS_UTILITY_FN_NAMES.has(tag.name)) return true;
410
+ if (t.isMemberExpression(tag) && t.isIdentifier(tag.object) && CSS_UTILITY_FN_NAMES.has(tag.object.name)) return true;
411
+ }
412
+ current = current.parentPath;
413
+ }
414
+ return false;
415
+ }
138
416
  /**
139
417
  * Walk import declarations; when the source matches a known translation
140
418
  * import, collect all named/default imports as translation function names.
@@ -220,7 +498,7 @@ var AstScanner = class {
220
498
  jsxTextRegex.lastIndex = 0;
221
499
  while ((m = jsxTextRegex.exec(line)) !== null) {
222
500
  const text = normalizeText(m[1]);
223
- if (!isHumanReadableText(text)) continue;
501
+ if (!this.isTranslatableText(text)) continue;
224
502
  const key = generateKeyByStyle(
225
503
  this.options.filePath,
226
504
  text,
@@ -243,22 +521,22 @@ var AstScanner = class {
243
521
  }
244
522
  };
245
523
  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());
524
+ if (!importPackage.startsWith(".") && !importPackage.startsWith("/") && !importPackage.startsWith("@/")) {
525
+ return (src) => src === importPackage;
526
+ }
527
+ const normalise = (s) => s.replace(/^[@./]+/, "").replace(/\\/g, "/").toLowerCase();
528
+ const normTarget = normalise(importPackage);
529
+ const targetSegments = normTarget.split("/").filter(Boolean);
530
+ const segCount = targetSegments.length;
531
+ return (src) => {
532
+ if (src === importPackage) return true;
533
+ const normSrc = normalise(src);
534
+ const srcSegments = normSrc.split("/").filter(Boolean);
535
+ if (srcSegments.length < segCount) return false;
536
+ const tail = srcSegments.slice(-segCount);
537
+ return tail.join("/") === targetSegments.join("/");
257
538
  };
258
539
  }
259
- function normalisePath(p) {
260
- return p.replace(/\\/g, "/").replace(/^(@\/|\.{1,2}\/)+/, "").replace(/^@/, "");
261
- }
262
540
 
263
541
  // src/asset-scanner.ts
264
542
  import * as fs from "fs";
@@ -368,15 +646,27 @@ import { readJsonSafe, writeJson, ensureDir } from "ai-localize-shared";
368
646
  var IncrementalScanCache = class {
369
647
  cachePath;
370
648
  cache;
371
- constructor(cacheDir) {
649
+ /**
650
+ * @param cacheDir Directory where `scan-cache.json` is stored.
651
+ * @param configHash SHA-256 hash of the resolved config object.
652
+ * When this differs from the persisted value the entire
653
+ * cache is invalidated so that config changes (keyStyle,
654
+ * ignoreTextPatterns, codemods, etc.) are always reflected.
655
+ */
656
+ constructor(cacheDir, configHash) {
372
657
  ensureDir(cacheDir);
373
658
  this.cachePath = path2.join(cacheDir, "scan-cache.json");
374
- this.cache = this.load();
659
+ this.cache = this.load(configHash);
375
660
  }
376
- load() {
661
+ load(configHash) {
377
662
  const existing = readJsonSafe(this.cachePath);
378
- if (existing?.version === "1") return existing;
379
- return { version: "1", lastRun: (/* @__PURE__ */ new Date()).toISOString(), fileHashes: {}, processedFiles: {} };
663
+ if (existing?.version === "1") {
664
+ if (configHash && existing.configHash !== configHash) {
665
+ return { version: "1", lastRun: (/* @__PURE__ */ new Date()).toISOString(), configHash, fileHashes: {}, processedFiles: {} };
666
+ }
667
+ return existing;
668
+ }
669
+ return { version: "1", lastRun: (/* @__PURE__ */ new Date()).toISOString(), configHash, fileHashes: {}, processedFiles: {} };
380
670
  }
381
671
  isFileChanged(filePath) {
382
672
  return this.hashFile(filePath) !== this.cache.fileHashes[filePath];
@@ -404,7 +694,13 @@ var IncrementalScanCache = class {
404
694
  }
405
695
  }
406
696
  clear() {
407
- this.cache = { version: "1", lastRun: (/* @__PURE__ */ new Date()).toISOString(), fileHashes: {}, processedFiles: {} };
697
+ this.cache = {
698
+ version: "1",
699
+ lastRun: (/* @__PURE__ */ new Date()).toISOString(),
700
+ configHash: this.cache.configHash,
701
+ fileHashes: {},
702
+ processedFiles: {}
703
+ };
408
704
  this.persist();
409
705
  }
410
706
  };
@@ -412,6 +708,7 @@ var IncrementalScanCache = class {
412
708
  // src/project-scanner.ts
413
709
  import * as path3 from "path";
414
710
  import * as os from "os";
711
+ import * as crypto2 from "crypto";
415
712
  import { collectFiles, DEFAULT_IGNORE_DIRS, SOURCE_EXTENSIONS } from "ai-localize-shared";
416
713
  var ProjectScanner = class {
417
714
  config;
@@ -424,10 +721,39 @@ var ProjectScanner = class {
424
721
  this.assetScanner = new AssetScanner(config.aws?.legacyCdnPattern);
425
722
  if (config.incrementalCache) {
426
723
  this.cache = new IncrementalScanCache(
427
- path3.join(process.cwd(), config.cacheDir || ".ai-localize-cache")
724
+ path3.join(process.cwd(), config.cacheDir || ".ai-localize-cache"),
725
+ this.hashConfig(config)
428
726
  );
429
727
  }
430
728
  }
729
+ /**
730
+ * Produces a stable SHA-256 fingerprint of the config fields that affect
731
+ * scan output. When any of these change the incremental cache is fully
732
+ * invalidated so the next run re-scans every file with the new settings.
733
+ *
734
+ * Fields intentionally excluded: `incrementalCache`, `cacheDir`, `aws`,
735
+ * `plugins` — none of those influence what text the AST scanner detects
736
+ * or how keys are generated.
737
+ */
738
+ hashConfig(config) {
739
+ const relevant = {
740
+ framework: config.framework,
741
+ defaultLanguage: config.defaultLanguage,
742
+ targetLanguages: config.targetLanguages,
743
+ sourceDir: config.sourceDir,
744
+ localesDir: config.localesDir,
745
+ keyPrefix: config.keyPrefix,
746
+ namespaces: config.namespaces,
747
+ ignorePatterns: config.ignorePatterns,
748
+ includePatterns: config.includePatterns,
749
+ localeStructure: config.localeStructure,
750
+ keyStyle: config.keyStyle,
751
+ staticKeys: config.staticKeys,
752
+ ignoreTextPatterns: config.ignoreTextPatterns,
753
+ codemods: config.codemods
754
+ };
755
+ return crypto2.createHash("sha256").update(JSON.stringify(relevant)).digest("hex");
756
+ }
431
757
  async scan(options = {}) {
432
758
  const startTime = Date.now();
433
759
  let filesToScan = [];
@@ -493,7 +819,8 @@ var ProjectScanner = class {
493
819
  content,
494
820
  sourceRoot: this.sourceRoot,
495
821
  codemodConfig: this.config.codemods,
496
- keyStyle: this.config.keyStyle ?? "path"
822
+ keyStyle: this.config.keyStyle ?? "path",
823
+ ignoreTextPatterns: this.config.ignoreTextPatterns ?? []
497
824
  });
498
825
  const texts = scanner.scan();
499
826
  const { assets, legacyCdnUrls } = this.assetScanner.scanFile(filePath);
package/package.json CHANGED
@@ -1,10 +1,22 @@
1
1
  {
2
2
  "name": "ai-localize-scanner",
3
- "version": "2.0.6",
3
+ "version": "3.0.0",
4
4
  "description": "AST-based hardcoded text scanner for frontend applications",
5
+ "author": "ai-localize-core contributors",
6
+ "license": "MIT",
7
+ "repository": {
8
+ "type": "git",
9
+ "url": "https://github.com/ai-localize/ai-localize-core.git",
10
+ "directory": "packages/scanner"
11
+ },
12
+ "homepage": "https://github.com/ai-localize/ai-localize-core/tree/main/packages/scanner#readme",
13
+ "bugs": {
14
+ "url": "https://github.com/ai-localize/ai-localize-core/issues"
15
+ },
5
16
  "main": "./dist/index.js",
6
17
  "module": "./dist/index.mjs",
7
18
  "types": "./dist/index.d.ts",
19
+ "sideEffects": false,
8
20
  "files": [
9
21
  "dist",
10
22
  "README.md",
@@ -39,8 +51,8 @@
39
51
  "@babel/traverse": "^7.23.9",
40
52
  "@babel/types": "^7.23.9",
41
53
  "glob": "^10.3.10",
42
- "ai-localize-shared": "2.0.6",
43
- "ai-localize-config": "2.0.6"
54
+ "ai-localize-shared": "3.0.0",
55
+ "ai-localize-config": "3.0.0"
44
56
  },
45
57
  "devDependencies": {
46
58
  "@types/babel__traverse": "^7.20.5",
@@ -48,7 +60,6 @@
48
60
  "typescript": "^5.3.3",
49
61
  "vitest": "^1.2.1"
50
62
  },
51
- "license": "MIT",
52
63
  "publishConfig": {
53
64
  "access": "public"
54
65
  },