ai-localize-scanner 2.0.6 → 2.0.7

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