ai-localize-scanner 2.0.1 → 2.0.4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -1,5 +1,26 @@
1
1
  # ai-localize-scanner
2
2
 
3
+ ## 2.0.3
4
+
5
+ ### Patch Changes
6
+
7
+ - Updated dependencies
8
+ - ai-localize-shared@2.0.3
9
+ - ai-localize-config@2.0.3
10
+
11
+ ## 2.0.2
12
+
13
+ ### Minor Changes
14
+
15
+ - **AST scanner seeds translation-fn names from codemods config** — translation function names configured under `codemods.translationFunction` are now automatically recognised by the AST scanner so already-translated strings are correctly skipped
16
+
17
+ ### Patch Changes
18
+
19
+ - **Bug fix — locale key prefix with absolute sourceRoot** — `normalizePath()` now calls `path.relative()` when `sourceRoot` is an absolute path; `ProjectScanner` passes the resolved absolute `sourceRoot` to `AstScanner`, preventing ancestor path segments leaking into generated locale keys
20
+ - Updated dependencies
21
+ - ai-localize-config@2.0.2
22
+ - ai-localize-shared@2.0.2
23
+
3
24
  ## 2.0.1
4
25
 
5
26
  ### Patch Changes
package/dist/index.d.mts CHANGED
@@ -1,9 +1,20 @@
1
- import { CodemodConfig, DetectedText, AssetReference, LegacyCdnUrl, LocalizationConfig, ScanResult } from 'ai-localize-shared';
1
+ import { KeyStyle, CodemodConfig, DetectedText, AssetReference, LegacyCdnUrl, LocalizationConfig, ScanResult } from 'ai-localize-shared';
2
2
 
3
3
  interface AstScanOptions {
4
4
  filePath: string;
5
5
  content: string;
6
6
  sourceRoot?: string;
7
+ /**
8
+ * Controls the format of the generated locale key for each detected text.
9
+ *
10
+ * - `"path"` (default) — hierarchical dot-notation key derived from file path + text:
11
+ * `settings.settings_page.save_changes`
12
+ *
13
+ * - `"screaming_snake"` — UPPER_SNAKE_CASE key derived solely from the text value:
14
+ * "Save Changes" → `SAVE_CHANGES`
15
+ * "Max Count" → `MAX_COUNT`
16
+ */
17
+ keyStyle?: KeyStyle;
7
18
  /**
8
19
  * Optional codemod config from ai-localize.config.json.
9
20
  *
@@ -13,7 +24,7 @@ interface AstScanOptions {
13
24
  * importPackage — matched against import source strings. Supports:
14
25
  * - npm package names: "react-i18next", "my-i18n-lib"
15
26
  * - path aliases: "@/hooks/useTranslation", "@/i18n"
16
- * - relative paths: "../../hooks/useTranslation"
27
+ * - relative paths: "../../hooks/useTranslation"
17
28
  * Matching is done by checking whether the import source equals the value
18
29
  * OR ends with the last path segment(s) of the value (normalised).
19
30
  *
@@ -37,10 +48,10 @@ declare class AstScanner {
37
48
  /** Identifiers whose call/bracket expressions contain already-translated strings. */
38
49
  private translationFunctionNames;
39
50
  /**
40
- * Import source matchers.
41
- * Each entry is either an exact string (npm package) or a path-suffix matcher
42
- * function built from a relative/alias path.
43
- */
51
+ * Import source matchers.
52
+ * Each entry is either an exact string (npm package) or a path-suffix matcher
53
+ * function built from a relative/alias path.
54
+ */
44
55
  private importSourceMatchers;
45
56
  constructor(options: AstScanOptions);
46
57
  scan(): DetectedText[];
package/dist/index.d.ts CHANGED
@@ -1,9 +1,20 @@
1
- import { CodemodConfig, DetectedText, AssetReference, LegacyCdnUrl, LocalizationConfig, ScanResult } from 'ai-localize-shared';
1
+ import { KeyStyle, CodemodConfig, DetectedText, AssetReference, LegacyCdnUrl, LocalizationConfig, ScanResult } from 'ai-localize-shared';
2
2
 
3
3
  interface AstScanOptions {
4
4
  filePath: string;
5
5
  content: string;
6
6
  sourceRoot?: string;
7
+ /**
8
+ * Controls the format of the generated locale key for each detected text.
9
+ *
10
+ * - `"path"` (default) — hierarchical dot-notation key derived from file path + text:
11
+ * `settings.settings_page.save_changes`
12
+ *
13
+ * - `"screaming_snake"` — UPPER_SNAKE_CASE key derived solely from the text value:
14
+ * "Save Changes" → `SAVE_CHANGES`
15
+ * "Max Count" → `MAX_COUNT`
16
+ */
17
+ keyStyle?: KeyStyle;
7
18
  /**
8
19
  * Optional codemod config from ai-localize.config.json.
9
20
  *
@@ -13,7 +24,7 @@ interface AstScanOptions {
13
24
  * importPackage — matched against import source strings. Supports:
14
25
  * - npm package names: "react-i18next", "my-i18n-lib"
15
26
  * - path aliases: "@/hooks/useTranslation", "@/i18n"
16
- * - relative paths: "../../hooks/useTranslation"
27
+ * - relative paths: "../../hooks/useTranslation"
17
28
  * Matching is done by checking whether the import source equals the value
18
29
  * OR ends with the last path segment(s) of the value (normalised).
19
30
  *
@@ -37,10 +48,10 @@ declare class AstScanner {
37
48
  /** Identifiers whose call/bracket expressions contain already-translated strings. */
38
49
  private translationFunctionNames;
39
50
  /**
40
- * Import source matchers.
41
- * Each entry is either an exact string (npm package) or a path-suffix matcher
42
- * function built from a relative/alias path.
43
- */
51
+ * Import source matchers.
52
+ * Each entry is either an exact string (npm package) or a path-suffix matcher
53
+ * function built from a relative/alias path.
54
+ */
44
55
  private importSourceMatchers;
45
56
  constructor(options: AstScanOptions);
46
57
  scan(): DetectedText[];
package/dist/index.js CHANGED
@@ -55,10 +55,10 @@ var AstScanner = class {
55
55
  /** Identifiers whose call/bracket expressions contain already-translated strings. */
56
56
  translationFunctionNames;
57
57
  /**
58
- * Import source matchers.
59
- * Each entry is either an exact string (npm package) or a path-suffix matcher
60
- * function built from a relative/alias path.
61
- */
58
+ * Import source matchers.
59
+ * Each entry is either an exact string (npm package) or a path-suffix matcher
60
+ * function built from a relative/alias path.
61
+ */
62
62
  importSourceMatchers;
63
63
  constructor(options) {
64
64
  this.options = options;
@@ -221,10 +221,11 @@ var AstScanner = class {
221
221
  return false;
222
222
  }
223
223
  addDetected(text, line, column, context, nodeType) {
224
- const key = (0, import_ai_localize_shared.generateLocaleKey)(
224
+ const key = (0, import_ai_localize_shared.generateKeyByStyle)(
225
225
  this.options.filePath,
226
226
  text,
227
- this.options.sourceRoot || "src"
227
+ this.options.sourceRoot || "src",
228
+ this.options.keyStyle || "path"
228
229
  );
229
230
  this.detectedTexts.push({
230
231
  filePath: this.options.filePath,
@@ -255,10 +256,11 @@ var AstScanner = class {
255
256
  while ((m = jsxTextRegex.exec(line)) !== null) {
256
257
  const text = (0, import_ai_localize_shared.normalizeText)(m[1]);
257
258
  if (!(0, import_ai_localize_shared.isHumanReadableText)(text)) continue;
258
- const key = (0, import_ai_localize_shared.generateLocaleKey)(
259
+ const key = (0, import_ai_localize_shared.generateKeyByStyle)(
259
260
  this.options.filePath,
260
261
  text,
261
- this.options.sourceRoot || "src"
262
+ this.options.sourceRoot || "src",
263
+ this.options.keyStyle || "path"
262
264
  );
263
265
  results.push({
264
266
  filePath: this.options.filePath,
@@ -525,7 +527,8 @@ var ProjectScanner = class {
525
527
  filePath,
526
528
  content,
527
529
  sourceRoot: this.sourceRoot,
528
- codemodConfig: this.config.codemods
530
+ codemodConfig: this.config.codemods,
531
+ keyStyle: this.config.keyStyle ?? "path"
529
532
  });
530
533
  const texts = scanner.scan();
531
534
  const { assets, legacyCdnUrls } = this.assetScanner.scanFile(filePath);
package/dist/index.mjs CHANGED
@@ -6,7 +6,7 @@ import {
6
6
  isHumanReadableText,
7
7
  normalizeText,
8
8
  TEXT_ATTRIBUTE_NAMES,
9
- generateLocaleKey
9
+ generateKeyByStyle
10
10
  } from "ai-localize-shared";
11
11
  var BUILTIN_TRANSLATION_IMPORT_SOURCES = /* @__PURE__ */ new Set([
12
12
  "react-i18next",
@@ -20,10 +20,10 @@ var AstScanner = class {
20
20
  /** Identifiers whose call/bracket expressions contain already-translated strings. */
21
21
  translationFunctionNames;
22
22
  /**
23
- * Import source matchers.
24
- * Each entry is either an exact string (npm package) or a path-suffix matcher
25
- * function built from a relative/alias path.
26
- */
23
+ * Import source matchers.
24
+ * Each entry is either an exact string (npm package) or a path-suffix matcher
25
+ * function built from a relative/alias path.
26
+ */
27
27
  importSourceMatchers;
28
28
  constructor(options) {
29
29
  this.options = options;
@@ -186,10 +186,11 @@ var AstScanner = class {
186
186
  return false;
187
187
  }
188
188
  addDetected(text, line, column, context, nodeType) {
189
- const key = generateLocaleKey(
189
+ const key = generateKeyByStyle(
190
190
  this.options.filePath,
191
191
  text,
192
- this.options.sourceRoot || "src"
192
+ this.options.sourceRoot || "src",
193
+ this.options.keyStyle || "path"
193
194
  );
194
195
  this.detectedTexts.push({
195
196
  filePath: this.options.filePath,
@@ -220,10 +221,11 @@ var AstScanner = class {
220
221
  while ((m = jsxTextRegex.exec(line)) !== null) {
221
222
  const text = normalizeText(m[1]);
222
223
  if (!isHumanReadableText(text)) continue;
223
- const key = generateLocaleKey(
224
+ const key = generateKeyByStyle(
224
225
  this.options.filePath,
225
226
  text,
226
- this.options.sourceRoot || "src"
227
+ this.options.sourceRoot || "src",
228
+ this.options.keyStyle || "path"
227
229
  );
228
230
  results.push({
229
231
  filePath: this.options.filePath,
@@ -490,7 +492,8 @@ var ProjectScanner = class {
490
492
  filePath,
491
493
  content,
492
494
  sourceRoot: this.sourceRoot,
493
- codemodConfig: this.config.codemods
495
+ codemodConfig: this.config.codemods,
496
+ keyStyle: this.config.keyStyle ?? "path"
494
497
  });
495
498
  const texts = scanner.scan();
496
499
  const { assets, legacyCdnUrls } = this.assetScanner.scanFile(filePath);
package/package.json CHANGED
@@ -1,10 +1,15 @@
1
1
  {
2
2
  "name": "ai-localize-scanner",
3
- "version": "2.0.1",
3
+ "version": "2.0.4",
4
4
  "description": "AST-based hardcoded text scanner for frontend applications",
5
5
  "main": "./dist/index.js",
6
6
  "module": "./dist/index.mjs",
7
7
  "types": "./dist/index.d.ts",
8
+ "files": [
9
+ "dist",
10
+ "README.md",
11
+ "CHANGELOG.md"
12
+ ],
8
13
  "exports": {
9
14
  ".": {
10
15
  "types": "./dist/index.d.ts",
@@ -12,13 +17,30 @@
12
17
  "require": "./dist/index.js"
13
18
  }
14
19
  },
20
+ "keywords": [
21
+ "i18n",
22
+ "localization",
23
+ "l10n",
24
+ "internationalization",
25
+ "ai-localize",
26
+ "ast",
27
+ "scanner",
28
+ "babel",
29
+ "hardcoded-strings",
30
+ "react",
31
+ "vue",
32
+ "angular"
33
+ ],
34
+ "engines": {
35
+ "node": ">=18.0.0"
36
+ },
15
37
  "dependencies": {
16
38
  "@babel/parser": "^7.23.9",
17
39
  "@babel/traverse": "^7.23.9",
18
40
  "@babel/types": "^7.23.9",
19
41
  "glob": "^10.3.10",
20
- "ai-localize-shared": "2.0.1",
21
- "ai-localize-config": "2.0.1"
42
+ "ai-localize-shared": "2.0.4",
43
+ "ai-localize-config": "2.0.4"
22
44
  },
23
45
  "devDependencies": {
24
46
  "@types/babel__traverse": "^7.20.5",
@@ -1,65 +0,0 @@
1
- import { describe, it, expect } from 'vitest';
2
- import { AstScanner } from '../ast-scanner.js';
3
-
4
- describe('AstScanner', () => {
5
- it('detects JSX text', () => {
6
- const content = `
7
- export default function Button() {
8
- return <button>Save Campaign</button>;
9
- }
10
- `;
11
- const scanner = new AstScanner({ filePath: 'src/Button.tsx', content, sourceRoot: 'src' });
12
- const results = scanner.scan();
13
- expect(results.length).toBeGreaterThan(0);
14
- expect(results[0].text).toBe('Save Campaign');
15
- expect(results[0].context).toBe('jsx-text');
16
- });
17
-
18
- it('detects JSX attribute placeholder', () => {
19
- const content = `
20
- export default function Input() {
21
- return <input placeholder="Enter your name" />;
22
- }
23
- `;
24
- const scanner = new AstScanner({ filePath: 'src/Input.tsx', content, sourceRoot: 'src' });
25
- const results = scanner.scan();
26
- const placeholders = results.filter((r) => r.context === 'placeholder');
27
- expect(placeholders.length).toBeGreaterThan(0);
28
- expect(placeholders[0].text).toBe('Enter your name');
29
- });
30
-
31
- it('skips already-translated text', () => {
32
- const content = `
33
- import { useTranslation } from 'react-i18next';
34
- export default function Button() {
35
- const { t } = useTranslation();
36
- return <button>{t('button.save')}</button>;
37
- }
38
- `;
39
- const scanner = new AstScanner({ filePath: 'src/Button.tsx', content, sourceRoot: 'src' });
40
- const results = scanner.scan();
41
- expect(results.filter((r) => r.text === 'button.save').length).toBe(0);
42
- });
43
-
44
- it('skips import declarations', () => {
45
- const content = `
46
- import { something } from 'some-package';
47
- export default function App() {
48
- return <div>Hello World</div>;
49
- }
50
- `;
51
- const scanner = new AstScanner({ filePath: 'src/App.tsx', content, sourceRoot: 'src' });
52
- const results = scanner.scan();
53
- const importResults = results.filter((r) => r.text === 'some-package');
54
- expect(importResults.length).toBe(0);
55
- });
56
-
57
- it('generates deterministic locale keys', () => {
58
- const content = `export default function Btn() { return <button>Save</button>; }`;
59
- const scanner1 = new AstScanner({ filePath: 'src/Btn.tsx', content, sourceRoot: 'src' });
60
- const scanner2 = new AstScanner({ filePath: 'src/Btn.tsx', content, sourceRoot: 'src' });
61
- const results1 = scanner1.scan();
62
- const results2 = scanner2.scan();
63
- expect(results1[0]?.suggestedKey).toBe(results2[0]?.suggestedKey);
64
- });
65
- });
@@ -1,118 +0,0 @@
1
- import * as fs from 'fs';
2
- import * as path from 'path';
3
-
4
- import type { AssetReference, AssetType, LegacyCdnUrl } from 'ai-localize-shared';
5
- import { ASSET_EXTENSIONS } from 'ai-localize-shared';
6
-
7
- const CDN_URL_PATTERN = /https?:\/\/[a-zA-Z0-9\-.]+\.[a-zA-Z]{2,}\/[^\s"'`\)\]>]+/g;
8
- const CSS_URL_PATTERN = /url\(['"\s]?([^'")]+)['"\s]?\)/g;
9
- const IMPORT_ASSET_PATTERN =
10
- /import\s+\w+\s+from\s+['"]([^'"]+\.(png|jpg|jpeg|svg|webp|gif|ico|woff|woff2|ttf|eot|mp4))['"];?/gi;
11
- const SRC_ATTR_PATTERN =
12
- /(?:src|href)=["']([^"']+\.(png|jpg|jpeg|svg|webp|gif|ico|mp4))["']/gi;
13
-
14
- export class AssetScanner {
15
- private legacyCdnPattern: RegExp | null = null;
16
-
17
- constructor(legacyCdnPattern?: string) {
18
- if (legacyCdnPattern) {
19
- try {
20
- this.legacyCdnPattern = new RegExp(legacyCdnPattern, 'g');
21
- } catch {
22
- // Invalid regex, ignore
23
- }
24
- }
25
- }
26
-
27
- scanFile(filePath: string): { assets: AssetReference[]; legacyCdnUrls: LegacyCdnUrl[] } {
28
- const assets: AssetReference[] = [];
29
- const legacyCdnUrls: LegacyCdnUrl[] = [];
30
-
31
- let content: string;
32
- try {
33
- content = fs.readFileSync(filePath, 'utf-8');
34
- } catch {
35
- return { assets, legacyCdnUrls };
36
- }
37
-
38
- let m: RegExpExecArray | null;
39
-
40
- IMPORT_ASSET_PATTERN.lastIndex = 0;
41
- while ((m = IMPORT_ASSET_PATTERN.exec(content)) !== null) {
42
- const assetPath = m[1];
43
- assets.push({
44
- filePath,
45
- line: this.getLineNumber(content, m.index),
46
- assetPath,
47
- assetType: this.getAssetType(assetPath),
48
- referenceType: 'import',
49
- });
50
- }
51
-
52
- CSS_URL_PATTERN.lastIndex = 0;
53
- while ((m = CSS_URL_PATTERN.exec(content)) !== null) {
54
- const assetPath = m[1];
55
- if (assetPath.startsWith('data:')) continue;
56
- assets.push({
57
- filePath,
58
- line: this.getLineNumber(content, m.index),
59
- assetPath,
60
- assetType: this.getAssetType(assetPath),
61
- referenceType: 'css-url',
62
- });
63
- }
64
-
65
- SRC_ATTR_PATTERN.lastIndex = 0;
66
- while ((m = SRC_ATTR_PATTERN.exec(content)) !== null) {
67
- assets.push({
68
- filePath,
69
- line: this.getLineNumber(content, m.index),
70
- assetPath: m[1],
71
- assetType: this.getAssetType(m[1]),
72
- referenceType: 'src-attr',
73
- });
74
- }
75
-
76
- if (this.legacyCdnPattern) {
77
- this.legacyCdnPattern.lastIndex = 0;
78
- while ((m = this.legacyCdnPattern.exec(content)) !== null) {
79
- const url = m[0];
80
- legacyCdnUrls.push({
81
- filePath,
82
- line: this.getLineNumber(content, m.index),
83
- url,
84
- assetPath: this.extractPathFromUrl(url),
85
- });
86
- }
87
- }
88
-
89
- CDN_URL_PATTERN.lastIndex = 0;
90
- while ((m = CDN_URL_PATTERN.exec(content)) !== null) {
91
- const url = m[0];
92
- if (!ASSET_EXTENSIONS.some((ext) => url.includes(`.${ext}`))) continue;
93
- const line = this.getLineNumber(content, m.index);
94
- if (!legacyCdnUrls.find((u) => u.url === url && u.line === line)) {
95
- legacyCdnUrls.push({ filePath, line, url, assetPath: this.extractPathFromUrl(url) });
96
- }
97
- }
98
-
99
- return { assets, legacyCdnUrls };
100
- }
101
-
102
- private getLineNumber(content: string, index: number): number {
103
- return content.slice(0, index).split('\n').length;
104
- }
105
-
106
- private getAssetType(assetPath: string): AssetType {
107
- const ext = path.extname(assetPath).toLowerCase().replace('.', '') as AssetType;
108
- return (ASSET_EXTENSIONS.includes(ext) ? ext : 'other') as AssetType;
109
- }
110
-
111
- private extractPathFromUrl(url: string): string {
112
- try {
113
- return new URL(url).pathname;
114
- } catch {
115
- return url;
116
- }
117
- }
118
- }
@@ -1,400 +0,0 @@
1
- import * as parser from '@babel/parser';
2
- import traverse from '@babel/traverse';
3
- import * as t from '@babel/types';
4
-
5
- import type { DetectedText, TextContext, CodemodConfig } from 'ai-localize-shared';
6
- import {
7
- isHumanReadableText,
8
- normalizeText,
9
- TEXT_ATTRIBUTE_NAMES,
10
- generateLocaleKey,
11
- } from 'ai-localize-shared';
12
-
13
- export interface AstScanOptions {
14
- filePath: string;
15
- content: string;
16
- sourceRoot?: string;
17
- /**
18
- * Optional codemod config from ai-localize.config.json.
19
- *
20
- * The scanner uses this to recognise already-translated strings even when
21
- * the project uses a custom i18n library or a locally-defined hook:
22
- *
23
- * importPackage — matched against import source strings. Supports:
24
- * - npm package names: "react-i18next", "my-i18n-lib"
25
- * - path aliases: "@/hooks/useTranslation", "@/i18n"
26
- * - relative paths: "../../hooks/useTranslation"
27
- * Matching is done by checking whether the import source equals the value
28
- * OR ends with the last path segment(s) of the value (normalised).
29
- *
30
- * hookName — the hook identifier (e.g. "useTranslation", "useI18n").
31
- * Added directly to the translation-function names set regardless of
32
- * how the hook is imported. This means even default imports, re-exports
33
- * or barrel aliases are handled correctly:
34
- * import useT from '../../hooks/useT' (default import, hookName="useT")
35
- *
36
- * translationFunction — the accessor returned by the hook (e.g. "t").
37
- * Added directly to the translation-function names set.
38
- */
39
- codemodConfig?: CodemodConfig;
40
- }
41
-
42
- /** Well-known i18n library import sources (npm package names). */
43
- const BUILTIN_TRANSLATION_IMPORT_SOURCES = new Set([
44
- 'react-i18next',
45
- 'i18next',
46
- 'vue-i18n',
47
- '@ngx-translate/core',
48
- ]);
49
-
50
- /**
51
- * Scans a JS/TS/JSX/TSX file using Babel AST to find hardcoded text.
52
- */
53
- export class AstScanner {
54
- private options: AstScanOptions;
55
- private detectedTexts: DetectedText[] = [];
56
-
57
- /** Identifiers whose call/bracket expressions contain already-translated strings. */
58
- private translationFunctionNames: Set<string>;
59
-
60
- /**
61
- * Import source matchers.
62
- * Each entry is either an exact string (npm package) or a path-suffix matcher
63
- * function built from a relative/alias path.
64
- */
65
- private importSourceMatchers: Array<(source: string) => boolean>;
66
-
67
- constructor(options: AstScanOptions) {
68
- this.options = options;
69
-
70
- // Seed default translation function names
71
- this.translationFunctionNames = new Set<string>(['t', '$t', 'i18n', 'translate']);
72
-
73
- // Build import-source matchers from builtin list
74
- this.importSourceMatchers = Array.from(BUILTIN_TRANSLATION_IMPORT_SOURCES).map(
75
- (pkg) => (src: string) => src === pkg
76
- );
77
-
78
- const cc = options.codemodConfig;
79
- if (cc) {
80
- // translationFunction — seed directly; no import lookup needed
81
- if (cc.translationFunction) {
82
- this.translationFunctionNames.add(cc.translationFunction);
83
- }
84
-
85
- // hookName — seed directly so the hook identifier is always recognised
86
- // regardless of how it is imported (named, default, aliased, re-exported).
87
- if (cc.hookName) {
88
- this.translationFunctionNames.add(cc.hookName);
89
- }
90
-
91
- // importPackage — build a matcher that handles:
92
- // 1. exact npm package name "react-i18next"
93
- // 2. path alias "@/hooks/useTranslation"
94
- // 3. relative path "../../hooks/useTranslation"
95
- if (cc.importPackage) {
96
- this.importSourceMatchers.push(buildImportMatcher(cc.importPackage));
97
- }
98
- }
99
- }
100
-
101
- scan(): DetectedText[] {
102
- const { content } = this.options;
103
-
104
- let ast: t.File;
105
- try {
106
- ast = parser.parse(content, {
107
- sourceType: 'module',
108
- plugins: [
109
- 'jsx',
110
- 'typescript',
111
- 'decorators-legacy',
112
- 'classProperties',
113
- 'optionalChaining',
114
- 'nullishCoalescingOperator',
115
- 'dynamicImport',
116
- 'exportDefaultFrom',
117
- ],
118
- errorRecovery: true,
119
- });
120
- } catch {
121
- return this.regexFallbackScan();
122
- }
123
-
124
- // Collect translation function names from imports before scanning text
125
- this.collectTranslationImports(ast);
126
-
127
- traverse(ast, {
128
- JSXText: (nodePath) => {
129
- const text = normalizeText(nodePath.node.value);
130
- if (!isHumanReadableText(text)) return;
131
- if (this.isInsideTranslationCall(nodePath)) return;
132
- this.addDetected(
133
- text,
134
- nodePath.node.loc?.start.line ?? 0,
135
- nodePath.node.loc?.start.column ?? 0,
136
- 'jsx-text',
137
- 'JSXText'
138
- );
139
- },
140
-
141
- JSXAttribute: (nodePath) => {
142
- const attrName = t.isJSXIdentifier(nodePath.node.name)
143
- ? nodePath.node.name.name
144
- : '';
145
- if (!TEXT_ATTRIBUTE_NAMES.has(attrName.toLowerCase())) return;
146
- const valueNode = nodePath.node.value;
147
- if (!t.isStringLiteral(valueNode)) return;
148
- const text = normalizeText(valueNode.value);
149
- if (!isHumanReadableText(text)) return;
150
- if (this.isInsideTranslationCall(nodePath)) return;
151
- const context = this.mapAttrToContext(attrName);
152
- this.addDetected(
153
- text,
154
- valueNode.loc?.start.line ?? 0,
155
- valueNode.loc?.start.column ?? 0,
156
- context,
157
- 'JSXAttribute'
158
- );
159
- },
160
-
161
- StringLiteral: (nodePath) => {
162
- if (t.isImportDeclaration(nodePath.parent)) return;
163
- if (t.isObjectProperty(nodePath.parent) && nodePath.parent.key === nodePath.node) return;
164
-
165
- // Ignore className and similar CSS-class attributes
166
- if (t.isJSXAttribute(nodePath.parent)) {
167
- const attrName = t.isJSXIdentifier(nodePath.parent.name)
168
- ? nodePath.parent.name.name.toLowerCase()
169
- : '';
170
- if (attrName === 'classname' || attrName === 'class') return;
171
- return;
172
- }
173
-
174
- if (this.isInsideTranslationCall(nodePath)) return;
175
-
176
- const val = nodePath.node.value;
177
- if (
178
- /^[a-z][a-z0-9_.-]*$/.test(val) ||
179
- /^#?[0-9a-fA-F]+$/.test(val) ||
180
- (/^[\w-]+\s[\w- ]+$/.test(val) && !val.includes(','))
181
- ) {
182
- return; // Likely a CSS class, ID, hex colour, or plain word pair
183
- }
184
-
185
- const text = normalizeText(nodePath.node.value);
186
- if (!isHumanReadableText(text)) return;
187
- this.addDetected(
188
- text,
189
- nodePath.node.loc?.start.line ?? 0,
190
- nodePath.node.loc?.start.column ?? 0,
191
- 'string-literal',
192
- 'StringLiteral'
193
- );
194
- },
195
-
196
- TemplateLiteral: (nodePath) => {
197
- if (nodePath.node.expressions.length > 0) return;
198
- if (this.isInsideTranslationCall(nodePath)) return;
199
- const text = normalizeText(nodePath.node.quasis[0]?.value.cooked ?? '');
200
- if (!isHumanReadableText(text)) return;
201
- this.addDetected(
202
- text,
203
- nodePath.node.loc?.start.line ?? 0,
204
- nodePath.node.loc?.start.column ?? 0,
205
- 'template-literal',
206
- 'TemplateLiteral'
207
- );
208
- },
209
- });
210
-
211
- return this.detectedTexts;
212
- }
213
-
214
- /**
215
- * Walk import declarations; when the source matches a known translation
216
- * import, collect all named/default imports as translation function names.
217
- */
218
- private collectTranslationImports(ast: t.File): void {
219
- for (const node of ast.program.body) {
220
- if (!t.isImportDeclaration(node)) continue;
221
- const source = node.source.value;
222
- if (!this.isTranslationImportSource(source)) continue;
223
- for (const specifier of node.specifiers) {
224
- if (t.isImportSpecifier(specifier) && t.isIdentifier(specifier.local)) {
225
- this.translationFunctionNames.add(specifier.local.name);
226
- }
227
- // Default import: import useT from '../../hooks/useT'
228
- if (t.isImportDefaultSpecifier(specifier) && t.isIdentifier(specifier.local)) {
229
- this.translationFunctionNames.add(specifier.local.name);
230
- }
231
- // Namespace import: import * as i18n from '...'
232
- if (t.isImportNamespaceSpecifier(specifier) && t.isIdentifier(specifier.local)) {
233
- this.translationFunctionNames.add(specifier.local.name);
234
- }
235
- }
236
- }
237
- }
238
-
239
- /** Returns true if the import source string should be treated as a translation library. */
240
- private isTranslationImportSource(source: string): boolean {
241
- return this.importSourceMatchers.some((match) => match(source));
242
- }
243
-
244
- // eslint-disable-next-line @typescript-eslint/no-explicit-any
245
- private isInsideTranslationCall(nodePath: any): boolean {
246
- let current = nodePath.parentPath;
247
- while (current) {
248
- const node = current.node;
249
-
250
- // Function-call style: t('key')
251
- if (t.isCallExpression(node)) {
252
- const callee = node.callee;
253
- if (t.isIdentifier(callee) && this.translationFunctionNames.has(callee.name)) {
254
- return true;
255
- }
256
- if (
257
- t.isMemberExpression(callee) &&
258
- t.isIdentifier(callee.property) &&
259
- this.translationFunctionNames.has(callee.property.name)
260
- ) {
261
- return true;
262
- }
263
- }
264
-
265
- // Bracket-notation accessor: t['key'] (MemberExpression, computed=true)
266
- if (t.isMemberExpression(node) && node.computed) {
267
- const obj = node.object;
268
- if (t.isIdentifier(obj) && this.translationFunctionNames.has(obj.name)) {
269
- return true;
270
- }
271
- }
272
-
273
- current = current.parentPath;
274
- }
275
- return false;
276
- }
277
-
278
- private addDetected(
279
- text: string,
280
- line: number,
281
- column: number,
282
- context: TextContext,
283
- nodeType: string
284
- ): void {
285
- const key = generateLocaleKey(
286
- this.options.filePath,
287
- text,
288
- this.options.sourceRoot || 'src'
289
- );
290
- this.detectedTexts.push({
291
- filePath: this.options.filePath,
292
- line,
293
- column,
294
- text,
295
- suggestedKey: key,
296
- context,
297
- nodeType,
298
- alreadyTranslated: false,
299
- });
300
- }
301
-
302
- private mapAttrToContext(attrName: string): TextContext {
303
- const lower = attrName.toLowerCase();
304
- if (lower === 'placeholder') return 'placeholder';
305
- if (lower === 'aria-label' || lower === 'aria-placeholder') return 'aria-label';
306
- if (lower === 'title') return 'title';
307
- if (lower === 'alt') return 'alt';
308
- return 'jsx-attribute';
309
- }
310
-
311
- private regexFallbackScan(): DetectedText[] {
312
- const results: DetectedText[] = [];
313
- const jsxTextRegex = />([^<>{}\n]+)</g;
314
- const lines = this.options.content.split('\n');
315
- lines.forEach((line, idx) => {
316
- let m: RegExpExecArray | null;
317
- jsxTextRegex.lastIndex = 0;
318
- while ((m = jsxTextRegex.exec(line)) !== null) {
319
- const text = normalizeText(m[1]);
320
- if (!isHumanReadableText(text)) continue;
321
- const key = generateLocaleKey(
322
- this.options.filePath,
323
- text,
324
- this.options.sourceRoot || 'src'
325
- );
326
- results.push({
327
- filePath: this.options.filePath,
328
- line: idx + 1,
329
- column: m.index,
330
- text,
331
- suggestedKey: key,
332
- context: 'jsx-text',
333
- nodeType: 'regex-fallback',
334
- alreadyTranslated: false,
335
- });
336
- }
337
- });
338
- return results;
339
- }
340
- }
341
-
342
- /**
343
- * Builds an import-source matcher function for the given `importPackage` value.
344
- *
345
- * Handles three cases:
346
- *
347
- * 1. Exact npm package name — "react-i18next", "my-i18n-lib"
348
- * Matches when the import source string equals the value exactly.
349
- *
350
- * 2. Path alias — "@/hooks/useTranslation", "@/i18n"
351
- * Matches when the import source equals the value OR ends with the
352
- * normalised path suffix (with or without the alias prefix).
353
- *
354
- * 3. Relative path — "../../hooks/useTranslation", "./i18n/hook"
355
- * Because relative paths resolve differently per file, we match by
356
- * comparing the **last N segments** of the import source with the
357
- * last N segments of the configured value (case-insensitive, stripping
358
- * leading dots and slashes). This is intentionally lenient so that
359
- * `hooks/useTranslation` matches both `../../hooks/useTranslation` and
360
- * `./hooks/useTranslation`.
361
- *
362
- * Examples:
363
- * importPackage="../../hooks/useTranslation"
364
- * matches "../../hooks/useTranslation" ✓
365
- * matches "./hooks/useTranslation" ✓ (same tail segments)
366
- * matches "../hooks/useTranslation" ✓
367
- * importPackage="@/i18n/hook"
368
- * matches "@/i18n/hook" ✓
369
- * matches "i18n/hook" ✓
370
- */
371
- function buildImportMatcher(importPackage: string): (source: string) => boolean {
372
- // Normalise: strip leading ./ ../ @ and collapse slashes
373
- const normalisedPkg = normalisePath(importPackage);
374
- const pkgSegments = normalisedPkg.split('/').filter(Boolean);
375
-
376
- return (source: string): boolean => {
377
- // 1. Exact match (covers plain npm package names and exact alias paths)
378
- if (source === importPackage) return true;
379
-
380
- // 2. Suffix / tail-segment match
381
- const normSource = normalisePath(source);
382
- const srcSegments = normSource.split('/').filter(Boolean);
383
-
384
- if (pkgSegments.length === 0) return false;
385
-
386
- // Check if the source ends with the same N tail segments as the package
387
- const n = pkgSegments.length;
388
- if (srcSegments.length < n) return false;
389
- const tail = srcSegments.slice(-n);
390
- return tail.every((seg, i) => seg.toLowerCase() === pkgSegments[i].toLowerCase());
391
- };
392
- }
393
-
394
- /** Strips leading dots, slashes and @ from a path string for comparison purposes. */
395
- function normalisePath(p: string): string {
396
- return p
397
- .replace(/\\/g, '/')
398
- .replace(/^(@\/|\.{1,2}\/)+/, '') // strip leading ./ ../ @/
399
- .replace(/^@/, ''); // strip bare @ prefix
400
- }
@@ -1,52 +0,0 @@
1
- import { execSync } from 'child_process';
2
- import * as path from 'path';
3
-
4
- export class GitScanner {
5
- private cwd: string;
6
-
7
- constructor(cwd = process.cwd()) {
8
- this.cwd = cwd;
9
- }
10
-
11
- getStagedFiles(extensions = ['ts', 'tsx', 'js', 'jsx', 'vue']): string[] {
12
- try {
13
- const out = execSync('git diff --cached --name-only --diff-filter=ACM', {
14
- cwd: this.cwd,
15
- encoding: 'utf-8',
16
- });
17
- return this.filter(out.trim().split('\n'), extensions);
18
- } catch {
19
- return [];
20
- }
21
- }
22
-
23
- getChangedFiles(base = 'main', extensions = ['ts', 'tsx', 'js', 'jsx', 'vue']): string[] {
24
- try {
25
- const out = execSync(`git diff --name-only --diff-filter=ACM ${base}...HEAD`, {
26
- cwd: this.cwd,
27
- encoding: 'utf-8',
28
- });
29
- return this.filter(out.trim().split('\n'), extensions);
30
- } catch {
31
- return [];
32
- }
33
- }
34
-
35
- getRecentlyChangedFiles(commits = 1, extensions = ['ts', 'tsx', 'js', 'jsx', 'vue']): string[] {
36
- try {
37
- const out = execSync(
38
- `git diff --name-only --diff-filter=ACM HEAD~${commits}...HEAD`,
39
- { cwd: this.cwd, encoding: 'utf-8' }
40
- );
41
- return this.filter(out.trim().split('\n'), extensions);
42
- } catch {
43
- return [];
44
- }
45
- }
46
-
47
- private filter(files: string[], extensions: string[]): string[] {
48
- return files
49
- .filter((f) => f && extensions.some((e) => f.endsWith(`.${e}`)))
50
- .map((f) => path.join(this.cwd, f));
51
- }
52
- }
@@ -1,58 +0,0 @@
1
- import * as fs from 'fs';
2
- import * as path from 'path';
3
- import * as crypto from 'crypto';
4
-
5
- import type { IncrementalCache, DetectedText } from 'ai-localize-shared';
6
- import { readJsonSafe, writeJson, ensureDir } from 'ai-localize-shared';
7
-
8
- export class IncrementalScanCache {
9
- private cachePath: string;
10
- private cache: IncrementalCache;
11
-
12
- constructor(cacheDir: string) {
13
- ensureDir(cacheDir);
14
- this.cachePath = path.join(cacheDir, 'scan-cache.json');
15
- this.cache = this.load();
16
- }
17
-
18
- private load(): IncrementalCache {
19
- const existing = readJsonSafe<IncrementalCache>(this.cachePath);
20
- if (existing?.version === '1') return existing;
21
- return { version: '1', lastRun: new Date().toISOString(), fileHashes: {}, processedFiles: {} };
22
- }
23
-
24
- isFileChanged(filePath: string): boolean {
25
- return this.hashFile(filePath) !== this.cache.fileHashes[filePath];
26
- }
27
-
28
- getCachedResult(filePath: string): DetectedText[] | null {
29
- const entry = this.cache.processedFiles[filePath];
30
- if (!entry) return null;
31
- if (entry.hash !== this.hashFile(filePath)) return null;
32
- return entry.detectedTexts;
33
- }
34
-
35
- setCachedResult(filePath: string, texts: DetectedText[]): void {
36
- const hash = this.hashFile(filePath);
37
- this.cache.fileHashes[filePath] = hash;
38
- this.cache.processedFiles[filePath] = { hash, detectedTexts: texts, lastModified: Date.now() };
39
- }
40
-
41
- persist(): void {
42
- this.cache.lastRun = new Date().toISOString();
43
- writeJson(this.cachePath, this.cache);
44
- }
45
-
46
- private hashFile(filePath: string): string {
47
- try {
48
- return crypto.createHash('sha256').update(fs.readFileSync(filePath)).digest('hex');
49
- } catch {
50
- return '';
51
- }
52
- }
53
-
54
- clear(): void {
55
- this.cache = { version: '1', lastRun: new Date().toISOString(), fileHashes: {}, processedFiles: {} };
56
- this.persist();
57
- }
58
- }
package/src/index.ts DELETED
@@ -1,5 +0,0 @@
1
- export * from './ast-scanner.js';
2
- export * from './asset-scanner.js';
3
- export * from './incremental-scanner.js';
4
- export * from './project-scanner.js';
5
- export * from './git-scanner.js';
@@ -1,140 +0,0 @@
1
- import * as path from 'path';
2
- import * as os from 'os';
3
-
4
- import type {
5
- DetectedText,
6
- AssetReference,
7
- LegacyCdnUrl,
8
- ScanResult,
9
- LocalizationConfig,
10
- } from 'ai-localize-shared';
11
- import { collectFiles, DEFAULT_IGNORE_DIRS, SOURCE_EXTENSIONS } from 'ai-localize-shared';
12
-
13
- import { AstScanner } from './ast-scanner.js';
14
- import { AssetScanner } from './asset-scanner.js';
15
- import { IncrementalScanCache } from './incremental-scanner.js';
16
-
17
- export interface ScanOptions {
18
- files?: string[];
19
- incremental?: boolean;
20
- }
21
-
22
- export class ProjectScanner {
23
- private config: LocalizationConfig;
24
- private sourceRoot: string;
25
- private cache?: IncrementalScanCache;
26
- private assetScanner: AssetScanner;
27
-
28
- constructor(config: LocalizationConfig) {
29
- this.config = config;
30
- // Resolve to absolute path so key-generator can use path.relative()
31
- // and never produce keys that include ancestor directory segments.
32
- this.sourceRoot = path.resolve(process.cwd(), config.sourceDir);
33
- this.assetScanner = new AssetScanner(config.aws?.legacyCdnPattern);
34
- if (config.incrementalCache) {
35
- this.cache = new IncrementalScanCache(
36
- path.join(process.cwd(), config.cacheDir || '.ai-localize-cache')
37
- );
38
- }
39
- }
40
-
41
- async scan(options: ScanOptions = {}): Promise<ScanResult> {
42
- const startTime = Date.now();
43
- let filesToScan: string[] = [];
44
-
45
- if (options.files?.length) {
46
- filesToScan = options.files;
47
- } else {
48
- const allFiles = collectFiles(this.sourceRoot, SOURCE_EXTENSIONS, [
49
- ...DEFAULT_IGNORE_DIRS,
50
- ...(this.config.ignorePatterns || []),
51
- ]);
52
-
53
- if (this.config.includePatterns && this.config.includePatterns.length > 0) {
54
- filesToScan = allFiles.filter(file => {
55
- return this.config.includePatterns!.some(pattern => {
56
- const regexPattern = pattern.replace(/\*/g, '.*').replace(/\?/g, '.');
57
- return new RegExp(regexPattern).test(file);
58
- });
59
- });
60
- } else {
61
- filesToScan = allFiles;
62
- }
63
- }
64
-
65
- const allTexts: DetectedText[] = [];
66
- const allAssets: AssetReference[] = [];
67
- const allLegacyUrls: LegacyCdnUrl[] = [];
68
-
69
- const chunkSize = Math.max(
70
- 1,
71
- Math.min(50, Math.ceil(filesToScan.length / (os.cpus().length || 4)))
72
- );
73
- const chunks = this.chunkArray(filesToScan, chunkSize);
74
-
75
- for (const chunk of chunks) {
76
- const results = await Promise.all(chunk.map((f) => this.scanFile(f)));
77
- for (const r of results) {
78
- allTexts.push(...r.texts);
79
- allAssets.push(...r.assets);
80
- allLegacyUrls.push(...r.legacyUrls);
81
- }
82
- }
83
-
84
- this.cache?.persist();
85
-
86
- return {
87
- framework: this.config.framework,
88
- scannedFiles: filesToScan.length,
89
- detectedTexts: allTexts,
90
- assets: allAssets,
91
- legacyCdnUrls: allLegacyUrls,
92
- duration: Date.now() - startTime,
93
- timestamp: new Date().toISOString(),
94
- };
95
- }
96
-
97
- private async scanFile(filePath: string): Promise<{
98
- texts: DetectedText[];
99
- assets: AssetReference[];
100
- legacyUrls: LegacyCdnUrl[];
101
- }> {
102
- if (this.cache && !this.cache.isFileChanged(filePath)) {
103
- const cached = this.cache.getCachedResult(filePath);
104
- if (cached) return { texts: cached, assets: [], legacyUrls: [] };
105
- }
106
-
107
- let content: string;
108
- try {
109
- const { readFileSync } = await import('fs');
110
- content = readFileSync(filePath, 'utf-8');
111
- } catch {
112
- return { texts: [], assets: [], legacyUrls: [] };
113
- }
114
-
115
- // Pass the absolute sourceRoot so normalizePath uses path.relative()
116
- // and strips only the project source directory — not ancestor segments.
117
- // Also pass codemods config so the scanner recognises user-defined
118
- // translation functions/hooks and skips already-translated strings.
119
- const scanner = new AstScanner({
120
- filePath,
121
- content,
122
- sourceRoot: this.sourceRoot,
123
- codemodConfig: this.config.codemods,
124
- });
125
- const texts = scanner.scan();
126
- const { assets, legacyCdnUrls } = this.assetScanner.scanFile(filePath);
127
-
128
- this.cache?.setCachedResult(filePath, texts);
129
-
130
- return { texts, assets, legacyUrls: legacyCdnUrls };
131
- }
132
-
133
- private chunkArray<T>(array: T[], size: number): T[][] {
134
- const chunks: T[][] = [];
135
- for (let i = 0; i < array.length; i += size) {
136
- chunks.push(array.slice(i, i + size));
137
- }
138
- return chunks;
139
- }
140
- }
package/tsconfig.json DELETED
@@ -1,9 +0,0 @@
1
- {
2
- "extends": "../../tsconfig.base.json",
3
- "compilerOptions": {
4
- "outDir": "./dist",
5
- "rootDir": "./src"
6
- },
7
- "include": ["src/**/*"],
8
- "exclude": ["node_modules", "dist"]
9
- }