ai-localize-scanner 2.0.3 → 2.0.5
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/package.json +25 -3
- package/src/__tests__/ast-scanner.test.ts +0 -65
- package/src/asset-scanner.ts +0 -118
- package/src/ast-scanner.ts +0 -413
- package/src/git-scanner.ts +0 -52
- package/src/incremental-scanner.ts +0 -58
- package/src/index.ts +0 -5
- package/src/project-scanner.ts +0 -142
- package/tsconfig.json +0 -9
package/package.json
CHANGED
|
@@ -1,10 +1,15 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "ai-localize-scanner",
|
|
3
|
-
"version": "2.0.
|
|
3
|
+
"version": "2.0.5",
|
|
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.
|
|
21
|
-
"ai-localize-config": "2.0.
|
|
42
|
+
"ai-localize-shared": "2.0.5",
|
|
43
|
+
"ai-localize-config": "2.0.5"
|
|
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
|
-
});
|
package/src/asset-scanner.ts
DELETED
|
@@ -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
|
-
}
|
package/src/ast-scanner.ts
DELETED
|
@@ -1,413 +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, KeyStyle } from 'ai-localize-shared';
|
|
6
|
-
import {
|
|
7
|
-
isHumanReadableText,
|
|
8
|
-
normalizeText,
|
|
9
|
-
TEXT_ATTRIBUTE_NAMES,
|
|
10
|
-
generateKeyByStyle,
|
|
11
|
-
} from 'ai-localize-shared';
|
|
12
|
-
|
|
13
|
-
export interface AstScanOptions {
|
|
14
|
-
filePath: string;
|
|
15
|
-
content: string;
|
|
16
|
-
sourceRoot?: string;
|
|
17
|
-
/**
|
|
18
|
-
* Controls the format of the generated locale key for each detected text.
|
|
19
|
-
*
|
|
20
|
-
* - `"path"` (default) — hierarchical dot-notation key derived from file path + text:
|
|
21
|
-
* `settings.settings_page.save_changes`
|
|
22
|
-
*
|
|
23
|
-
* - `"screaming_snake"` — UPPER_SNAKE_CASE key derived solely from the text value:
|
|
24
|
-
* "Save Changes" → `SAVE_CHANGES`
|
|
25
|
-
* "Max Count" → `MAX_COUNT`
|
|
26
|
-
*/
|
|
27
|
-
keyStyle?: KeyStyle;
|
|
28
|
-
/**
|
|
29
|
-
* Optional codemod config from ai-localize.config.json.
|
|
30
|
-
*
|
|
31
|
-
* The scanner uses this to recognise already-translated strings even when
|
|
32
|
-
* the project uses a custom i18n library or a locally-defined hook:
|
|
33
|
-
*
|
|
34
|
-
* importPackage — matched against import source strings. Supports:
|
|
35
|
-
* - npm package names: "react-i18next", "my-i18n-lib"
|
|
36
|
-
* - path aliases: "@/hooks/useTranslation", "@/i18n"
|
|
37
|
-
* - relative paths: "../../hooks/useTranslation"
|
|
38
|
-
* Matching is done by checking whether the import source equals the value
|
|
39
|
-
* OR ends with the last path segment(s) of the value (normalised).
|
|
40
|
-
*
|
|
41
|
-
* hookName — the hook identifier (e.g. "useTranslation", "useI18n").
|
|
42
|
-
* Added directly to the translation-function names set regardless of
|
|
43
|
-
* how the hook is imported. This means even default imports, re-exports
|
|
44
|
-
* or barrel aliases are handled correctly:
|
|
45
|
-
* import useT from '../../hooks/useT' (default import, hookName="useT")
|
|
46
|
-
*
|
|
47
|
-
* translationFunction — the accessor returned by the hook (e.g. "t").
|
|
48
|
-
* Added directly to the translation-function names set.
|
|
49
|
-
*/
|
|
50
|
-
codemodConfig?: CodemodConfig;
|
|
51
|
-
}
|
|
52
|
-
|
|
53
|
-
/** Well-known i18n library import sources (npm package names). */
|
|
54
|
-
const BUILTIN_TRANSLATION_IMPORT_SOURCES = new Set([
|
|
55
|
-
'react-i18next',
|
|
56
|
-
'i18next',
|
|
57
|
-
'vue-i18n',
|
|
58
|
-
'@ngx-translate/core',
|
|
59
|
-
]);
|
|
60
|
-
|
|
61
|
-
/**
|
|
62
|
-
* Scans a JS/TS/JSX/TSX file using Babel AST to find hardcoded text.
|
|
63
|
-
*/
|
|
64
|
-
export class AstScanner {
|
|
65
|
-
private options: AstScanOptions;
|
|
66
|
-
private detectedTexts: DetectedText[] = [];
|
|
67
|
-
|
|
68
|
-
/** Identifiers whose call/bracket expressions contain already-translated strings. */
|
|
69
|
-
private translationFunctionNames: Set<string>;
|
|
70
|
-
|
|
71
|
-
/**
|
|
72
|
-
* Import source matchers.
|
|
73
|
-
* Each entry is either an exact string (npm package) or a path-suffix matcher
|
|
74
|
-
* function built from a relative/alias path.
|
|
75
|
-
*/
|
|
76
|
-
private importSourceMatchers: Array<(source: string) => boolean>;
|
|
77
|
-
|
|
78
|
-
constructor(options: AstScanOptions) {
|
|
79
|
-
this.options = options;
|
|
80
|
-
|
|
81
|
-
// Seed default translation function names
|
|
82
|
-
this.translationFunctionNames = new Set<string>(['t', '$t', 'i18n', 'translate']);
|
|
83
|
-
|
|
84
|
-
// Build import-source matchers from builtin list
|
|
85
|
-
this.importSourceMatchers = Array.from(BUILTIN_TRANSLATION_IMPORT_SOURCES).map(
|
|
86
|
-
(pkg) => (src: string) => src === pkg
|
|
87
|
-
);
|
|
88
|
-
|
|
89
|
-
const cc = options.codemodConfig;
|
|
90
|
-
if (cc) {
|
|
91
|
-
// translationFunction — seed directly; no import lookup needed
|
|
92
|
-
if (cc.translationFunction) {
|
|
93
|
-
this.translationFunctionNames.add(cc.translationFunction);
|
|
94
|
-
}
|
|
95
|
-
|
|
96
|
-
// hookName — seed directly so the hook identifier is always recognised
|
|
97
|
-
// regardless of how it is imported (named, default, aliased, re-exported).
|
|
98
|
-
if (cc.hookName) {
|
|
99
|
-
this.translationFunctionNames.add(cc.hookName);
|
|
100
|
-
}
|
|
101
|
-
|
|
102
|
-
// importPackage — build a matcher that handles:
|
|
103
|
-
// 1. exact npm package name "react-i18next"
|
|
104
|
-
// 2. path alias"@/hooks/useTranslation"
|
|
105
|
-
// 3. relative path "../../hooks/useTranslation"
|
|
106
|
-
if (cc.importPackage) {
|
|
107
|
-
this.importSourceMatchers.push(buildImportMatcher(cc.importPackage));
|
|
108
|
-
}
|
|
109
|
-
}
|
|
110
|
-
}
|
|
111
|
-
|
|
112
|
-
scan(): DetectedText[] {
|
|
113
|
-
const { content } = this.options;
|
|
114
|
-
|
|
115
|
-
let ast: t.File;
|
|
116
|
-
try {
|
|
117
|
-
ast = parser.parse(content, {
|
|
118
|
-
sourceType: 'module',
|
|
119
|
-
plugins: [
|
|
120
|
-
'jsx',
|
|
121
|
-
'typescript',
|
|
122
|
-
'decorators-legacy',
|
|
123
|
-
'classProperties',
|
|
124
|
-
'optionalChaining',
|
|
125
|
-
'nullishCoalescingOperator',
|
|
126
|
-
'dynamicImport',
|
|
127
|
-
'exportDefaultFrom',
|
|
128
|
-
],
|
|
129
|
-
errorRecovery: true,
|
|
130
|
-
});
|
|
131
|
-
} catch {
|
|
132
|
-
return this.regexFallbackScan();
|
|
133
|
-
}
|
|
134
|
-
|
|
135
|
-
// Collect translation function names from imports before scanning text
|
|
136
|
-
this.collectTranslationImports(ast);
|
|
137
|
-
|
|
138
|
-
traverse(ast, {
|
|
139
|
-
JSXText: (nodePath) => {
|
|
140
|
-
const text = normalizeText(nodePath.node.value);
|
|
141
|
-
if (!isHumanReadableText(text)) return;
|
|
142
|
-
if (this.isInsideTranslationCall(nodePath)) return;
|
|
143
|
-
this.addDetected(
|
|
144
|
-
text,
|
|
145
|
-
nodePath.node.loc?.start.line ?? 0,
|
|
146
|
-
nodePath.node.loc?.start.column ?? 0,
|
|
147
|
-
'jsx-text',
|
|
148
|
-
'JSXText'
|
|
149
|
-
);
|
|
150
|
-
},
|
|
151
|
-
|
|
152
|
-
JSXAttribute: (nodePath) => {
|
|
153
|
-
const attrName = t.isJSXIdentifier(nodePath.node.name)
|
|
154
|
-
? nodePath.node.name.name
|
|
155
|
-
: '';
|
|
156
|
-
if (!TEXT_ATTRIBUTE_NAMES.has(attrName.toLowerCase())) return;
|
|
157
|
-
const valueNode = nodePath.node.value;
|
|
158
|
-
if (!t.isStringLiteral(valueNode)) return;
|
|
159
|
-
const text = normalizeText(valueNode.value);
|
|
160
|
-
if (!isHumanReadableText(text)) return;
|
|
161
|
-
if (this.isInsideTranslationCall(nodePath)) return;
|
|
162
|
-
const context = this.mapAttrToContext(attrName);
|
|
163
|
-
this.addDetected(
|
|
164
|
-
text,
|
|
165
|
-
valueNode.loc?.start.line ?? 0,
|
|
166
|
-
valueNode.loc?.start.column ?? 0,
|
|
167
|
-
context,
|
|
168
|
-
'JSXAttribute'
|
|
169
|
-
);
|
|
170
|
-
},
|
|
171
|
-
|
|
172
|
-
StringLiteral: (nodePath) => {
|
|
173
|
-
if (t.isImportDeclaration(nodePath.parent)) return;
|
|
174
|
-
if (t.isObjectProperty(nodePath.parent) && nodePath.parent.key === nodePath.node) return;
|
|
175
|
-
|
|
176
|
-
// Ignore className and similar CSS-class attributes
|
|
177
|
-
if (t.isJSXAttribute(nodePath.parent)) {
|
|
178
|
-
const attrName = t.isJSXIdentifier(nodePath.parent.name)
|
|
179
|
-
? nodePath.parent.name.name.toLowerCase()
|
|
180
|
-
: '';
|
|
181
|
-
if (attrName === 'classname' || attrName === 'class') return;
|
|
182
|
-
return;
|
|
183
|
-
}
|
|
184
|
-
|
|
185
|
-
if (this.isInsideTranslationCall(nodePath)) return;
|
|
186
|
-
|
|
187
|
-
const val = nodePath.node.value;
|
|
188
|
-
if (
|
|
189
|
-
/^[a-z][a-z0-9_.-]*$/.test(val) ||
|
|
190
|
-
/^#?[0-9a-fA-F]+$/.test(val) ||
|
|
191
|
-
(/^[\w-]+\s[\w- ]+$/.test(val) && !val.includes(','))
|
|
192
|
-
) {
|
|
193
|
-
return; // Likely a CSS class, ID, hex colour, or plain word pair
|
|
194
|
-
}
|
|
195
|
-
|
|
196
|
-
const text = normalizeText(nodePath.node.value);
|
|
197
|
-
if (!isHumanReadableText(text)) return;
|
|
198
|
-
this.addDetected(
|
|
199
|
-
text,
|
|
200
|
-
nodePath.node.loc?.start.line ?? 0,
|
|
201
|
-
nodePath.node.loc?.start.column ?? 0,
|
|
202
|
-
'string-literal',
|
|
203
|
-
'StringLiteral'
|
|
204
|
-
);
|
|
205
|
-
},
|
|
206
|
-
|
|
207
|
-
TemplateLiteral: (nodePath) => {
|
|
208
|
-
if (nodePath.node.expressions.length > 0) return;
|
|
209
|
-
if (this.isInsideTranslationCall(nodePath)) return;
|
|
210
|
-
const text = normalizeText(nodePath.node.quasis[0]?.value.cooked ?? '');
|
|
211
|
-
if (!isHumanReadableText(text)) return;
|
|
212
|
-
this.addDetected(
|
|
213
|
-
text,
|
|
214
|
-
nodePath.node.loc?.start.line ?? 0,
|
|
215
|
-
nodePath.node.loc?.start.column ?? 0,
|
|
216
|
-
'template-literal',
|
|
217
|
-
'TemplateLiteral'
|
|
218
|
-
);
|
|
219
|
-
},
|
|
220
|
-
});
|
|
221
|
-
|
|
222
|
-
return this.detectedTexts;
|
|
223
|
-
}
|
|
224
|
-
|
|
225
|
-
/**
|
|
226
|
-
* Walk import declarations; when the source matches a known translation
|
|
227
|
-
* import, collect all named/default imports as translation function names.
|
|
228
|
-
*/
|
|
229
|
-
private collectTranslationImports(ast: t.File): void {
|
|
230
|
-
for (const node of ast.program.body) {
|
|
231
|
-
if (!t.isImportDeclaration(node)) continue;
|
|
232
|
-
const source = node.source.value;
|
|
233
|
-
if (!this.isTranslationImportSource(source)) continue;
|
|
234
|
-
for (const specifier of node.specifiers) {
|
|
235
|
-
if (t.isImportSpecifier(specifier) && t.isIdentifier(specifier.local)) {
|
|
236
|
-
this.translationFunctionNames.add(specifier.local.name);
|
|
237
|
-
}
|
|
238
|
-
// Default import: import useT from '../../hooks/useT'
|
|
239
|
-
if (t.isImportDefaultSpecifier(specifier) && t.isIdentifier(specifier.local)) {
|
|
240
|
-
this.translationFunctionNames.add(specifier.local.name);
|
|
241
|
-
}
|
|
242
|
-
// Namespace import: import * as i18n from '...'
|
|
243
|
-
if (t.isImportNamespaceSpecifier(specifier) && t.isIdentifier(specifier.local)) {
|
|
244
|
-
this.translationFunctionNames.add(specifier.local.name);
|
|
245
|
-
}
|
|
246
|
-
}
|
|
247
|
-
}
|
|
248
|
-
}
|
|
249
|
-
|
|
250
|
-
/** Returns true if the import source string should be treated as a translation library. */
|
|
251
|
-
private isTranslationImportSource(source: string): boolean {
|
|
252
|
-
return this.importSourceMatchers.some((match) => match(source));
|
|
253
|
-
}
|
|
254
|
-
|
|
255
|
-
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
256
|
-
private isInsideTranslationCall(nodePath: any): boolean {
|
|
257
|
-
let current = nodePath.parentPath;
|
|
258
|
-
while (current) {
|
|
259
|
-
const node = current.node;
|
|
260
|
-
|
|
261
|
-
// Function-call style: t('key')
|
|
262
|
-
if (t.isCallExpression(node)) {
|
|
263
|
-
const callee = node.callee;
|
|
264
|
-
if (t.isIdentifier(callee) && this.translationFunctionNames.has(callee.name)) {
|
|
265
|
-
return true;
|
|
266
|
-
}
|
|
267
|
-
if (
|
|
268
|
-
t.isMemberExpression(callee) &&
|
|
269
|
-
t.isIdentifier(callee.property) &&
|
|
270
|
-
this.translationFunctionNames.has(callee.property.name)
|
|
271
|
-
) {
|
|
272
|
-
return true;
|
|
273
|
-
}
|
|
274
|
-
}
|
|
275
|
-
|
|
276
|
-
// Bracket-notation accessor: t['key'] (MemberExpression, computed=true)
|
|
277
|
-
if (t.isMemberExpression(node) && node.computed) {
|
|
278
|
-
const obj = node.object;
|
|
279
|
-
if (t.isIdentifier(obj) && this.translationFunctionNames.has(obj.name)) {
|
|
280
|
-
return true;
|
|
281
|
-
}
|
|
282
|
-
}
|
|
283
|
-
|
|
284
|
-
current = current.parentPath;
|
|
285
|
-
}
|
|
286
|
-
return false;
|
|
287
|
-
}
|
|
288
|
-
|
|
289
|
-
private addDetected(
|
|
290
|
-
text: string,
|
|
291
|
-
line: number,
|
|
292
|
-
column: number,
|
|
293
|
-
context: TextContext,
|
|
294
|
-
nodeType: string
|
|
295
|
-
): void {
|
|
296
|
-
const key = generateKeyByStyle(
|
|
297
|
-
this.options.filePath,
|
|
298
|
-
text,
|
|
299
|
-
this.options.sourceRoot || 'src',
|
|
300
|
-
this.options.keyStyle || 'path'
|
|
301
|
-
);
|
|
302
|
-
this.detectedTexts.push({
|
|
303
|
-
filePath: this.options.filePath,
|
|
304
|
-
line,
|
|
305
|
-
column,
|
|
306
|
-
text,
|
|
307
|
-
suggestedKey: key,
|
|
308
|
-
context,
|
|
309
|
-
nodeType,
|
|
310
|
-
alreadyTranslated: false,
|
|
311
|
-
});
|
|
312
|
-
}
|
|
313
|
-
|
|
314
|
-
private mapAttrToContext(attrName: string): TextContext {
|
|
315
|
-
const lower = attrName.toLowerCase();
|
|
316
|
-
if (lower === 'placeholder') return 'placeholder';
|
|
317
|
-
if (lower === 'aria-label' || lower === 'aria-placeholder') return 'aria-label';
|
|
318
|
-
if (lower === 'title') return 'title';
|
|
319
|
-
if (lower === 'alt') return 'alt';
|
|
320
|
-
return 'jsx-attribute';
|
|
321
|
-
}
|
|
322
|
-
|
|
323
|
-
private regexFallbackScan(): DetectedText[] {
|
|
324
|
-
const results: DetectedText[] = [];
|
|
325
|
-
const jsxTextRegex = />([^<>{}\n]+)</g;
|
|
326
|
-
const lines = this.options.content.split('\n');
|
|
327
|
-
lines.forEach((line, idx) => {
|
|
328
|
-
let m: RegExpExecArray | null;
|
|
329
|
-
jsxTextRegex.lastIndex = 0;
|
|
330
|
-
while ((m = jsxTextRegex.exec(line)) !== null) {
|
|
331
|
-
const text = normalizeText(m[1]);
|
|
332
|
-
if (!isHumanReadableText(text)) continue;
|
|
333
|
-
const key = generateKeyByStyle(
|
|
334
|
-
this.options.filePath,
|
|
335
|
-
text,
|
|
336
|
-
this.options.sourceRoot || 'src',
|
|
337
|
-
this.options.keyStyle || 'path'
|
|
338
|
-
);
|
|
339
|
-
results.push({
|
|
340
|
-
filePath: this.options.filePath,
|
|
341
|
-
line: idx + 1,
|
|
342
|
-
column: m.index,
|
|
343
|
-
text,
|
|
344
|
-
suggestedKey: key,
|
|
345
|
-
context: 'jsx-text',
|
|
346
|
-
nodeType: 'regex-fallback',
|
|
347
|
-
alreadyTranslated: false,
|
|
348
|
-
});
|
|
349
|
-
}
|
|
350
|
-
});
|
|
351
|
-
return results;
|
|
352
|
-
}
|
|
353
|
-
}
|
|
354
|
-
|
|
355
|
-
/**
|
|
356
|
-
* Builds an import-source matcher function for the given `importPackage` value.
|
|
357
|
-
*
|
|
358
|
-
* Handles three cases:
|
|
359
|
-
*
|
|
360
|
-
* 1. Exact npm package name — "react-i18next", "my-i18n-lib"
|
|
361
|
-
* Matches when the import source string equals the value exactly.
|
|
362
|
-
*
|
|
363
|
-
* 2. Path alias — "@/hooks/useTranslation", "@/i18n"
|
|
364
|
-
* Matches when the import source equals the value OR ends with the
|
|
365
|
-
* normalised path suffix (with or without the alias prefix).
|
|
366
|
-
*
|
|
367
|
-
* 3. Relative path — "../../hooks/useTranslation", "./i18n/hook"
|
|
368
|
-
* Because relative paths resolve differently per file, we match by
|
|
369
|
-
* comparing the **last N segments** of the import source with the
|
|
370
|
-
* last N segments of the configured value (case-insensitive, stripping
|
|
371
|
-
* leading dots and slashes). This is intentionally lenient so that
|
|
372
|
-
* `hooks/useTranslation` matches both `../../hooks/useTranslation` and
|
|
373
|
-
* `./hooks/useTranslation`.
|
|
374
|
-
*
|
|
375
|
-
* Examples:
|
|
376
|
-
* importPackage="../../hooks/useTranslation"
|
|
377
|
-
* matches "../../hooks/useTranslation" ✓
|
|
378
|
-
* matches "./hooks/useTranslation" ✓ (same tail segments)
|
|
379
|
-
* matches "../hooks/useTranslation" ✓
|
|
380
|
-
* importPackage="@/i18n/hook"
|
|
381
|
-
* matches "@/i18n/hook"✓
|
|
382
|
-
* matches "i18n/hook" ✓
|
|
383
|
-
*/
|
|
384
|
-
function buildImportMatcher(importPackage: string): (source: string) => boolean {
|
|
385
|
-
// Normalise: strip leading ./ ../ @ and collapse slashes
|
|
386
|
-
const normalisedPkg = normalisePath(importPackage);
|
|
387
|
-
const pkgSegments = normalisedPkg.split('/').filter(Boolean);
|
|
388
|
-
|
|
389
|
-
return (source: string): boolean => {
|
|
390
|
-
// 1. Exact match (covers plain npm package names and exact alias paths)
|
|
391
|
-
if (source === importPackage) return true;
|
|
392
|
-
|
|
393
|
-
// 2. Suffix / tail-segment match
|
|
394
|
-
const normSource = normalisePath(source);
|
|
395
|
-
const srcSegments = normSource.split('/').filter(Boolean);
|
|
396
|
-
|
|
397
|
-
if (pkgSegments.length === 0) return false;
|
|
398
|
-
|
|
399
|
-
// Check if the source ends with the same N tail segments as the package
|
|
400
|
-
const n = pkgSegments.length;
|
|
401
|
-
if (srcSegments.length < n) return false;
|
|
402
|
-
const tail = srcSegments.slice(-n);
|
|
403
|
-
return tail.every((seg, i) => seg.toLowerCase() === pkgSegments[i].toLowerCase());
|
|
404
|
-
};
|
|
405
|
-
}
|
|
406
|
-
|
|
407
|
-
/** Strips leading dots, slashes and @ from a path string for comparison purposes. */
|
|
408
|
-
function normalisePath(p: string): string {
|
|
409
|
-
return p
|
|
410
|
-
.replace(/\\/g, '/')
|
|
411
|
-
.replace(/^(@\/|\.{1,2}\/)+/, '') // strip leading ./ ../ @/
|
|
412
|
-
.replace(/^@/, ''); // strip bare @ prefix
|
|
413
|
-
}
|
package/src/git-scanner.ts
DELETED
|
@@ -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
package/src/project-scanner.ts
DELETED
|
@@ -1,142 +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
|
-
// keyStyle controls whether keys are path-based (default) or SCREAMING_SNAKE.
|
|
120
|
-
const scanner = new AstScanner({
|
|
121
|
-
filePath,
|
|
122
|
-
content,
|
|
123
|
-
sourceRoot: this.sourceRoot,
|
|
124
|
-
codemodConfig: this.config.codemods,
|
|
125
|
-
keyStyle: this.config.keyStyle ?? 'path',
|
|
126
|
-
});
|
|
127
|
-
const texts = scanner.scan();
|
|
128
|
-
const { assets, legacyCdnUrls } = this.assetScanner.scanFile(filePath);
|
|
129
|
-
|
|
130
|
-
this.cache?.setCachedResult(filePath, texts);
|
|
131
|
-
|
|
132
|
-
return { texts, assets, legacyUrls: legacyCdnUrls };
|
|
133
|
-
}
|
|
134
|
-
|
|
135
|
-
private chunkArray<T>(array: T[], size: number): T[][] {
|
|
136
|
-
const chunks: T[][] = [];
|
|
137
|
-
for (let i = 0; i < array.length; i += size) {
|
|
138
|
-
chunks.push(array.slice(i, i + size));
|
|
139
|
-
}
|
|
140
|
-
return chunks;
|
|
141
|
-
}
|
|
142
|
-
}
|