reverse-engine 0.3.1 → 0.5.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Potentially problematic release.
This version of reverse-engine might be problematic. Click here for more details.
- package/dist/cli/index.js +128 -21
- package/dist/crawler/index.d.ts +20 -0
- package/dist/crawler/index.js +201 -0
- package/dist/index.d.ts +1 -0
- package/dist/index.js +1 -0
- package/package.json +1 -1
package/dist/cli/index.js
CHANGED
|
@@ -7,6 +7,7 @@ import { execFileSync } from 'child_process';
|
|
|
7
7
|
import { dirname, join, resolve } from 'path';
|
|
8
8
|
import { createRequire } from 'module';
|
|
9
9
|
import { analyze } from '../analyzer/index.js';
|
|
10
|
+
import { crawl } from '../crawler/index.js';
|
|
10
11
|
import { generateReport } from '../docgen/index.js';
|
|
11
12
|
import { generateTests } from '../testgen/index.js';
|
|
12
13
|
// ─── 기본값 ───
|
|
@@ -54,6 +55,27 @@ function runNative(args) {
|
|
|
54
55
|
return;
|
|
55
56
|
execFileSync(nativeBin, args, { stdio: 'inherit' });
|
|
56
57
|
}
|
|
58
|
+
/** CLI 옵션에서 인증 설정 구성 */
|
|
59
|
+
function buildAuth(opts, baseUrl) {
|
|
60
|
+
const auth = {};
|
|
61
|
+
if (opts.authCookie)
|
|
62
|
+
auth.cookie = opts.authCookie;
|
|
63
|
+
if (opts.authBearer)
|
|
64
|
+
auth.bearer = opts.authBearer;
|
|
65
|
+
if (opts.loginUrl) {
|
|
66
|
+
// --login-url이 상대경로면 baseUrl 기준으로 resolve
|
|
67
|
+
const loginUrl = opts.loginUrl.startsWith('http')
|
|
68
|
+
? opts.loginUrl
|
|
69
|
+
: new URL(opts.loginUrl, baseUrl).href;
|
|
70
|
+
auth.loginUrl = loginUrl;
|
|
71
|
+
auth.credentials = {};
|
|
72
|
+
if (opts.loginId)
|
|
73
|
+
auth.credentials.email = opts.loginId;
|
|
74
|
+
if (opts.loginPw)
|
|
75
|
+
auth.credentials.password = opts.loginPw;
|
|
76
|
+
}
|
|
77
|
+
return Object.keys(auth).length > 0 ? auth : undefined;
|
|
78
|
+
}
|
|
57
79
|
const nativeBin = findNativeBinary();
|
|
58
80
|
const program = new Command();
|
|
59
81
|
if (nativeBin) {
|
|
@@ -135,40 +157,125 @@ program
|
|
|
135
157
|
console.log(chalk.green('✓'), `완료! (${files.length}개 파일)`);
|
|
136
158
|
files.forEach(p => console.log(` → ${chalk.cyan(p)}`));
|
|
137
159
|
});
|
|
160
|
+
// ─── crawl ───
|
|
161
|
+
program
|
|
162
|
+
.command('crawl')
|
|
163
|
+
.argument('[url]', '크롤링 대상 URL')
|
|
164
|
+
.option('--max-depth <n>', '최대 탐색 깊이', '5')
|
|
165
|
+
.option('--max-pages <n>', '최대 페이지 수', '100')
|
|
166
|
+
.option('--no-screenshot', '스크린샷 비활성화')
|
|
167
|
+
.option('--no-headless', '브라우저 표시 (디버깅용)')
|
|
168
|
+
.option('--auth-cookie <cookie>', '인증 쿠키 (name=value;name2=value2)')
|
|
169
|
+
.option('--auth-bearer <token>', 'Bearer 토큰')
|
|
170
|
+
.option('--login-url <url>', '로그인 페이지 URL')
|
|
171
|
+
.option('--login-id <id>', '로그인 ID 필드값')
|
|
172
|
+
.option('--login-pw <pw>', '로그인 PW 필드값')
|
|
173
|
+
.option('--wait <ms>', '페이지 로드 후 대기시간(ms)', '1500')
|
|
174
|
+
.option('-o, --output <dir>', '출력 디렉토리')
|
|
175
|
+
.description('실행 중인 서비스를 브라우저로 크롤링하여 화면/API 수집')
|
|
176
|
+
.action(async (url, opts) => {
|
|
177
|
+
if (!url) {
|
|
178
|
+
console.log(chalk.red('✗'), 'URL을 입력하세요: reverse-engine crawl http://localhost:3000');
|
|
179
|
+
process.exit(1);
|
|
180
|
+
}
|
|
181
|
+
const outputDir = opts.output || DEFAULT_OUTPUT;
|
|
182
|
+
console.log(chalk.green('▶'), '크롤링 시작:', chalk.cyan(url));
|
|
183
|
+
console.log(` 최대 깊이: ${opts.maxDepth} | 최대 페이지: ${opts.maxPages}`);
|
|
184
|
+
const result = await crawl({
|
|
185
|
+
url,
|
|
186
|
+
maxDepth: parseInt(opts.maxDepth),
|
|
187
|
+
maxPages: parseInt(opts.maxPages),
|
|
188
|
+
screenshot: opts.screenshot !== false,
|
|
189
|
+
headless: opts.headless !== false,
|
|
190
|
+
outputDir,
|
|
191
|
+
waitTime: parseInt(opts.wait),
|
|
192
|
+
auth: buildAuth(opts, url),
|
|
193
|
+
});
|
|
194
|
+
console.log(chalk.green('✓'), `크롤링 완료!`);
|
|
195
|
+
console.log(` 페이지: ${result.pages.length}개`);
|
|
196
|
+
console.log(` API 호출: ${result.pages.reduce((n, p) => n + p.apiCalls.length, 0)}개`);
|
|
197
|
+
await mkdir(outputDir, { recursive: true });
|
|
198
|
+
const crawlPath = join(outputDir, 'crawl-result.json');
|
|
199
|
+
await writeFile(crawlPath, JSON.stringify(result, null, 2));
|
|
200
|
+
console.log(` → ${chalk.cyan(crawlPath)}`);
|
|
201
|
+
});
|
|
138
202
|
// ─── full ───
|
|
139
203
|
program
|
|
140
204
|
.command('full')
|
|
141
|
-
.argument('[
|
|
205
|
+
.argument('[target]', 'URL 또는 소스코드 경로 (생략하면 현재 디렉토리)')
|
|
206
|
+
.option('--source <path>', '소스코드 경로 (URL과 함께 사용 시)')
|
|
142
207
|
.option('--framework <name>', '프레임워크', 'auto')
|
|
143
|
-
.option('-
|
|
144
|
-
.
|
|
145
|
-
.
|
|
146
|
-
|
|
147
|
-
|
|
208
|
+
.option('--no-headless', '브라우저 표시 (디버깅용)')
|
|
209
|
+
.option('--login-url <url>', '로그인 페이지 URL')
|
|
210
|
+
.option('--login-id <id>', '로그인 ID')
|
|
211
|
+
.option('--login-pw <pw>', '로그인 PW')
|
|
212
|
+
.option('--auth-cookie <cookie>', '인증 쿠키')
|
|
213
|
+
.option('--auth-bearer <token>', 'Bearer 토큰')
|
|
214
|
+
.option('--max-depth <n>', '크롤링 최대 깊이', '5')
|
|
215
|
+
.option('--max-pages <n>', '크롤링 최대 페이지', '100')
|
|
216
|
+
.option('--wait <ms>', '페이지 대기시간(ms)', '1500')
|
|
217
|
+
.option('-o, --output <dir>', '출력 디렉토리')
|
|
218
|
+
.description('전체 파이프라인 — URL이면 크롤링, 경로면 코드 분석, 둘 다 가능')
|
|
219
|
+
.action(async (target, opts) => {
|
|
220
|
+
// target이 URL인지 경로인지 판별
|
|
221
|
+
const isUrl = target?.startsWith('http://') || target?.startsWith('https://');
|
|
222
|
+
const crawlUrl = isUrl ? target : undefined;
|
|
223
|
+
const sourcePath = isUrl
|
|
224
|
+
? (opts.source ? resolve(opts.source) : null)
|
|
225
|
+
: (target ? resolve(target) : detectProjectRoot());
|
|
226
|
+
const outputDir = resolveOutput(opts.output, sourcePath || undefined);
|
|
148
227
|
console.log(chalk.green('\n◆'), 'ReversEngine', nativeBin ? chalk.dim('⚡') : '', '\n');
|
|
149
|
-
|
|
228
|
+
if (crawlUrl)
|
|
229
|
+
console.log(` URL: ${chalk.cyan(crawlUrl)}`);
|
|
230
|
+
if (sourcePath)
|
|
231
|
+
console.log(` 소스: ${chalk.cyan(sourcePath)}`);
|
|
150
232
|
console.log(` 출력: ${chalk.cyan(outputDir)}\n`);
|
|
151
233
|
await mkdir(outputDir, { recursive: true });
|
|
152
234
|
const analysisPath = join(outputDir, 'analysis.json');
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
if (
|
|
156
|
-
|
|
235
|
+
const crawlResultPath = join(outputDir, 'crawl-result.json');
|
|
236
|
+
// ── 크롤링 ──
|
|
237
|
+
if (crawlUrl) {
|
|
238
|
+
console.log(chalk.gray('━'.repeat(50)));
|
|
239
|
+
console.log(chalk.green('▶'), '크롤링:', chalk.cyan(crawlUrl));
|
|
240
|
+
const auth = buildAuth(opts, crawlUrl);
|
|
241
|
+
const crawlResult = await crawl({
|
|
242
|
+
url: crawlUrl,
|
|
243
|
+
maxDepth: parseInt(opts.maxDepth),
|
|
244
|
+
maxPages: parseInt(opts.maxPages),
|
|
245
|
+
outputDir,
|
|
246
|
+
headless: opts.headless !== false,
|
|
247
|
+
waitTime: parseInt(opts.wait),
|
|
248
|
+
auth,
|
|
249
|
+
});
|
|
250
|
+
const totalApi = crawlResult.pages.reduce((n, p) => n + p.apiCalls.length, 0);
|
|
251
|
+
console.log(chalk.green('✓'), `크롤링: 페이지 ${crawlResult.pages.length} | API ${totalApi} | 스크린샷 ${crawlResult.pages.filter(p => p.screenshotPath).length}`);
|
|
252
|
+
await writeFile(crawlResultPath, JSON.stringify(crawlResult, null, 2));
|
|
157
253
|
}
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
console.log(chalk.
|
|
161
|
-
|
|
254
|
+
// ── 코드 분석 ──
|
|
255
|
+
if (sourcePath) {
|
|
256
|
+
console.log(chalk.gray('━'.repeat(50)));
|
|
257
|
+
if (nativeBin) {
|
|
258
|
+
runNative(['analyze', sourcePath, '--framework', opts.framework || 'auto']);
|
|
259
|
+
}
|
|
260
|
+
else {
|
|
261
|
+
const result = await analyze(sourcePath, { framework: opts.framework });
|
|
262
|
+
console.log(chalk.green('✓'), `분석: 컴포넌트 ${result.components.length} | 함수 ${result.functions.length} | API ${result.apiClients.length} | 라우트 ${result.routes.length}`);
|
|
263
|
+
await writeFile(analysisPath, JSON.stringify(result, null, 2));
|
|
264
|
+
}
|
|
162
265
|
}
|
|
163
|
-
//
|
|
164
|
-
|
|
165
|
-
|
|
266
|
+
// ── 리포트 + 테스트 ──
|
|
267
|
+
const hasAnalysis = existsSync(analysisPath);
|
|
268
|
+
const hasCrawl = existsSync(crawlResultPath);
|
|
269
|
+
if (hasAnalysis || hasCrawl) {
|
|
270
|
+
// 리포트 데이터 결정 (분석 결과 우선, 없으면 크롤링 결과)
|
|
271
|
+
const reportData = hasAnalysis
|
|
272
|
+
? JSON.parse(await readFile(analysisPath, 'utf-8'))
|
|
273
|
+
: JSON.parse(await readFile(crawlResultPath, 'utf-8'));
|
|
166
274
|
console.log(chalk.gray('━'.repeat(50)));
|
|
167
|
-
const reports = await generateReport(
|
|
275
|
+
const reports = await generateReport(reportData, { outputDir: join(outputDir, 'reports') });
|
|
168
276
|
console.log(chalk.green('✓'), '리포트:', reports.map(p => chalk.cyan(p.split('/').pop())).join(', '));
|
|
169
|
-
// Step 3: 테스트
|
|
170
277
|
console.log(chalk.gray('━'.repeat(50)));
|
|
171
|
-
const tests = await generateTests(
|
|
278
|
+
const tests = await generateTests(reportData, { outputDir: join(outputDir, 'tests') });
|
|
172
279
|
console.log(chalk.green('✓'), `테스트: ${tests.length}개 파일`);
|
|
173
280
|
}
|
|
174
281
|
console.log(chalk.gray('━'.repeat(50)));
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
import type { CrawlResult } from '../types.js';
|
|
2
|
+
export interface CrawlOptions {
|
|
3
|
+
url: string;
|
|
4
|
+
maxDepth?: number;
|
|
5
|
+
maxPages?: number;
|
|
6
|
+
screenshot?: boolean;
|
|
7
|
+
outputDir?: string;
|
|
8
|
+
auth?: AuthOptions;
|
|
9
|
+
headless?: boolean;
|
|
10
|
+
waitTime?: number;
|
|
11
|
+
ignorePatterns?: string[];
|
|
12
|
+
}
|
|
13
|
+
export interface AuthOptions {
|
|
14
|
+
cookie?: string;
|
|
15
|
+
bearer?: string;
|
|
16
|
+
loginUrl?: string;
|
|
17
|
+
credentials?: Record<string, string>;
|
|
18
|
+
submitSelector?: string;
|
|
19
|
+
}
|
|
20
|
+
export declare function crawl(options: CrawlOptions): Promise<CrawlResult>;
|
|
@@ -0,0 +1,201 @@
|
|
|
1
|
+
import { chromium } from 'playwright';
|
|
2
|
+
import { mkdir } from 'fs/promises';
|
|
3
|
+
import { join } from 'path';
|
|
4
|
+
export async function crawl(options) {
|
|
5
|
+
const { url, maxDepth = 5, maxPages = 100, screenshot = true, outputDir = '.reverse-engine', headless = true, waitTime = 1500, ignorePatterns = ['/logout', '/signout', '/auth/logout'], } = options;
|
|
6
|
+
const screenshotDir = join(outputDir, 'screenshots');
|
|
7
|
+
if (screenshot)
|
|
8
|
+
await mkdir(screenshotDir, { recursive: true });
|
|
9
|
+
const browser = await chromium.launch({ headless });
|
|
10
|
+
const context = await browser.newContext({
|
|
11
|
+
viewport: { width: 1920, height: 1080 },
|
|
12
|
+
locale: 'ko-KR',
|
|
13
|
+
});
|
|
14
|
+
// 인증 설정
|
|
15
|
+
if (options.auth) {
|
|
16
|
+
await setupAuth(context, url, options.auth);
|
|
17
|
+
}
|
|
18
|
+
const result = {
|
|
19
|
+
targetUrl: url,
|
|
20
|
+
pages: [],
|
|
21
|
+
timestamp: new Date().toISOString(),
|
|
22
|
+
};
|
|
23
|
+
const visited = new Set();
|
|
24
|
+
const queue = [{ url, depth: 0 }];
|
|
25
|
+
const baseHost = new URL(url).hostname;
|
|
26
|
+
while (queue.length > 0 && result.pages.length < maxPages) {
|
|
27
|
+
const current = queue.shift();
|
|
28
|
+
const normalizedUrl = normalizeUrl(current.url);
|
|
29
|
+
if (visited.has(normalizedUrl) || current.depth > maxDepth)
|
|
30
|
+
continue;
|
|
31
|
+
if (ignorePatterns.some(p => normalizedUrl.includes(p)))
|
|
32
|
+
continue;
|
|
33
|
+
visited.add(normalizedUrl);
|
|
34
|
+
const page = await context.newPage();
|
|
35
|
+
try {
|
|
36
|
+
const pageInfo = await scanPage(page, current.url, waitTime);
|
|
37
|
+
// 스크린샷
|
|
38
|
+
if (screenshot) {
|
|
39
|
+
const safeName = normalizedUrl.replace(/[^a-zA-Z0-9가-힣]/g, '_').slice(0, 80);
|
|
40
|
+
const ssPath = join(screenshotDir, `${safeName}.png`);
|
|
41
|
+
await page.screenshot({ path: ssPath, fullPage: true });
|
|
42
|
+
pageInfo.screenshotPath = ssPath;
|
|
43
|
+
}
|
|
44
|
+
// API 호출 수집 (이미 scanPage에서 네트워크 인터셉트)
|
|
45
|
+
result.pages.push(pageInfo);
|
|
46
|
+
// 새 URL 큐에 추가
|
|
47
|
+
for (const link of pageInfo.elements.links) {
|
|
48
|
+
try {
|
|
49
|
+
const linkUrl = new URL(link.href, current.url);
|
|
50
|
+
if (linkUrl.hostname === baseHost && !visited.has(normalizeUrl(linkUrl.href))) {
|
|
51
|
+
queue.push({ url: linkUrl.href, depth: current.depth + 1 });
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
catch { /* invalid URL */ }
|
|
55
|
+
}
|
|
56
|
+
// 버튼 클릭으로 발견되는 URL도 추가
|
|
57
|
+
for (const nav of pageInfo.navigatesTo) {
|
|
58
|
+
try {
|
|
59
|
+
const navUrl = new URL(nav, current.url);
|
|
60
|
+
if (navUrl.hostname === baseHost && !visited.has(normalizeUrl(navUrl.href))) {
|
|
61
|
+
queue.push({ url: navUrl.href, depth: current.depth + 1 });
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
catch { /* invalid URL */ }
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
catch (err) {
|
|
68
|
+
// 페이지 로드 실패 → 건너뜀
|
|
69
|
+
}
|
|
70
|
+
finally {
|
|
71
|
+
await page.close();
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
await browser.close();
|
|
75
|
+
return result;
|
|
76
|
+
}
|
|
77
|
+
/** 개별 페이지 스캔: DOM 요소 + 네트워크 인터셉트 */
|
|
78
|
+
async function scanPage(page, url, waitTime) {
|
|
79
|
+
const apiCalls = [];
|
|
80
|
+
// 네트워크 인터셉트 — API 호출 캡처
|
|
81
|
+
page.on('response', async (response) => {
|
|
82
|
+
const request = response.request();
|
|
83
|
+
const resUrl = request.url();
|
|
84
|
+
const resourceType = request.resourceType();
|
|
85
|
+
if ((resourceType === 'xhr' || resourceType === 'fetch') && !isStaticResource(resUrl)) {
|
|
86
|
+
apiCalls.push({
|
|
87
|
+
method: request.method(),
|
|
88
|
+
url: resUrl,
|
|
89
|
+
responseStatus: response.status(),
|
|
90
|
+
triggeredBy: null,
|
|
91
|
+
});
|
|
92
|
+
}
|
|
93
|
+
});
|
|
94
|
+
// 페이지 로드
|
|
95
|
+
await page.goto(url, { waitUntil: 'networkidle', timeout: 30000 });
|
|
96
|
+
await page.waitForTimeout(waitTime);
|
|
97
|
+
const title = await page.title();
|
|
98
|
+
// DOM 스캔
|
|
99
|
+
const elements = await page.evaluate(() => {
|
|
100
|
+
// 링크
|
|
101
|
+
const links = Array.from(document.querySelectorAll('a[href]')).map((el, i) => {
|
|
102
|
+
const a = el;
|
|
103
|
+
return {
|
|
104
|
+
text: a.textContent?.trim().slice(0, 100) || '',
|
|
105
|
+
href: a.href,
|
|
106
|
+
selector: a.id ? `#${a.id}` : `a:nth-of-type(${i + 1})`,
|
|
107
|
+
};
|
|
108
|
+
});
|
|
109
|
+
// 버튼
|
|
110
|
+
const buttons = Array.from(document.querySelectorAll('button, [role="button"], input[type="submit"]')).map((el, i) => {
|
|
111
|
+
const btn = el;
|
|
112
|
+
return {
|
|
113
|
+
text: btn.textContent?.trim().slice(0, 100) || btn.value || '',
|
|
114
|
+
selector: btn.id ? `#${btn.id}` : `button:nth-of-type(${i + 1})`,
|
|
115
|
+
navigatesTo: null,
|
|
116
|
+
};
|
|
117
|
+
});
|
|
118
|
+
// 폼
|
|
119
|
+
const forms = Array.from(document.querySelectorAll('form')).map((el) => {
|
|
120
|
+
const form = el;
|
|
121
|
+
const fields = Array.from(form.querySelectorAll('input, select, textarea')).map((f) => {
|
|
122
|
+
const field = f;
|
|
123
|
+
return {
|
|
124
|
+
name: field.name || field.id || '',
|
|
125
|
+
fieldType: field.type || field.tagName.toLowerCase(),
|
|
126
|
+
required: field.required,
|
|
127
|
+
};
|
|
128
|
+
});
|
|
129
|
+
return {
|
|
130
|
+
id: form.id || null,
|
|
131
|
+
action: form.action || null,
|
|
132
|
+
method: form.method?.toUpperCase() || 'GET',
|
|
133
|
+
fields,
|
|
134
|
+
};
|
|
135
|
+
});
|
|
136
|
+
return { links, buttons, forms };
|
|
137
|
+
});
|
|
138
|
+
// 네비게이션 대상 URL 수집
|
|
139
|
+
const navigatesTo = [
|
|
140
|
+
...new Set(elements.links.map(l => l.href).filter(Boolean)),
|
|
141
|
+
];
|
|
142
|
+
// 로그인 페이지 감지
|
|
143
|
+
const authRequired = await page.evaluate(() => {
|
|
144
|
+
const html = document.body?.innerHTML?.toLowerCase() || '';
|
|
145
|
+
return html.includes('login') || html.includes('sign in') || html.includes('로그인');
|
|
146
|
+
});
|
|
147
|
+
return {
|
|
148
|
+
url,
|
|
149
|
+
title,
|
|
150
|
+
screenshotPath: null,
|
|
151
|
+
elements,
|
|
152
|
+
apiCalls,
|
|
153
|
+
navigatesTo,
|
|
154
|
+
authRequired,
|
|
155
|
+
};
|
|
156
|
+
}
|
|
157
|
+
/** 인증 설정 */
|
|
158
|
+
async function setupAuth(context, baseUrl, auth) {
|
|
159
|
+
const host = new URL(baseUrl).hostname;
|
|
160
|
+
if (auth.cookie) {
|
|
161
|
+
const cookies = auth.cookie.split(';').map(c => {
|
|
162
|
+
const [name, ...rest] = c.trim().split('=');
|
|
163
|
+
return { name, value: rest.join('='), domain: host, path: '/' };
|
|
164
|
+
});
|
|
165
|
+
await context.addCookies(cookies);
|
|
166
|
+
}
|
|
167
|
+
if (auth.bearer) {
|
|
168
|
+
await context.setExtraHTTPHeaders({
|
|
169
|
+
Authorization: `Bearer ${auth.bearer}`,
|
|
170
|
+
});
|
|
171
|
+
}
|
|
172
|
+
if (auth.loginUrl && auth.credentials) {
|
|
173
|
+
const page = await context.newPage();
|
|
174
|
+
await page.goto(auth.loginUrl, { waitUntil: 'networkidle' });
|
|
175
|
+
for (const [field, value] of Object.entries(auth.credentials)) {
|
|
176
|
+
await page.fill(`[name="${field}"], #${field}, input[type="${field}"]`, value).catch(() => { });
|
|
177
|
+
}
|
|
178
|
+
const submitSelector = auth.submitSelector || 'button[type="submit"], button:has-text("로그인"), button:has-text("Login")';
|
|
179
|
+
await page.click(submitSelector).catch(() => { });
|
|
180
|
+
await page.waitForNavigation({ waitUntil: 'networkidle' }).catch(() => { });
|
|
181
|
+
await page.close();
|
|
182
|
+
}
|
|
183
|
+
}
|
|
184
|
+
function normalizeUrl(url) {
|
|
185
|
+
try {
|
|
186
|
+
const u = new URL(url);
|
|
187
|
+
u.hash = '';
|
|
188
|
+
u.search = '';
|
|
189
|
+
let path = u.pathname;
|
|
190
|
+
if (path.endsWith('/') && path.length > 1)
|
|
191
|
+
path = path.slice(0, -1);
|
|
192
|
+
u.pathname = path;
|
|
193
|
+
return u.href;
|
|
194
|
+
}
|
|
195
|
+
catch {
|
|
196
|
+
return url;
|
|
197
|
+
}
|
|
198
|
+
}
|
|
199
|
+
function isStaticResource(url) {
|
|
200
|
+
return /\.(js|css|png|jpg|jpeg|gif|svg|woff|woff2|ttf|ico|map)(\?|$)/i.test(url);
|
|
201
|
+
}
|
package/dist/index.d.ts
CHANGED
package/dist/index.js
CHANGED