design-clone 1.2.0 → 2.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +26 -12
- package/bin/commands/clone-site.js +75 -10
- package/bin/commands/init.js +33 -1
- package/bin/commands/verify.js +5 -3
- package/bin/utils/validate.js +24 -8
- package/docs/cli-reference.md +200 -2
- package/docs/codebase-summary.md +309 -0
- package/docs/design-clone-architecture.md +259 -42
- package/docs/pixel-perfect.md +35 -4
- package/docs/project-roadmap.md +382 -0
- package/docs/troubleshooting.md +5 -4
- package/package.json +10 -8
- package/src/ai/__pycache__/analyze-structure.cpython-313.pyc +0 -0
- package/src/ai/__pycache__/extract-design-tokens.cpython-313.pyc +0 -0
- package/src/ai/analyze-structure.py +73 -3
- package/src/ai/extract-design-tokens.py +356 -13
- package/src/ai/prompts/__pycache__/design_tokens.cpython-313.pyc +0 -0
- package/src/ai/prompts/__pycache__/structure_analysis.cpython-313.pyc +0 -0
- package/src/ai/prompts/__pycache__/ux_audit.cpython-313.pyc +0 -0
- package/src/ai/prompts/design_tokens.py +133 -0
- package/src/ai/prompts/structure_analysis.py +329 -10
- package/src/ai/prompts/ux_audit.py +198 -0
- package/src/ai/ux-audit.js +596 -0
- package/src/core/app-state-snapshot.js +511 -0
- package/src/core/content-counter.js +342 -0
- package/src/core/cookie-handler.js +1 -1
- package/src/core/css-extractor.js +4 -4
- package/src/core/dimension-extractor.js +93 -21
- package/src/core/dimension-output.js +103 -6
- package/src/core/discover-pages.js +242 -14
- package/src/core/dom-tree-analyzer.js +298 -0
- package/src/core/extract-assets.js +1 -1
- package/src/core/framework-detector.js +538 -0
- package/src/core/html-extractor.js +45 -4
- package/src/core/lazy-loader.js +7 -7
- package/src/core/multi-page-screenshot.js +9 -6
- package/src/core/page-readiness.js +8 -8
- package/src/core/screenshot.js +138 -9
- package/src/core/section-cropper.js +209 -0
- package/src/core/section-detector.js +386 -0
- package/src/core/semantic-enhancer.js +492 -0
- package/src/core/state-capture.js +18 -22
- package/src/core/tests/test-section-cropper.js +177 -0
- package/src/core/tests/test-section-detector.js +55 -0
- package/src/core/video-capture.js +152 -146
- package/src/route-discoverers/angular-discoverer.js +157 -0
- package/src/route-discoverers/astro-discoverer.js +123 -0
- package/src/route-discoverers/base-discoverer.js +242 -0
- package/src/route-discoverers/index.js +106 -0
- package/src/route-discoverers/next-discoverer.js +130 -0
- package/src/route-discoverers/nuxt-discoverer.js +138 -0
- package/src/route-discoverers/react-discoverer.js +139 -0
- package/src/route-discoverers/svelte-discoverer.js +109 -0
- package/src/route-discoverers/universal-discoverer.js +227 -0
- package/src/route-discoverers/vue-discoverer.js +118 -0
- package/src/utils/__init__.py +1 -1
- package/src/utils/__pycache__/__init__.cpython-313.pyc +0 -0
- package/src/utils/browser.js +11 -37
- package/src/utils/playwright.js +213 -0
- package/src/verification/generate-audit-report.js +398 -0
- package/src/verification/verify-footer.js +493 -0
- package/src/verification/verify-header.js +486 -0
- package/src/verification/verify-layout.js +2 -2
- package/src/verification/verify-menu.js +4 -20
- package/src/verification/verify-slider.js +533 -0
- package/src/utils/puppeteer.js +0 -281
|
@@ -0,0 +1,157 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Angular Route Discoverer
|
|
3
|
+
*
|
|
4
|
+
* Extracts routes from Angular applications using:
|
|
5
|
+
* - ng.probe() for component inspection
|
|
6
|
+
* - routerLink attributes in DOM
|
|
7
|
+
* - app-root element analysis
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
import { BaseDiscoverer } from './base-discoverer.js';
|
|
11
|
+
|
|
12
|
+
export class AngularDiscoverer extends BaseDiscoverer {
|
|
13
|
+
/**
|
|
14
|
+
* Discover routes from an Angular application
|
|
15
|
+
* @returns {Promise<import('./base-discoverer.js').DiscoveredRoute[]>}
|
|
16
|
+
*/
|
|
17
|
+
async discover() {
|
|
18
|
+
const rawRoutes = await this.page.evaluate(() => {
|
|
19
|
+
const routes = [];
|
|
20
|
+
|
|
21
|
+
// Method 1: ng.probe() to access Router
|
|
22
|
+
const appRoot = document.querySelector('app-root');
|
|
23
|
+
if (appRoot && window.ng?.probe) {
|
|
24
|
+
try {
|
|
25
|
+
const debugElement = window.ng.probe(appRoot);
|
|
26
|
+
if (debugElement?.injector) {
|
|
27
|
+
// Try to get Router from injector
|
|
28
|
+
// Note: This may not work in production builds
|
|
29
|
+
const injector = debugElement.injector;
|
|
30
|
+
|
|
31
|
+
// Look for router in provider tree
|
|
32
|
+
const getAllProviders = (inj) => {
|
|
33
|
+
const providers = [];
|
|
34
|
+
if (inj._records) {
|
|
35
|
+
inj._records.forEach((v, k) => {
|
|
36
|
+
if (k.toString().includes('Router')) {
|
|
37
|
+
providers.push(v);
|
|
38
|
+
}
|
|
39
|
+
});
|
|
40
|
+
}
|
|
41
|
+
return providers;
|
|
42
|
+
};
|
|
43
|
+
|
|
44
|
+
const routerProviders = getAllProviders(injector);
|
|
45
|
+
routerProviders.forEach(provider => {
|
|
46
|
+
if (provider?.config) {
|
|
47
|
+
extractAngularRoutes(provider.config, routes);
|
|
48
|
+
}
|
|
49
|
+
});
|
|
50
|
+
}
|
|
51
|
+
} catch (e) {
|
|
52
|
+
// ng.probe may not be available in production
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
// Method 2: routerLink attributes (most reliable)
|
|
57
|
+
document.querySelectorAll('[routerLink], [routerlink]').forEach(el => {
|
|
58
|
+
const path = el.getAttribute('routerLink') || el.getAttribute('routerlink');
|
|
59
|
+
if (path) {
|
|
60
|
+
const text = el.textContent?.trim();
|
|
61
|
+
routes.push({
|
|
62
|
+
path: path.startsWith('/') ? path : '/' + path,
|
|
63
|
+
name: text || '',
|
|
64
|
+
source: 'framework'
|
|
65
|
+
});
|
|
66
|
+
}
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
// Method 3: [routerLink] with binding syntax
|
|
70
|
+
document.querySelectorAll('a[href]').forEach(link => {
|
|
71
|
+
const href = link.getAttribute('href');
|
|
72
|
+
if (href && href.startsWith('/')) {
|
|
73
|
+
// Check if it's inside Angular app
|
|
74
|
+
const isAngularLink = link.closest('app-root') ||
|
|
75
|
+
link.hasAttribute('routerLinkActive') ||
|
|
76
|
+
link.classList.contains('active');
|
|
77
|
+
|
|
78
|
+
if (isAngularLink || link.closest('nav, header, [role="navigation"]')) {
|
|
79
|
+
const text = link.textContent?.trim();
|
|
80
|
+
if (!routes.some(r => r.path === href)) {
|
|
81
|
+
routes.push({
|
|
82
|
+
path: href,
|
|
83
|
+
name: text || '',
|
|
84
|
+
source: isAngularLink ? 'framework' : 'link-scrape'
|
|
85
|
+
});
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
});
|
|
90
|
+
|
|
91
|
+
// Method 4: routerLinkActive elements
|
|
92
|
+
document.querySelectorAll('[routerLinkActive], [routerlinkactive]').forEach(el => {
|
|
93
|
+
const link = el.tagName === 'A' ? el : el.querySelector('a');
|
|
94
|
+
if (link) {
|
|
95
|
+
const href = link.getAttribute('href') ||
|
|
96
|
+
link.getAttribute('routerLink') ||
|
|
97
|
+
link.getAttribute('routerlink');
|
|
98
|
+
if (href && !routes.some(r => r.path === href)) {
|
|
99
|
+
routes.push({
|
|
100
|
+
path: href.startsWith('/') ? href : '/' + href,
|
|
101
|
+
name: link.textContent?.trim() || '',
|
|
102
|
+
source: 'framework'
|
|
103
|
+
});
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
});
|
|
107
|
+
|
|
108
|
+
/**
|
|
109
|
+
* Extract routes from Angular Router config
|
|
110
|
+
*/
|
|
111
|
+
function extractAngularRoutes(config, output, prefix = '') {
|
|
112
|
+
if (!Array.isArray(config)) return;
|
|
113
|
+
|
|
114
|
+
config.forEach(route => {
|
|
115
|
+
if (!route.path && route.path !== '') return;
|
|
116
|
+
|
|
117
|
+
let path = route.path;
|
|
118
|
+
if (prefix && !path.startsWith('/')) {
|
|
119
|
+
path = prefix + '/' + path;
|
|
120
|
+
}
|
|
121
|
+
if (!path.startsWith('/')) {
|
|
122
|
+
path = '/' + path;
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
// Skip wildcard and redirect-only routes
|
|
126
|
+
if (path === '/**' || path === '**' || (!route.component && route.redirectTo)) {
|
|
127
|
+
return;
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
output.push({
|
|
131
|
+
path,
|
|
132
|
+
name: route.data?.title || route.title || '',
|
|
133
|
+
component: route.component?.name || '',
|
|
134
|
+
source: 'framework'
|
|
135
|
+
});
|
|
136
|
+
|
|
137
|
+
// Process child routes
|
|
138
|
+
if (route.children) {
|
|
139
|
+
extractAngularRoutes(route.children, output, path);
|
|
140
|
+
}
|
|
141
|
+
});
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
return routes;
|
|
145
|
+
});
|
|
146
|
+
|
|
147
|
+
const processedRoutes = rawRoutes.map(route => ({
|
|
148
|
+
...route,
|
|
149
|
+
name: route.name || this.extractPageName(route.path, route.component),
|
|
150
|
+
path: this.normalizeRoute(route.path)
|
|
151
|
+
}));
|
|
152
|
+
|
|
153
|
+
return this.deduplicateRoutes(processedRoutes);
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
export default AngularDiscoverer;
|
|
@@ -0,0 +1,123 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Astro Route Discoverer
|
|
3
|
+
*
|
|
4
|
+
* Astro is primarily a static site generator with islands architecture.
|
|
5
|
+
* Routes are extracted from:
|
|
6
|
+
* - astro-island components
|
|
7
|
+
* - data-astro-* prefetch attributes
|
|
8
|
+
* - Standard navigation links (Astro generates static HTML)
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
import { BaseDiscoverer } from './base-discoverer.js';
|
|
12
|
+
|
|
13
|
+
export class AstroDiscoverer extends BaseDiscoverer {
|
|
14
|
+
/**
|
|
15
|
+
* Discover routes from an Astro application
|
|
16
|
+
* @returns {Promise<import('./base-discoverer.js').DiscoveredRoute[]>}
|
|
17
|
+
*/
|
|
18
|
+
async discover() {
|
|
19
|
+
const rawRoutes = await this.page.evaluate(() => {
|
|
20
|
+
const routes = [];
|
|
21
|
+
|
|
22
|
+
// Method 1: astro-island components (indicate interactive pages)
|
|
23
|
+
document.querySelectorAll('astro-island').forEach(island => {
|
|
24
|
+
// Islands don't directly indicate routes, but their presence
|
|
25
|
+
// confirms we're on an Astro site. Look for nearby links.
|
|
26
|
+
const nearbyLinks = island.querySelectorAll('a[href]');
|
|
27
|
+
nearbyLinks.forEach(link => {
|
|
28
|
+
const href = link.getAttribute('href');
|
|
29
|
+
if (href && href.startsWith('/')) {
|
|
30
|
+
routes.push({
|
|
31
|
+
path: href,
|
|
32
|
+
name: link.textContent?.trim() || '',
|
|
33
|
+
source: 'framework'
|
|
34
|
+
});
|
|
35
|
+
}
|
|
36
|
+
});
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
// Method 2: data-astro-prefetch links (Astro's View Transitions)
|
|
40
|
+
document.querySelectorAll('a[data-astro-prefetch]').forEach(link => {
|
|
41
|
+
const href = link.getAttribute('href');
|
|
42
|
+
if (href && href.startsWith('/')) {
|
|
43
|
+
if (!routes.some(r => r.path === href)) {
|
|
44
|
+
routes.push({
|
|
45
|
+
path: href,
|
|
46
|
+
name: link.textContent?.trim() || '',
|
|
47
|
+
source: 'framework'
|
|
48
|
+
});
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
// Method 3: Links with data-astro-cid-* (component IDs)
|
|
54
|
+
document.querySelectorAll('a[href]').forEach(link => {
|
|
55
|
+
// Check if link has Astro component ID attribute
|
|
56
|
+
const hasAstroAttr = Array.from(link.attributes).some(
|
|
57
|
+
attr => attr.name.startsWith('data-astro-cid-')
|
|
58
|
+
);
|
|
59
|
+
|
|
60
|
+
if (hasAstroAttr) {
|
|
61
|
+
const href = link.getAttribute('href');
|
|
62
|
+
if (href && href.startsWith('/')) {
|
|
63
|
+
if (!routes.some(r => r.path === href)) {
|
|
64
|
+
routes.push({
|
|
65
|
+
path: href,
|
|
66
|
+
name: link.textContent?.trim() || '',
|
|
67
|
+
source: 'framework'
|
|
68
|
+
});
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
// Method 4: Standard navigation (Astro generates static HTML)
|
|
75
|
+
document.querySelectorAll('nav a, header a, [role="navigation"] a, footer a').forEach(link => {
|
|
76
|
+
const href = link.getAttribute('href');
|
|
77
|
+
if (href && href.startsWith('/')) {
|
|
78
|
+
// Check if inside Astro component
|
|
79
|
+
const isAstroLink = link.closest('astro-island') ||
|
|
80
|
+
Array.from(link.attributes).some(
|
|
81
|
+
attr => attr.name.startsWith('data-astro-')
|
|
82
|
+
);
|
|
83
|
+
|
|
84
|
+
if (!routes.some(r => r.path === href)) {
|
|
85
|
+
routes.push({
|
|
86
|
+
path: href,
|
|
87
|
+
name: link.textContent?.trim() || '',
|
|
88
|
+
source: isAstroLink ? 'framework' : 'link-scrape'
|
|
89
|
+
});
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
});
|
|
93
|
+
|
|
94
|
+
// Method 5: Look for Astro's client:* directives in islands
|
|
95
|
+
document.querySelectorAll('[client\\:load], [client\\:idle], [client\\:visible]').forEach(el => {
|
|
96
|
+
// These indicate interactive components
|
|
97
|
+
const links = el.querySelectorAll('a[href^="/"]');
|
|
98
|
+
links.forEach(link => {
|
|
99
|
+
const href = link.getAttribute('href');
|
|
100
|
+
if (href && !routes.some(r => r.path === href)) {
|
|
101
|
+
routes.push({
|
|
102
|
+
path: href,
|
|
103
|
+
name: link.textContent?.trim() || '',
|
|
104
|
+
source: 'framework'
|
|
105
|
+
});
|
|
106
|
+
}
|
|
107
|
+
});
|
|
108
|
+
});
|
|
109
|
+
|
|
110
|
+
return routes;
|
|
111
|
+
});
|
|
112
|
+
|
|
113
|
+
const processedRoutes = rawRoutes.map(route => ({
|
|
114
|
+
...route,
|
|
115
|
+
name: route.name || this.extractPageName(route.path),
|
|
116
|
+
path: this.normalizeRoute(route.path)
|
|
117
|
+
}));
|
|
118
|
+
|
|
119
|
+
return this.deduplicateRoutes(processedRoutes);
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
export default AstroDiscoverer;
|
|
@@ -0,0 +1,242 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Base Discoverer - Abstract base class for route discoverers
|
|
3
|
+
*
|
|
4
|
+
* Provides common utilities for route normalization, deduplication,
|
|
5
|
+
* and page name extraction. Framework-specific discoverers extend this.
|
|
6
|
+
*
|
|
7
|
+
* Usage:
|
|
8
|
+
* class NextDiscoverer extends BaseDiscoverer {
|
|
9
|
+
* async discover() { ... }
|
|
10
|
+
* }
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* @typedef {Object} DiscoveredRoute
|
|
15
|
+
* @property {string} path - Route path (e.g., '/about', '/blog/[slug]')
|
|
16
|
+
* @property {string} name - Human-readable name
|
|
17
|
+
* @property {string} url - Full URL
|
|
18
|
+
* @property {boolean} dynamic - True if contains dynamic segments
|
|
19
|
+
* @property {string} [component] - Component name if available
|
|
20
|
+
* @property {string} source - Discovery source ('framework'|'link-scrape'|'interception')
|
|
21
|
+
*/
|
|
22
|
+
|
|
23
|
+
// Dynamic segment patterns
|
|
24
|
+
const DYNAMIC_PATTERNS = [
|
|
25
|
+
/\[[\w-]+\]/, // Next.js [slug]
|
|
26
|
+
/\[\.\.\.([\w-]+)\]/, // Next.js catch-all [...slug]
|
|
27
|
+
/:[\w-]+/, // Vue/React :id
|
|
28
|
+
/\{[\w-]+\}/, // Angular {id}
|
|
29
|
+
/\*[\w-]*/ // Wildcard
|
|
30
|
+
];
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* Abstract base class for route discoverers
|
|
34
|
+
*/
|
|
35
|
+
export class BaseDiscoverer {
|
|
36
|
+
/**
|
|
37
|
+
* @param {import('playwright').Page} page - Playwright page object
|
|
38
|
+
* @param {string} baseUrl - Base URL of the site
|
|
39
|
+
*/
|
|
40
|
+
constructor(page, baseUrl) {
|
|
41
|
+
this.page = page;
|
|
42
|
+
this.baseUrl = baseUrl;
|
|
43
|
+
this.baseOrigin = new URL(baseUrl).origin;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* Discover routes - must be implemented by subclasses
|
|
48
|
+
* @returns {Promise<DiscoveredRoute[]>}
|
|
49
|
+
*/
|
|
50
|
+
async discover() {
|
|
51
|
+
throw new Error('discover() must be implemented by subclass');
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* Normalize a route path
|
|
56
|
+
* - Removes trailing slashes (except for root)
|
|
57
|
+
* - Removes query params and hash
|
|
58
|
+
* - Ensures leading slash
|
|
59
|
+
* @param {string} path - Route path to normalize
|
|
60
|
+
* @returns {string} Normalized path
|
|
61
|
+
*/
|
|
62
|
+
normalizeRoute(path) {
|
|
63
|
+
if (!path || typeof path !== 'string') return '/';
|
|
64
|
+
|
|
65
|
+
// Handle full URLs
|
|
66
|
+
if (path.startsWith('http')) {
|
|
67
|
+
try {
|
|
68
|
+
path = new URL(path).pathname;
|
|
69
|
+
} catch {
|
|
70
|
+
return '/';
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
// Ensure leading slash
|
|
75
|
+
if (!path.startsWith('/')) {
|
|
76
|
+
path = '/' + path;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
// Remove query params and hash
|
|
80
|
+
path = path.split('?')[0].split('#')[0];
|
|
81
|
+
|
|
82
|
+
// Remove trailing slash (except for root)
|
|
83
|
+
if (path.length > 1 && path.endsWith('/')) {
|
|
84
|
+
path = path.slice(0, -1);
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
return path;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/**
|
|
91
|
+
* Check if a path contains dynamic segments
|
|
92
|
+
* @param {string} path - Route path
|
|
93
|
+
* @returns {boolean}
|
|
94
|
+
*/
|
|
95
|
+
isDynamicRoute(path) {
|
|
96
|
+
return DYNAMIC_PATTERNS.some(pattern => pattern.test(path));
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/**
|
|
100
|
+
* Extract a human-readable page name from a path
|
|
101
|
+
* @param {string} path - Route path
|
|
102
|
+
* @param {string} [componentName] - Optional component name
|
|
103
|
+
* @returns {string}
|
|
104
|
+
*/
|
|
105
|
+
extractPageName(path, componentName) {
|
|
106
|
+
// Use component name if available
|
|
107
|
+
if (componentName && componentName !== 'default' && componentName !== 'index') {
|
|
108
|
+
// Convert camelCase/PascalCase to Title Case
|
|
109
|
+
return componentName
|
|
110
|
+
.replace(/([A-Z])/g, ' $1')
|
|
111
|
+
.replace(/^./, s => s.toUpperCase())
|
|
112
|
+
.trim();
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
// Extract from path
|
|
116
|
+
const normalized = this.normalizeRoute(path);
|
|
117
|
+
|
|
118
|
+
if (normalized === '/') return 'Home';
|
|
119
|
+
|
|
120
|
+
// Get last segment
|
|
121
|
+
const segments = normalized.split('/').filter(Boolean);
|
|
122
|
+
if (segments.length === 0) return 'Home';
|
|
123
|
+
|
|
124
|
+
let lastSegment = segments[segments.length - 1];
|
|
125
|
+
|
|
126
|
+
// Handle dynamic segments
|
|
127
|
+
if (this.isDynamicRoute(lastSegment)) {
|
|
128
|
+
lastSegment = lastSegment.replace(/[\[\]:{}*\.]/g, '');
|
|
129
|
+
return `${this.titleCase(lastSegment)} (Dynamic)`;
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
// Convert kebab-case/snake_case to Title Case
|
|
133
|
+
return this.titleCase(lastSegment);
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
/**
|
|
137
|
+
* Convert string to Title Case
|
|
138
|
+
* @param {string} str - Input string
|
|
139
|
+
* @returns {string}
|
|
140
|
+
*/
|
|
141
|
+
titleCase(str) {
|
|
142
|
+
return str
|
|
143
|
+
.replace(/[-_]/g, ' ')
|
|
144
|
+
.replace(/\b\w/g, c => c.toUpperCase());
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
/**
|
|
148
|
+
* Deduplicate routes by path, preferring 'framework' source over others
|
|
149
|
+
* @param {DiscoveredRoute[]} routes - Array of routes
|
|
150
|
+
* @returns {DiscoveredRoute[]} Deduplicated routes
|
|
151
|
+
*/
|
|
152
|
+
deduplicateRoutes(routes) {
|
|
153
|
+
const seen = new Map();
|
|
154
|
+
|
|
155
|
+
// Source priority: framework > interception > sitemap > link-scrape
|
|
156
|
+
const sourcePriority = {
|
|
157
|
+
'framework': 4,
|
|
158
|
+
'interception': 3,
|
|
159
|
+
'sitemap': 2,
|
|
160
|
+
'link-scrape': 1
|
|
161
|
+
};
|
|
162
|
+
|
|
163
|
+
for (const route of routes) {
|
|
164
|
+
const normalized = this.normalizeRoute(route.path);
|
|
165
|
+
const existing = seen.get(normalized);
|
|
166
|
+
|
|
167
|
+
const currentPriority = sourcePriority[route.source] || 0;
|
|
168
|
+
const existingPriority = existing ? (sourcePriority[existing.source] || 0) : -1;
|
|
169
|
+
|
|
170
|
+
// Replace if higher priority or if same priority but has a name while existing doesn't
|
|
171
|
+
if (!existing || currentPriority > existingPriority ||
|
|
172
|
+
(currentPriority === existingPriority && route.name && !existing.name)) {
|
|
173
|
+
seen.set(normalized, {
|
|
174
|
+
...route,
|
|
175
|
+
path: normalized,
|
|
176
|
+
url: this.buildFullUrl(normalized),
|
|
177
|
+
dynamic: this.isDynamicRoute(normalized)
|
|
178
|
+
});
|
|
179
|
+
}
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
return Array.from(seen.values());
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
/**
|
|
186
|
+
* Build full URL from path
|
|
187
|
+
* @param {string} path - Route path
|
|
188
|
+
* @returns {string}
|
|
189
|
+
*/
|
|
190
|
+
buildFullUrl(path) {
|
|
191
|
+
const normalized = this.normalizeRoute(path);
|
|
192
|
+
return `${this.baseOrigin}${normalized}`;
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
/**
|
|
196
|
+
* Scrape link elements from navigation areas
|
|
197
|
+
* Common utility for all discoverers as fallback
|
|
198
|
+
* @param {string[]} [selectors] - CSS selectors to search
|
|
199
|
+
* @returns {Promise<DiscoveredRoute[]>}
|
|
200
|
+
*/
|
|
201
|
+
async scrapeLinkElements(selectors = ['nav a', 'header a', '[role="navigation"] a']) {
|
|
202
|
+
const selectorString = selectors.join(', ');
|
|
203
|
+
const baseOrigin = this.baseOrigin;
|
|
204
|
+
|
|
205
|
+
const routes = await this.page.evaluate(({ sel, origin }) => {
|
|
206
|
+
const links = [];
|
|
207
|
+
const elements = document.querySelectorAll(sel);
|
|
208
|
+
|
|
209
|
+
elements.forEach(el => {
|
|
210
|
+
const href = el.getAttribute('href');
|
|
211
|
+
if (!href) return;
|
|
212
|
+
|
|
213
|
+
// Skip non-http links
|
|
214
|
+
if (href.startsWith('mailto:') || href.startsWith('tel:') ||
|
|
215
|
+
href.startsWith('javascript:') || href === '#') return;
|
|
216
|
+
|
|
217
|
+
// Skip external links
|
|
218
|
+
try {
|
|
219
|
+
const url = new URL(href, origin);
|
|
220
|
+
if (url.origin !== origin) return;
|
|
221
|
+
|
|
222
|
+
links.push({
|
|
223
|
+
path: url.pathname,
|
|
224
|
+
name: el.textContent?.trim() || '',
|
|
225
|
+
source: 'link-scrape'
|
|
226
|
+
});
|
|
227
|
+
} catch {
|
|
228
|
+
// Invalid URL, skip
|
|
229
|
+
}
|
|
230
|
+
});
|
|
231
|
+
|
|
232
|
+
return links;
|
|
233
|
+
}, { sel: selectorString, origin: baseOrigin });
|
|
234
|
+
|
|
235
|
+
return routes.map(r => ({
|
|
236
|
+
...r,
|
|
237
|
+
name: r.name || this.extractPageName(r.path)
|
|
238
|
+
}));
|
|
239
|
+
}
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
export default BaseDiscoverer;
|
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Route Discoverers Registry
|
|
3
|
+
*
|
|
4
|
+
* Central registry for framework-specific route discoverers.
|
|
5
|
+
* Uses the framework detector to select the appropriate discoverer.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import { NextDiscoverer } from './next-discoverer.js';
|
|
9
|
+
import { NuxtDiscoverer } from './nuxt-discoverer.js';
|
|
10
|
+
import { VueDiscoverer } from './vue-discoverer.js';
|
|
11
|
+
import { ReactDiscoverer } from './react-discoverer.js';
|
|
12
|
+
import { AngularDiscoverer } from './angular-discoverer.js';
|
|
13
|
+
import { SvelteDiscoverer } from './svelte-discoverer.js';
|
|
14
|
+
import { AstroDiscoverer } from './astro-discoverer.js';
|
|
15
|
+
import { UniversalDiscoverer } from './universal-discoverer.js';
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* Registry mapping framework names to discoverer classes
|
|
19
|
+
*/
|
|
20
|
+
export const DISCOVERER_REGISTRY = {
|
|
21
|
+
'next': NextDiscoverer,
|
|
22
|
+
'nuxt': NuxtDiscoverer,
|
|
23
|
+
'vue': VueDiscoverer,
|
|
24
|
+
'react': ReactDiscoverer,
|
|
25
|
+
'angular': AngularDiscoverer,
|
|
26
|
+
'svelte': SvelteDiscoverer,
|
|
27
|
+
'astro': AstroDiscoverer,
|
|
28
|
+
'unknown': UniversalDiscoverer
|
|
29
|
+
};
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* Get the appropriate discoverer class for a framework
|
|
33
|
+
* @param {string} framework - Framework name from detector
|
|
34
|
+
* @returns {typeof import('./base-discoverer.js').BaseDiscoverer}
|
|
35
|
+
*/
|
|
36
|
+
export function getDiscovererClass(framework) {
|
|
37
|
+
const normalizedFramework = framework?.toLowerCase() || 'unknown';
|
|
38
|
+
return DISCOVERER_REGISTRY[normalizedFramework] || UniversalDiscoverer;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* Create a discoverer instance for a framework
|
|
43
|
+
* @param {string} framework - Framework name
|
|
44
|
+
* @param {import('playwright').Page} page - Playwright page instance
|
|
45
|
+
* @param {string} baseUrl - Base URL of the site
|
|
46
|
+
* @returns {import('./base-discoverer.js').BaseDiscoverer}
|
|
47
|
+
*/
|
|
48
|
+
export function createDiscoverer(framework, page, baseUrl) {
|
|
49
|
+
const DiscovererClass = getDiscovererClass(framework);
|
|
50
|
+
return new DiscovererClass(page, baseUrl);
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* Discover routes for a given page using framework detection
|
|
55
|
+
* @param {import('playwright').Page} page - Playwright page instance
|
|
56
|
+
* @param {string} baseUrl - Base URL of the site
|
|
57
|
+
* @param {object} [frameworkInfo] - Optional pre-detected framework info
|
|
58
|
+
* @returns {Promise<{routes: import('./base-discoverer.js').DiscoveredRoute[], framework: string, discoverer: string}>}
|
|
59
|
+
*/
|
|
60
|
+
export async function discoverRoutes(page, baseUrl, frameworkInfo = null) {
|
|
61
|
+
// Import framework detector if we need to detect
|
|
62
|
+
let detectedFramework = frameworkInfo?.framework || 'unknown';
|
|
63
|
+
|
|
64
|
+
if (!frameworkInfo) {
|
|
65
|
+
try {
|
|
66
|
+
const { detectFramework } = await import('../core/framework-detector.js');
|
|
67
|
+
const info = await detectFramework(page);
|
|
68
|
+
detectedFramework = info.framework;
|
|
69
|
+
} catch {
|
|
70
|
+
// Framework detector not available, use universal
|
|
71
|
+
detectedFramework = 'unknown';
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
const discoverer = createDiscoverer(detectedFramework, page, baseUrl);
|
|
76
|
+
const routes = await discoverer.discover();
|
|
77
|
+
|
|
78
|
+
return {
|
|
79
|
+
routes,
|
|
80
|
+
framework: detectedFramework,
|
|
81
|
+
discoverer: discoverer.constructor.name
|
|
82
|
+
};
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/**
|
|
86
|
+
* Get list of supported frameworks
|
|
87
|
+
* @returns {string[]}
|
|
88
|
+
*/
|
|
89
|
+
export function getSupportedFrameworks() {
|
|
90
|
+
return Object.keys(DISCOVERER_REGISTRY).filter(k => k !== 'unknown');
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
// Export all discoverer classes for direct use
|
|
94
|
+
export {
|
|
95
|
+
NextDiscoverer,
|
|
96
|
+
NuxtDiscoverer,
|
|
97
|
+
VueDiscoverer,
|
|
98
|
+
ReactDiscoverer,
|
|
99
|
+
AngularDiscoverer,
|
|
100
|
+
SvelteDiscoverer,
|
|
101
|
+
AstroDiscoverer,
|
|
102
|
+
UniversalDiscoverer
|
|
103
|
+
};
|
|
104
|
+
|
|
105
|
+
// Re-export base class
|
|
106
|
+
export { BaseDiscoverer } from './base-discoverer.js';
|