design-clone 1.0.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/.env.example +14 -0
- package/LICENSE +21 -0
- package/README.md +166 -0
- package/SKILL.md +239 -0
- package/bin/cli.js +45 -0
- package/bin/commands/help.js +29 -0
- package/bin/commands/init.js +126 -0
- package/bin/commands/verify.js +99 -0
- package/bin/utils/copy.js +65 -0
- package/bin/utils/validate.js +122 -0
- package/docs/basic-clone.md +63 -0
- package/docs/cli-reference.md +94 -0
- package/docs/design-clone-architecture.md +247 -0
- package/docs/pixel-perfect.md +86 -0
- package/docs/troubleshooting.md +97 -0
- package/package.json +57 -0
- package/requirements.txt +5 -0
- package/src/ai/analyze-structure.py +305 -0
- package/src/ai/extract-design-tokens.py +439 -0
- package/src/ai/prompts/__init__.py +2 -0
- package/src/ai/prompts/design_tokens.py +183 -0
- package/src/ai/prompts/structure_analysis.py +273 -0
- package/src/core/cookie-handler.js +76 -0
- package/src/core/css-extractor.js +107 -0
- package/src/core/dimension-extractor.js +366 -0
- package/src/core/dimension-output.js +208 -0
- package/src/core/extract-assets.js +468 -0
- package/src/core/filter-css.js +499 -0
- package/src/core/html-extractor.js +102 -0
- package/src/core/lazy-loader.js +188 -0
- package/src/core/page-readiness.js +161 -0
- package/src/core/screenshot.js +380 -0
- package/src/post-process/enhance-assets.js +157 -0
- package/src/post-process/fetch-images.js +398 -0
- package/src/post-process/inject-icons.js +311 -0
- package/src/utils/__init__.py +16 -0
- package/src/utils/__pycache__/__init__.cpython-313.pyc +0 -0
- package/src/utils/__pycache__/env.cpython-313.pyc +0 -0
- package/src/utils/browser.js +103 -0
- package/src/utils/env.js +153 -0
- package/src/utils/env.py +134 -0
- package/src/utils/helpers.js +71 -0
- package/src/utils/puppeteer.js +281 -0
- package/src/verification/verify-layout.js +424 -0
- package/src/verification/verify-menu.js +422 -0
- package/templates/base.css +705 -0
- package/templates/base.html +293 -0
|
@@ -0,0 +1,188 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Lazy Loading Utilities
|
|
3
|
+
*
|
|
4
|
+
* Functions to trigger lazy-loaded content including images,
|
|
5
|
+
* animations, and IntersectionObserver-based content.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
// Constants
|
|
9
|
+
export const LAZY_LOAD_MAX_ITERATIONS = 15;
|
|
10
|
+
export const IMAGE_LOAD_TIMEOUT = 20000;
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* Force all lazy images to load
|
|
14
|
+
* - Sets loading="eager" on all images
|
|
15
|
+
* - Copies data-src to src if exists
|
|
16
|
+
* - Triggers IntersectionObserver by scrolling
|
|
17
|
+
* @param {Page} page - Puppeteer page
|
|
18
|
+
*/
|
|
19
|
+
export async function forceLazyImages(page) {
|
|
20
|
+
return await page.evaluate(async () => {
|
|
21
|
+
const stats = { forced: 0, dataSrc: 0, eager: 0 };
|
|
22
|
+
const images = document.querySelectorAll('img');
|
|
23
|
+
|
|
24
|
+
for (const img of images) {
|
|
25
|
+
if (img.loading === 'lazy') {
|
|
26
|
+
img.loading = 'eager';
|
|
27
|
+
stats.eager++;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
const dataSrc = img.dataset.src || img.dataset.lazySrc || img.getAttribute('data-lazy-src');
|
|
31
|
+
if (dataSrc && !img.src) {
|
|
32
|
+
img.src = dataSrc;
|
|
33
|
+
stats.dataSrc++;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
img.scrollIntoView({ block: 'center', behavior: 'instant' });
|
|
37
|
+
stats.forced++;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
// Handle background images with data attributes
|
|
41
|
+
document.querySelectorAll('[data-bg], [data-background]').forEach(el => {
|
|
42
|
+
const bgUrl = el.dataset.bg || el.dataset.background;
|
|
43
|
+
if (bgUrl) {
|
|
44
|
+
el.style.backgroundImage = `url(${bgUrl})`;
|
|
45
|
+
}
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
return stats;
|
|
49
|
+
});
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* Force all hidden animated elements to be visible
|
|
54
|
+
* @param {Page} page - Puppeteer page
|
|
55
|
+
*/
|
|
56
|
+
export async function forceAnimatedElementsVisible(page) {
|
|
57
|
+
return await page.evaluate(() => {
|
|
58
|
+
let forcedCount = 0;
|
|
59
|
+
|
|
60
|
+
document.querySelectorAll('[class*="appear"], [class*="fade"], [class*="animate"]').forEach(el => {
|
|
61
|
+
const rect = el.getBoundingClientRect();
|
|
62
|
+
const absoluteTop = rect.top + window.scrollY;
|
|
63
|
+
|
|
64
|
+
// Only force elements below the hero area (first 500px)
|
|
65
|
+
if (absoluteTop > 500) {
|
|
66
|
+
const style = getComputedStyle(el);
|
|
67
|
+
if (style.opacity === '0' || parseFloat(style.opacity) < 0.5) {
|
|
68
|
+
el.style.setProperty('opacity', '1', 'important');
|
|
69
|
+
el.style.setProperty('visibility', 'visible', 'important');
|
|
70
|
+
forcedCount++;
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
return { forcedCount };
|
|
76
|
+
});
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/**
|
|
80
|
+
* Trigger lazy loading by scrolling through entire page
|
|
81
|
+
* @param {Page} page - Puppeteer page
|
|
82
|
+
* @param {number} maxIterations - Max scroll iterations
|
|
83
|
+
* @param {number} scrollDelay - Pause time between scrolls
|
|
84
|
+
*/
|
|
85
|
+
export async function triggerLazyLoad(page, maxIterations = 20, scrollDelay = 1500) {
|
|
86
|
+
return await page.evaluate(async (maxIter, pauseMs) => {
|
|
87
|
+
return new Promise(async (resolve) => {
|
|
88
|
+
const viewportHeight = window.innerHeight;
|
|
89
|
+
const totalHeight = document.body.scrollHeight;
|
|
90
|
+
const scrollStep = viewportHeight * 0.5;
|
|
91
|
+
const pauseTime = pauseMs;
|
|
92
|
+
|
|
93
|
+
let position = 0;
|
|
94
|
+
let iterations = 0;
|
|
95
|
+
|
|
96
|
+
// First pass: scroll through entire page
|
|
97
|
+
while (position < totalHeight && iterations < maxIter) {
|
|
98
|
+
window.scrollTo({ top: position, behavior: 'instant' });
|
|
99
|
+
await new Promise(r => setTimeout(r, pauseTime));
|
|
100
|
+
position += scrollStep;
|
|
101
|
+
iterations++;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
// Scroll to bottom
|
|
105
|
+
window.scrollTo({ top: document.body.scrollHeight, behavior: 'instant' });
|
|
106
|
+
await new Promise(r => setTimeout(r, 1000));
|
|
107
|
+
|
|
108
|
+
// Second pass: scroll back up
|
|
109
|
+
position = document.body.scrollHeight;
|
|
110
|
+
while (position > 0) {
|
|
111
|
+
position -= scrollStep;
|
|
112
|
+
window.scrollTo({ top: Math.max(0, position), behavior: 'instant' });
|
|
113
|
+
await new Promise(r => setTimeout(r, 300));
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
// Return to top
|
|
117
|
+
window.scrollTo({ top: 0, behavior: 'instant' });
|
|
118
|
+
await new Promise(r => setTimeout(r, 1500));
|
|
119
|
+
|
|
120
|
+
if (window.scrollY !== 0) {
|
|
121
|
+
window.scrollTo({ top: 0, behavior: 'instant' });
|
|
122
|
+
await new Promise(r => setTimeout(r, 500));
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
resolve({
|
|
126
|
+
scrolled: iterations,
|
|
127
|
+
height: document.body.scrollHeight,
|
|
128
|
+
stableAt: iterations
|
|
129
|
+
});
|
|
130
|
+
});
|
|
131
|
+
}, maxIterations, scrollDelay);
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
/**
|
|
135
|
+
* Wait for all images to finish loading
|
|
136
|
+
* @param {Page} page - Puppeteer page
|
|
137
|
+
* @param {number} timeout - Max wait time
|
|
138
|
+
*/
|
|
139
|
+
export async function waitForAllImages(page, timeout = IMAGE_LOAD_TIMEOUT) {
|
|
140
|
+
const imgStats = await page.evaluate(async (maxWait) => {
|
|
141
|
+
const startTime = Date.now();
|
|
142
|
+
const images = Array.from(document.querySelectorAll('img'));
|
|
143
|
+
const pendingImages = images.filter(img => img.src && !img.complete);
|
|
144
|
+
|
|
145
|
+
if (pendingImages.length === 0) {
|
|
146
|
+
return { loaded: images.length, pending: 0, timedOut: false };
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
const loadPromises = pendingImages.map(img => {
|
|
150
|
+
return new Promise((resolve) => {
|
|
151
|
+
if (img.complete) {
|
|
152
|
+
resolve(true);
|
|
153
|
+
return;
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
const checkComplete = () => {
|
|
157
|
+
if (img.complete || Date.now() - startTime > maxWait) {
|
|
158
|
+
resolve(img.complete);
|
|
159
|
+
} else {
|
|
160
|
+
setTimeout(checkComplete, 100);
|
|
161
|
+
}
|
|
162
|
+
};
|
|
163
|
+
|
|
164
|
+
img.onload = () => resolve(true);
|
|
165
|
+
img.onerror = () => resolve(false);
|
|
166
|
+
setTimeout(checkComplete, 100);
|
|
167
|
+
});
|
|
168
|
+
});
|
|
169
|
+
|
|
170
|
+
await Promise.all(loadPromises);
|
|
171
|
+
|
|
172
|
+
const stillPending = images.filter(img => img.src && !img.complete).length;
|
|
173
|
+
return {
|
|
174
|
+
loaded: images.length - stillPending,
|
|
175
|
+
pending: stillPending,
|
|
176
|
+
timedOut: Date.now() - startTime >= maxWait
|
|
177
|
+
};
|
|
178
|
+
}, timeout);
|
|
179
|
+
|
|
180
|
+
try {
|
|
181
|
+
await page.waitForNetworkIdle({ timeout: Math.min(timeout, 10000) });
|
|
182
|
+
} catch {
|
|
183
|
+
// Network didn't become idle, continue anyway
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
await new Promise(r => setTimeout(r, 1500));
|
|
187
|
+
return imgStats;
|
|
188
|
+
}
|
|
@@ -0,0 +1,161 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Page Readiness Detection
|
|
3
|
+
*
|
|
4
|
+
* Utilities for detecting when a page has fully loaded and stabilized.
|
|
5
|
+
* Handles SPA hydration, CSS-in-JS, fonts, and animations.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
// Page readiness constants
|
|
9
|
+
export const PAGE_READY_TIMEOUT = 45000; // 45s max wait for slow SPAs
|
|
10
|
+
export const DOM_STABLE_THRESHOLD = 800; // 800ms stability check
|
|
11
|
+
export const POST_READY_DELAY = 2000; // Extra animation buffer
|
|
12
|
+
export const FONT_LOAD_TIMEOUT = 5000; // Font loading timeout
|
|
13
|
+
|
|
14
|
+
// Loading indicator selectors
|
|
15
|
+
export const LOADING_SELECTORS = [
|
|
16
|
+
'.loading',
|
|
17
|
+
'[class*="loading"]',
|
|
18
|
+
'[class*="spinner"]',
|
|
19
|
+
'[class*="skeleton"]',
|
|
20
|
+
'[class*="placeholder"]'
|
|
21
|
+
];
|
|
22
|
+
|
|
23
|
+
// Content presence selectors
|
|
24
|
+
export const CONTENT_SELECTORS = [
|
|
25
|
+
'main',
|
|
26
|
+
'article',
|
|
27
|
+
'[role="main"]',
|
|
28
|
+
'.StudioCanvas', // studio.site specific
|
|
29
|
+
'.sd.appear', // studio.site appeared elements
|
|
30
|
+
'header nav a' // typical nav links
|
|
31
|
+
];
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* Wait for fonts to finish loading
|
|
35
|
+
* @param {Page} page - Puppeteer page
|
|
36
|
+
* @param {number} timeout - Max wait time in ms
|
|
37
|
+
*/
|
|
38
|
+
export async function waitForFontsLoaded(page, timeout = FONT_LOAD_TIMEOUT) {
|
|
39
|
+
try {
|
|
40
|
+
await page.evaluate(async (timeoutMs) => {
|
|
41
|
+
if (!document.fonts) return;
|
|
42
|
+
await Promise.race([
|
|
43
|
+
document.fonts.ready,
|
|
44
|
+
new Promise(resolve => setTimeout(resolve, timeoutMs))
|
|
45
|
+
]);
|
|
46
|
+
}, timeout);
|
|
47
|
+
} catch {
|
|
48
|
+
// Font API not available or error, continue anyway
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* Wait for styles to stabilize (no new style mutations)
|
|
54
|
+
* @param {Page} page - Puppeteer page
|
|
55
|
+
* @param {number} stableMs - How long to wait without changes
|
|
56
|
+
* @param {number} timeout - Max wait time in ms
|
|
57
|
+
*/
|
|
58
|
+
export async function waitForStylesStable(page, stableMs = 500, timeout = 5000) {
|
|
59
|
+
try {
|
|
60
|
+
await page.evaluate(async (stable, max) => {
|
|
61
|
+
return new Promise((resolve) => {
|
|
62
|
+
let lastChange = Date.now();
|
|
63
|
+
let checkInterval;
|
|
64
|
+
|
|
65
|
+
const observer = new MutationObserver((mutations) => {
|
|
66
|
+
for (const m of mutations) {
|
|
67
|
+
if (m.target.tagName === 'STYLE' ||
|
|
68
|
+
m.target.tagName === 'LINK' ||
|
|
69
|
+
m.addedNodes.length > 0) {
|
|
70
|
+
lastChange = Date.now();
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
observer.observe(document.head, {
|
|
76
|
+
childList: true,
|
|
77
|
+
subtree: true,
|
|
78
|
+
characterData: true
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
const startTime = Date.now();
|
|
82
|
+
checkInterval = setInterval(() => {
|
|
83
|
+
const now = Date.now();
|
|
84
|
+
if (now - lastChange >= stable || now - startTime >= max) {
|
|
85
|
+
clearInterval(checkInterval);
|
|
86
|
+
observer.disconnect();
|
|
87
|
+
resolve();
|
|
88
|
+
}
|
|
89
|
+
}, 100);
|
|
90
|
+
});
|
|
91
|
+
}, stableMs, timeout);
|
|
92
|
+
} catch {
|
|
93
|
+
// Error in style stability check, continue anyway
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
/**
|
|
98
|
+
* Wait for DOM to stabilize (element count unchanged)
|
|
99
|
+
* @param {Page} page - Puppeteer page
|
|
100
|
+
* @param {number} threshold - Stability duration in ms
|
|
101
|
+
* @param {number} timeout - Max wait time in ms
|
|
102
|
+
*/
|
|
103
|
+
export async function waitForDomStable(page, threshold = DOM_STABLE_THRESHOLD, timeout = 10000) {
|
|
104
|
+
let lastCount = 0;
|
|
105
|
+
let stableTime = 0;
|
|
106
|
+
const checkInterval = 100;
|
|
107
|
+
const startTime = Date.now();
|
|
108
|
+
|
|
109
|
+
while (stableTime < threshold && (Date.now() - startTime) < timeout) {
|
|
110
|
+
const count = await page.evaluate(() => document.querySelectorAll('*').length);
|
|
111
|
+
stableTime = (count === lastCount) ? stableTime + checkInterval : 0;
|
|
112
|
+
lastCount = count;
|
|
113
|
+
await new Promise(r => setTimeout(r, checkInterval));
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
/**
|
|
118
|
+
* Wait for page to be ready (loading complete, content visible)
|
|
119
|
+
* @param {Page} page - Puppeteer page
|
|
120
|
+
* @param {number} timeout - Max wait time
|
|
121
|
+
*/
|
|
122
|
+
export async function waitForPageReady(page, timeout = PAGE_READY_TIMEOUT) {
|
|
123
|
+
const startTime = Date.now();
|
|
124
|
+
const initialCount = await page.evaluate(() => document.querySelectorAll('*').length);
|
|
125
|
+
const minContentElements = Math.max(100, initialCount * 2);
|
|
126
|
+
|
|
127
|
+
while (Date.now() - startTime < timeout) {
|
|
128
|
+
const result = await page.evaluate((loadingSels, contentSels, minElements) => {
|
|
129
|
+
const elementCount = document.querySelectorAll('*').length;
|
|
130
|
+
|
|
131
|
+
const loadingGone = loadingSels.every(sel => {
|
|
132
|
+
const el = document.querySelector(sel);
|
|
133
|
+
if (!el) return true;
|
|
134
|
+
const style = getComputedStyle(el);
|
|
135
|
+
return style.display === 'none' || style.visibility === 'hidden' || style.opacity === '0';
|
|
136
|
+
});
|
|
137
|
+
|
|
138
|
+
const contentExists = contentSels.some(sel => {
|
|
139
|
+
const el = document.querySelector(sel);
|
|
140
|
+
return el && getComputedStyle(el).display !== 'none';
|
|
141
|
+
});
|
|
142
|
+
|
|
143
|
+
const hasEnoughElements = elementCount >= minElements;
|
|
144
|
+
|
|
145
|
+
return {
|
|
146
|
+
ready: (loadingGone && hasEnoughElements) || contentExists || hasEnoughElements,
|
|
147
|
+
elementCount,
|
|
148
|
+
loadingGone,
|
|
149
|
+
contentExists
|
|
150
|
+
};
|
|
151
|
+
}, LOADING_SELECTORS, CONTENT_SELECTORS, minContentElements);
|
|
152
|
+
|
|
153
|
+
if (result.ready) break;
|
|
154
|
+
await new Promise(r => setTimeout(r, 200));
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
await waitForDomStable(page, 300);
|
|
158
|
+
await waitForFontsLoaded(page);
|
|
159
|
+
await waitForStylesStable(page, 300, 3000);
|
|
160
|
+
await new Promise(r => setTimeout(r, POST_READY_DELAY));
|
|
161
|
+
}
|