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
|
@@ -16,7 +16,7 @@ import { getBrowser, getPage, disconnectBrowser } from '../utils/browser.js';
|
|
|
16
16
|
import { captureViewport, VIEWPORTS, DEFAULT_SCROLL_DELAY } from './screenshot.js';
|
|
17
17
|
import { waitForDomStable, waitForPageReady } from './page-readiness.js';
|
|
18
18
|
import { dismissCookieBanner } from './cookie-handler.js';
|
|
19
|
-
import {
|
|
19
|
+
import { extractAndEnhanceHtml, JS_FRAMEWORK_PATTERNS, MAX_HTML_SIZE } from './html-extractor.js';
|
|
20
20
|
import { extractAllCss, MAX_CSS_SIZE } from './css-extractor.js';
|
|
21
21
|
import { filterCssFile } from './filter-css.js';
|
|
22
22
|
|
|
@@ -69,7 +69,7 @@ async function createOutputStructure(outputDir, viewports) {
|
|
|
69
69
|
|
|
70
70
|
/**
|
|
71
71
|
* Capture a single page (all viewports + HTML/CSS extraction)
|
|
72
|
-
* @param {Page} page -
|
|
72
|
+
* @param {Page} page - Playwright page instance
|
|
73
73
|
* @param {Object} pageInfo - Page info { path, name, url }
|
|
74
74
|
* @param {string} outputDir - Output directory
|
|
75
75
|
* @param {Object} options - Capture options
|
|
@@ -91,7 +91,7 @@ async function captureSinglePage(page, pageInfo, outputDir, options) {
|
|
|
91
91
|
try {
|
|
92
92
|
// Navigate to page
|
|
93
93
|
await page.goto(pageInfo.url, {
|
|
94
|
-
waitUntil:
|
|
94
|
+
waitUntil: 'networkidle',
|
|
95
95
|
timeout: options.timeout
|
|
96
96
|
});
|
|
97
97
|
|
|
@@ -104,10 +104,11 @@ async function captureSinglePage(page, pageInfo, outputDir, options) {
|
|
|
104
104
|
// Extra stabilization
|
|
105
105
|
await waitForDomStable(page, 300, 3000);
|
|
106
106
|
|
|
107
|
-
// Extract HTML
|
|
107
|
+
// Extract HTML with semantic enhancement
|
|
108
108
|
if (options.extractHtml) {
|
|
109
109
|
try {
|
|
110
|
-
const
|
|
110
|
+
const enhanceSemantic = options.enhanceSemantic !== false;
|
|
111
|
+
const htmlResult = await extractAndEnhanceHtml(page, { enhanceSemantic });
|
|
111
112
|
const htmlSize = Buffer.byteLength(htmlResult.html, 'utf-8');
|
|
112
113
|
|
|
113
114
|
if (htmlSize > MAX_HTML_SIZE) {
|
|
@@ -118,7 +119,9 @@ async function captureSinglePage(page, pageInfo, outputDir, options) {
|
|
|
118
119
|
result.html = {
|
|
119
120
|
path: htmlPath,
|
|
120
121
|
size: htmlSize,
|
|
121
|
-
elementCount: htmlResult.elementCount
|
|
122
|
+
elementCount: htmlResult.elementCount,
|
|
123
|
+
semanticEnhanced: enhanceSemantic,
|
|
124
|
+
semanticStats: htmlResult.semanticStats || null
|
|
122
125
|
};
|
|
123
126
|
if (htmlResult.warnings.length > 0) {
|
|
124
127
|
result.warnings.push(...htmlResult.warnings);
|
|
@@ -32,7 +32,7 @@ export const CONTENT_SELECTORS = [
|
|
|
32
32
|
|
|
33
33
|
/**
|
|
34
34
|
* Wait for fonts to finish loading
|
|
35
|
-
* @param {Page} page -
|
|
35
|
+
* @param {Page} page - Playwright page
|
|
36
36
|
* @param {number} timeout - Max wait time in ms
|
|
37
37
|
*/
|
|
38
38
|
export async function waitForFontsLoaded(page, timeout = FONT_LOAD_TIMEOUT) {
|
|
@@ -51,13 +51,13 @@ export async function waitForFontsLoaded(page, timeout = FONT_LOAD_TIMEOUT) {
|
|
|
51
51
|
|
|
52
52
|
/**
|
|
53
53
|
* Wait for styles to stabilize (no new style mutations)
|
|
54
|
-
* @param {Page} page -
|
|
54
|
+
* @param {Page} page - Playwright page
|
|
55
55
|
* @param {number} stableMs - How long to wait without changes
|
|
56
56
|
* @param {number} timeout - Max wait time in ms
|
|
57
57
|
*/
|
|
58
58
|
export async function waitForStylesStable(page, stableMs = 500, timeout = 5000) {
|
|
59
59
|
try {
|
|
60
|
-
await page.evaluate(async (stable, max) => {
|
|
60
|
+
await page.evaluate(async ({ stable, max }) => {
|
|
61
61
|
return new Promise((resolve) => {
|
|
62
62
|
let lastChange = Date.now();
|
|
63
63
|
let checkInterval;
|
|
@@ -88,7 +88,7 @@ export async function waitForStylesStable(page, stableMs = 500, timeout = 5000)
|
|
|
88
88
|
}
|
|
89
89
|
}, 100);
|
|
90
90
|
});
|
|
91
|
-
}, stableMs, timeout);
|
|
91
|
+
}, { stable: stableMs, max: timeout });
|
|
92
92
|
} catch {
|
|
93
93
|
// Error in style stability check, continue anyway
|
|
94
94
|
}
|
|
@@ -96,7 +96,7 @@ export async function waitForStylesStable(page, stableMs = 500, timeout = 5000)
|
|
|
96
96
|
|
|
97
97
|
/**
|
|
98
98
|
* Wait for DOM to stabilize (element count unchanged)
|
|
99
|
-
* @param {Page} page -
|
|
99
|
+
* @param {Page} page - Playwright page
|
|
100
100
|
* @param {number} threshold - Stability duration in ms
|
|
101
101
|
* @param {number} timeout - Max wait time in ms
|
|
102
102
|
*/
|
|
@@ -116,7 +116,7 @@ export async function waitForDomStable(page, threshold = DOM_STABLE_THRESHOLD, t
|
|
|
116
116
|
|
|
117
117
|
/**
|
|
118
118
|
* Wait for page to be ready (loading complete, content visible)
|
|
119
|
-
* @param {Page} page -
|
|
119
|
+
* @param {Page} page - Playwright page
|
|
120
120
|
* @param {number} timeout - Max wait time
|
|
121
121
|
*/
|
|
122
122
|
export async function waitForPageReady(page, timeout = PAGE_READY_TIMEOUT) {
|
|
@@ -125,7 +125,7 @@ export async function waitForPageReady(page, timeout = PAGE_READY_TIMEOUT) {
|
|
|
125
125
|
const minContentElements = Math.max(100, initialCount * 2);
|
|
126
126
|
|
|
127
127
|
while (Date.now() - startTime < timeout) {
|
|
128
|
-
const result = await page.evaluate((loadingSels, contentSels, minElements) => {
|
|
128
|
+
const result = await page.evaluate(({ loadingSels, contentSels, minElements }) => {
|
|
129
129
|
const elementCount = document.querySelectorAll('*').length;
|
|
130
130
|
|
|
131
131
|
const loadingGone = loadingSels.every(sel => {
|
|
@@ -148,7 +148,7 @@ export async function waitForPageReady(page, timeout = PAGE_READY_TIMEOUT) {
|
|
|
148
148
|
loadingGone,
|
|
149
149
|
contentExists
|
|
150
150
|
};
|
|
151
|
-
}, LOADING_SELECTORS, CONTENT_SELECTORS, minContentElements);
|
|
151
|
+
}, { loadingSels: LOADING_SELECTORS, contentSels: CONTENT_SELECTORS, minElements: minContentElements });
|
|
152
152
|
|
|
153
153
|
if (result.ready) break;
|
|
154
154
|
await new Promise(r => setTimeout(r, 200));
|
package/src/core/screenshot.js
CHANGED
|
@@ -21,6 +21,8 @@
|
|
|
21
21
|
* --video Record scroll preview video (default: false)
|
|
22
22
|
* --video-format Video format: webm, mp4, gif (default: webm)
|
|
23
23
|
* --video-duration Video duration in ms (default: 12000)
|
|
24
|
+
* --section-mode Enable section-based capture for AI analysis (default: false)
|
|
25
|
+
* --no-semantic Disable WordPress semantic HTML enhancement (default: false)
|
|
24
26
|
*/
|
|
25
27
|
|
|
26
28
|
import path from 'path';
|
|
@@ -34,10 +36,12 @@ import { getBrowser, getPage, closeBrowser, disconnectBrowser, parseArgs, output
|
|
|
34
36
|
import { waitForDomStable, waitForFontsLoaded, waitForStylesStable, waitForPageReady } from './page-readiness.js';
|
|
35
37
|
import { dismissCookieBanner } from './cookie-handler.js';
|
|
36
38
|
import { forceLazyImages, forceAnimatedElementsVisible, triggerLazyLoad, waitForAllImages, LAZY_LOAD_MAX_ITERATIONS } from './lazy-loader.js';
|
|
37
|
-
import { extractCleanHtml, JS_FRAMEWORK_PATTERNS, MAX_HTML_SIZE } from './html-extractor.js';
|
|
39
|
+
import { extractCleanHtml, extractAndEnhanceHtml, JS_FRAMEWORK_PATTERNS, MAX_HTML_SIZE } from './html-extractor.js';
|
|
40
|
+
import { extractContentCounts, generateContentSummary } from './content-counter.js';
|
|
38
41
|
import { extractAllCss, MAX_CSS_SIZE } from './css-extractor.js';
|
|
39
42
|
import { extractComponentDimensions } from './dimension-extractor.js';
|
|
40
43
|
import { buildDimensionsOutput, generateAISummary } from './dimension-output.js';
|
|
44
|
+
import { extractDOMHierarchy } from './dom-tree-analyzer.js';
|
|
41
45
|
import { extractAnimations, generateAnimationsCss, generateAnimationTokens } from './animation-extractor.js';
|
|
42
46
|
import { captureAllHoverStates, generateHoverCss } from './state-capture.js';
|
|
43
47
|
import { captureVideo, hasFfmpeg, FFMPEG_REQUIRED_FORMATS } from './video-capture.js';
|
|
@@ -102,7 +106,7 @@ async function compressIfNeeded(filePath, maxSizeMB = 5) {
|
|
|
102
106
|
* Capture screenshot for a single viewport
|
|
103
107
|
*/
|
|
104
108
|
async function captureViewport(page, viewport, outputPath, fullPage = true, maxSize = 5, scrollDelay = DEFAULT_SCROLL_DELAY) {
|
|
105
|
-
await page.
|
|
109
|
+
await page.setViewportSize(VIEWPORTS[viewport]);
|
|
106
110
|
await new Promise(r => setTimeout(r, VIEWPORT_SETTLE_DELAY));
|
|
107
111
|
await waitForDomStable(page, 300, 5000);
|
|
108
112
|
await waitForFontsLoaded(page, 3000);
|
|
@@ -110,13 +114,23 @@ async function captureViewport(page, viewport, outputPath, fullPage = true, maxS
|
|
|
110
114
|
|
|
111
115
|
const componentDimensions = await extractComponentDimensions(page, viewport);
|
|
112
116
|
|
|
117
|
+
// Extract DOM hierarchy (desktop only for efficiency)
|
|
118
|
+
let domHierarchy = null;
|
|
119
|
+
if (viewport === 'desktop') {
|
|
120
|
+
try {
|
|
121
|
+
domHierarchy = await extractDOMHierarchy(page, { maxDepth: 8 });
|
|
122
|
+
} catch (err) {
|
|
123
|
+
console.error(`[WARN] DOM hierarchy extraction failed: ${err.message}`);
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
|
|
113
127
|
const lazyStats = await forceLazyImages(page);
|
|
114
128
|
const scrollInfo = await triggerLazyLoad(page, LAZY_LOAD_MAX_ITERATIONS, scrollDelay);
|
|
115
129
|
await forceLazyImages(page);
|
|
116
130
|
const imageStats = await waitForAllImages(page, 15000);
|
|
117
131
|
|
|
118
132
|
try {
|
|
119
|
-
await page.
|
|
133
|
+
await page.waitForLoadState('networkidle', { timeout: NETWORK_IDLE_TIMEOUT });
|
|
120
134
|
} catch {
|
|
121
135
|
// Timeout ok
|
|
122
136
|
}
|
|
@@ -142,6 +156,7 @@ async function captureViewport(page, viewport, outputPath, fullPage = true, maxS
|
|
|
142
156
|
path: path.resolve(outputPath),
|
|
143
157
|
dimensions: VIEWPORTS[viewport],
|
|
144
158
|
componentDimensions,
|
|
159
|
+
domHierarchy, // DOM hierarchy (desktop only)
|
|
145
160
|
scrollInfo,
|
|
146
161
|
imageStats,
|
|
147
162
|
size: compression.finalSize,
|
|
@@ -179,6 +194,7 @@ async function captureMultiViewport() {
|
|
|
179
194
|
const videoDuration = args['video-duration']
|
|
180
195
|
? parseInt(args['video-duration'], 10)
|
|
181
196
|
: 12000;
|
|
197
|
+
const sectionMode = args['section-mode'] === 'true';
|
|
182
198
|
|
|
183
199
|
for (const vp of requestedViewports) {
|
|
184
200
|
if (!VIEWPORTS[vp]) {
|
|
@@ -214,8 +230,8 @@ async function captureMultiViewport() {
|
|
|
214
230
|
currentHeadless = headless;
|
|
215
231
|
|
|
216
232
|
if (navigateUrl) {
|
|
217
|
-
await page.
|
|
218
|
-
await page.goto(navigateUrl, { waitUntil:
|
|
233
|
+
await page.setViewportSize(VIEWPORTS.desktop);
|
|
234
|
+
await page.goto(navigateUrl, { waitUntil: 'domcontentloaded', timeout: 90000 });
|
|
219
235
|
await new Promise(r => setTimeout(r, 3000));
|
|
220
236
|
cookieResult = await dismissCookieBanner(page);
|
|
221
237
|
await waitForPageReady(page);
|
|
@@ -234,9 +250,40 @@ async function captureMultiViewport() {
|
|
|
234
250
|
if (extractHtml || extractCss) {
|
|
235
251
|
extraction = { html: null, css: null, warnings: [] };
|
|
236
252
|
|
|
253
|
+
// Extract content counts BEFORE cleaning HTML (to count hidden items too)
|
|
237
254
|
if (extractHtml) {
|
|
238
255
|
try {
|
|
239
|
-
const
|
|
256
|
+
const contentCounts = await extractContentCounts(page);
|
|
257
|
+
const countsPath = path.join(args.output, 'content-counts.json');
|
|
258
|
+
await fs.writeFile(countsPath, JSON.stringify(contentCounts, null, 2), 'utf-8');
|
|
259
|
+
|
|
260
|
+
// Generate summary for structure analysis
|
|
261
|
+
const contentSummary = generateContentSummary(contentCounts);
|
|
262
|
+
const summaryPath = path.join(args.output, 'content-summary.md');
|
|
263
|
+
await fs.writeFile(summaryPath, contentSummary, 'utf-8');
|
|
264
|
+
|
|
265
|
+
extraction.contentCounts = {
|
|
266
|
+
path: path.resolve(countsPath),
|
|
267
|
+
summaryPath: path.resolve(summaryPath),
|
|
268
|
+
summary: contentCounts.summary
|
|
269
|
+
};
|
|
270
|
+
|
|
271
|
+
if (process.stderr.isTTY) {
|
|
272
|
+
console.error(`[INFO] Content counts: ${contentCounts.grids.total} grids, ${contentCounts.repeatedItems.total} items`);
|
|
273
|
+
}
|
|
274
|
+
} catch (error) {
|
|
275
|
+
extractionWarnings.push(`Content counting failed: ${error.message}`);
|
|
276
|
+
}
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
if (extractHtml) {
|
|
280
|
+
try {
|
|
281
|
+
// Use semantic enhancement unless --no-semantic flag is set
|
|
282
|
+
const enhanceSemantic = args['no-semantic'] !== 'true';
|
|
283
|
+
const htmlResult = enhanceSemantic
|
|
284
|
+
? await extractAndEnhanceHtml(page, { enhanceSemantic: true })
|
|
285
|
+
: await extractCleanHtml(page, JS_FRAMEWORK_PATTERNS);
|
|
286
|
+
|
|
240
287
|
const html = htmlResult.html;
|
|
241
288
|
const htmlSize = Buffer.byteLength(html, 'utf-8');
|
|
242
289
|
|
|
@@ -246,7 +293,13 @@ async function captureMultiViewport() {
|
|
|
246
293
|
|
|
247
294
|
const htmlPath = path.join(args.output, 'source.html');
|
|
248
295
|
await fs.writeFile(htmlPath, html, 'utf-8');
|
|
249
|
-
extraction.html = {
|
|
296
|
+
extraction.html = {
|
|
297
|
+
path: path.resolve(htmlPath),
|
|
298
|
+
size: htmlSize,
|
|
299
|
+
elementCount: htmlResult.elementCount,
|
|
300
|
+
semanticEnhanced: enhanceSemantic,
|
|
301
|
+
semanticStats: htmlResult.semanticStats || null
|
|
302
|
+
};
|
|
250
303
|
if (htmlResult.warnings.length > 0) extractionWarnings.push(...htmlResult.warnings);
|
|
251
304
|
} catch (error) {
|
|
252
305
|
extraction.html = { error: error.message, failed: true };
|
|
@@ -360,7 +413,7 @@ async function captureMultiViewport() {
|
|
|
360
413
|
}
|
|
361
414
|
}
|
|
362
415
|
|
|
363
|
-
// Capture hover states (requires headless mode per
|
|
416
|
+
// Capture hover states (requires headless mode per Playwright #5255)
|
|
364
417
|
let hoverResult = null;
|
|
365
418
|
if (captureHover) {
|
|
366
419
|
try {
|
|
@@ -449,7 +502,7 @@ async function captureMultiViewport() {
|
|
|
449
502
|
}
|
|
450
503
|
|
|
451
504
|
// Use desktop viewport for video
|
|
452
|
-
await page.
|
|
505
|
+
await page.setViewportSize(VIEWPORTS.desktop);
|
|
453
506
|
await new Promise(r => setTimeout(r, 1000));
|
|
454
507
|
|
|
455
508
|
if (process.stderr.isTTY) {
|
|
@@ -490,6 +543,77 @@ async function captureMultiViewport() {
|
|
|
490
543
|
const summaryPath = path.join(args.output, 'dimensions-summary.json');
|
|
491
544
|
await fs.writeFile(summaryPath, JSON.stringify(aiSummary, null, 2));
|
|
492
545
|
|
|
546
|
+
// Write DOM hierarchy if available (from desktop capture)
|
|
547
|
+
const desktopScreenshot = screenshots.find(s => s.viewport === 'desktop');
|
|
548
|
+
let hierarchyPath = null;
|
|
549
|
+
if (desktopScreenshot?.domHierarchy) {
|
|
550
|
+
hierarchyPath = path.join(args.output, 'dom-hierarchy.json');
|
|
551
|
+
await fs.writeFile(hierarchyPath, JSON.stringify(desktopScreenshot.domHierarchy, null, 2));
|
|
552
|
+
|
|
553
|
+
if (process.stderr.isTTY) {
|
|
554
|
+
const stats = desktopScreenshot.domHierarchy.stats || {};
|
|
555
|
+
console.error(`[INFO] DOM hierarchy: ${stats.totalNodes || 0} nodes, ${stats.landmarkCount || 0} landmarks, ${stats.extractionTimeMs || 0}ms`);
|
|
556
|
+
}
|
|
557
|
+
}
|
|
558
|
+
|
|
559
|
+
// Section-based capture (for improved AI analysis)
|
|
560
|
+
let sectionResult = null;
|
|
561
|
+
if (sectionMode && desktopScreenshot) {
|
|
562
|
+
try {
|
|
563
|
+
// Lazy import section modules
|
|
564
|
+
const { detectSections } = await import('./section-detector.js');
|
|
565
|
+
const { cropSections } = await import('./section-cropper.js');
|
|
566
|
+
|
|
567
|
+
// Reset to desktop viewport for section detection
|
|
568
|
+
await page.setViewportSize(VIEWPORTS.desktop);
|
|
569
|
+
await new Promise(r => setTimeout(r, 500));
|
|
570
|
+
|
|
571
|
+
if (process.stderr.isTTY) {
|
|
572
|
+
console.error('[INFO] Detecting sections...');
|
|
573
|
+
}
|
|
574
|
+
|
|
575
|
+
const sections = await detectSections(page, {
|
|
576
|
+
padding: 40,
|
|
577
|
+
minSections: 3,
|
|
578
|
+
fallbackToViewport: true
|
|
579
|
+
});
|
|
580
|
+
|
|
581
|
+
if (process.stderr.isTTY) {
|
|
582
|
+
console.error(`[INFO] Found ${sections.length} sections, cropping...`);
|
|
583
|
+
}
|
|
584
|
+
|
|
585
|
+
const croppedResult = await cropSections(
|
|
586
|
+
desktopScreenshot.path,
|
|
587
|
+
sections,
|
|
588
|
+
args.output
|
|
589
|
+
);
|
|
590
|
+
|
|
591
|
+
sectionResult = {
|
|
592
|
+
enabled: true,
|
|
593
|
+
count: croppedResult.sections.length,
|
|
594
|
+
skipped: croppedResult.skipped.length,
|
|
595
|
+
sections: croppedResult.sections.map(s => ({
|
|
596
|
+
index: s.index,
|
|
597
|
+
name: s.name,
|
|
598
|
+
path: s.relativePath,
|
|
599
|
+
bounds: s.bounds,
|
|
600
|
+
role: s.role
|
|
601
|
+
})),
|
|
602
|
+
directory: croppedResult.directory,
|
|
603
|
+
summary: croppedResult.summary
|
|
604
|
+
};
|
|
605
|
+
|
|
606
|
+
if (process.stderr.isTTY) {
|
|
607
|
+
console.error(`[INFO] Sections: ${croppedResult.sections.length} cropped, ${croppedResult.skipped.length} skipped`);
|
|
608
|
+
}
|
|
609
|
+
} catch (err) {
|
|
610
|
+
sectionResult = { enabled: true, error: err.message };
|
|
611
|
+
if (process.stderr.isTTY) {
|
|
612
|
+
console.error(`[WARN] Section processing failed: ${err.message}`);
|
|
613
|
+
}
|
|
614
|
+
}
|
|
615
|
+
}
|
|
616
|
+
|
|
493
617
|
const totalContainers = Object.values(dimensionsOutput.viewports).reduce((sum, vp) => sum + (vp.containers?.length || 0), 0);
|
|
494
618
|
const totalCards = Object.values(dimensionsOutput.viewports).reduce((sum, vp) => sum + (vp.cards?.length || 0), 0);
|
|
495
619
|
const totalGrids = Object.values(dimensionsOutput.viewports).reduce((sum, vp) => sum + (vp.gridLayouts?.length || 0), 0);
|
|
@@ -528,6 +652,11 @@ async function captureMultiViewport() {
|
|
|
528
652
|
stats: { containers: totalContainers, cards: totalCards, gridLayouts: totalGrids,
|
|
529
653
|
typography: Object.values(dimensionsOutput.viewports).reduce((sum, vp) => sum + (vp.typography?.length || 0), 0) }
|
|
530
654
|
},
|
|
655
|
+
domHierarchy: desktopScreenshot?.domHierarchy ? {
|
|
656
|
+
path: path.resolve(hierarchyPath),
|
|
657
|
+
stats: desktopScreenshot.domHierarchy.stats
|
|
658
|
+
} : undefined,
|
|
659
|
+
sections: sectionResult,
|
|
531
660
|
screenshots,
|
|
532
661
|
browserRestarts: browserRestarts.length > 0 ? browserRestarts : undefined,
|
|
533
662
|
scrollDelay,
|
|
@@ -0,0 +1,209 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Section Cropper
|
|
3
|
+
*
|
|
4
|
+
* Crop full-page screenshot into individual section images using Sharp.
|
|
5
|
+
* Uses section bounds from section-detector.js.
|
|
6
|
+
*
|
|
7
|
+
* Usage:
|
|
8
|
+
* import { cropSections } from './section-cropper.js';
|
|
9
|
+
* const results = await cropSections(screenshotPath, sections, outputDir);
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
import path from 'path';
|
|
13
|
+
import fs from 'fs/promises';
|
|
14
|
+
|
|
15
|
+
// Try to import Sharp
|
|
16
|
+
let sharp = null;
|
|
17
|
+
try {
|
|
18
|
+
sharp = (await import('sharp')).default;
|
|
19
|
+
} catch {
|
|
20
|
+
// Sharp not available
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
// Default configuration
|
|
24
|
+
const DEFAULT_OPTIONS = {
|
|
25
|
+
minHeight: 100, // Skip sections smaller than this
|
|
26
|
+
quality: 90, // PNG quality
|
|
27
|
+
compressionLevel: 6, // PNG compression (0-9)
|
|
28
|
+
format: 'png' // Output format
|
|
29
|
+
};
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* Crop sections from a full-page screenshot
|
|
33
|
+
* @param {string} screenshotPath - Path to full screenshot
|
|
34
|
+
* @param {Array} sections - Array of section objects with bounds
|
|
35
|
+
* @param {string} outputDir - Base output directory
|
|
36
|
+
* @param {Object} options - Configuration options
|
|
37
|
+
* @returns {Promise<Array>} Array of cropped section info
|
|
38
|
+
*/
|
|
39
|
+
export async function cropSections(screenshotPath, sections, outputDir, options = {}) {
|
|
40
|
+
const config = { ...DEFAULT_OPTIONS, ...options };
|
|
41
|
+
|
|
42
|
+
// Check Sharp availability
|
|
43
|
+
if (!sharp) {
|
|
44
|
+
throw new Error('Sharp is not installed. Run: npm install sharp');
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
// Create sections directory
|
|
48
|
+
const sectionsDir = path.join(outputDir, 'sections');
|
|
49
|
+
await fs.mkdir(sectionsDir, { recursive: true });
|
|
50
|
+
|
|
51
|
+
// Get source image metadata
|
|
52
|
+
const metadata = await sharp(screenshotPath).metadata();
|
|
53
|
+
const imageWidth = metadata.width;
|
|
54
|
+
const imageHeight = metadata.height;
|
|
55
|
+
|
|
56
|
+
const results = [];
|
|
57
|
+
const skipped = [];
|
|
58
|
+
|
|
59
|
+
for (const section of sections) {
|
|
60
|
+
// Validate and clamp bounds
|
|
61
|
+
const bounds = validateBounds(section.bounds, imageWidth, imageHeight);
|
|
62
|
+
|
|
63
|
+
// Skip tiny sections
|
|
64
|
+
if (bounds.height < config.minHeight) {
|
|
65
|
+
skipped.push({
|
|
66
|
+
index: section.index,
|
|
67
|
+
name: section.name,
|
|
68
|
+
reason: `Height ${bounds.height}px < ${config.minHeight}px minimum`
|
|
69
|
+
});
|
|
70
|
+
continue;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
// Skip zero-dimension sections
|
|
74
|
+
if (bounds.width <= 0 || bounds.height <= 0) {
|
|
75
|
+
skipped.push({
|
|
76
|
+
index: section.index,
|
|
77
|
+
name: section.name,
|
|
78
|
+
reason: 'Zero or negative dimensions'
|
|
79
|
+
});
|
|
80
|
+
continue;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
// Generate output filename
|
|
84
|
+
const safeName = sanitizeName(section.name);
|
|
85
|
+
const filename = `section-${section.index}-${safeName}.png`;
|
|
86
|
+
const outputPath = path.join(sectionsDir, filename);
|
|
87
|
+
|
|
88
|
+
try {
|
|
89
|
+
// Crop and save
|
|
90
|
+
await sharp(screenshotPath)
|
|
91
|
+
.extract({
|
|
92
|
+
left: bounds.left,
|
|
93
|
+
top: bounds.top,
|
|
94
|
+
width: bounds.width,
|
|
95
|
+
height: bounds.height
|
|
96
|
+
})
|
|
97
|
+
.png({
|
|
98
|
+
quality: config.quality,
|
|
99
|
+
compressionLevel: config.compressionLevel
|
|
100
|
+
})
|
|
101
|
+
.toFile(outputPath);
|
|
102
|
+
|
|
103
|
+
results.push({
|
|
104
|
+
index: section.index,
|
|
105
|
+
name: section.name,
|
|
106
|
+
filename,
|
|
107
|
+
path: outputPath,
|
|
108
|
+
relativePath: path.join('sections', filename),
|
|
109
|
+
bounds: {
|
|
110
|
+
x: bounds.left,
|
|
111
|
+
y: bounds.top,
|
|
112
|
+
width: bounds.width,
|
|
113
|
+
height: bounds.height
|
|
114
|
+
},
|
|
115
|
+
role: section.role || 'unknown',
|
|
116
|
+
selector: section.selector || null
|
|
117
|
+
});
|
|
118
|
+
} catch (err) {
|
|
119
|
+
skipped.push({
|
|
120
|
+
index: section.index,
|
|
121
|
+
name: section.name,
|
|
122
|
+
reason: `Crop error: ${err.message}`
|
|
123
|
+
});
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
// Write summary JSON
|
|
128
|
+
const summary = {
|
|
129
|
+
source: path.basename(screenshotPath),
|
|
130
|
+
sourceWidth: imageWidth,
|
|
131
|
+
sourceHeight: imageHeight,
|
|
132
|
+
sectionsCount: results.length,
|
|
133
|
+
skippedCount: skipped.length,
|
|
134
|
+
sections: results,
|
|
135
|
+
skipped: skipped.length > 0 ? skipped : undefined,
|
|
136
|
+
createdAt: new Date().toISOString()
|
|
137
|
+
};
|
|
138
|
+
|
|
139
|
+
const summaryPath = path.join(sectionsDir, 'sections.json');
|
|
140
|
+
await fs.writeFile(summaryPath, JSON.stringify(summary, null, 2));
|
|
141
|
+
|
|
142
|
+
return {
|
|
143
|
+
sections: results,
|
|
144
|
+
skipped,
|
|
145
|
+
summary: summaryPath,
|
|
146
|
+
directory: sectionsDir
|
|
147
|
+
};
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
/**
|
|
151
|
+
* Validate and clamp bounds to image dimensions
|
|
152
|
+
* @param {Object} bounds - Section bounds {x, y, width, height}
|
|
153
|
+
* @param {number} imageWidth - Source image width
|
|
154
|
+
* @param {number} imageHeight - Source image height
|
|
155
|
+
* @returns {Object} Validated bounds {left, top, width, height}
|
|
156
|
+
*/
|
|
157
|
+
function validateBounds(bounds, imageWidth, imageHeight) {
|
|
158
|
+
// Clamp starting position
|
|
159
|
+
const left = Math.max(0, Math.round(bounds.x));
|
|
160
|
+
const top = Math.max(0, Math.round(bounds.y));
|
|
161
|
+
|
|
162
|
+
// Calculate max possible dimensions
|
|
163
|
+
const maxWidth = imageWidth - left;
|
|
164
|
+
const maxHeight = imageHeight - top;
|
|
165
|
+
|
|
166
|
+
// Clamp dimensions
|
|
167
|
+
const width = Math.min(Math.round(bounds.width), maxWidth);
|
|
168
|
+
const height = Math.min(Math.round(bounds.height), maxHeight);
|
|
169
|
+
|
|
170
|
+
return { left, top, width, height };
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
/**
|
|
174
|
+
* Sanitize section name for filename
|
|
175
|
+
* @param {string} name - Section name
|
|
176
|
+
* @returns {string} Safe filename
|
|
177
|
+
*/
|
|
178
|
+
function sanitizeName(name) {
|
|
179
|
+
return name
|
|
180
|
+
.toLowerCase()
|
|
181
|
+
.replace(/[^a-z0-9-]/g, '-')
|
|
182
|
+
.replace(/-+/g, '-')
|
|
183
|
+
.replace(/^-|-$/g, '')
|
|
184
|
+
.substring(0, 50) || 'unnamed';
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
/**
|
|
188
|
+
* Check if Sharp is available
|
|
189
|
+
* @returns {boolean}
|
|
190
|
+
*/
|
|
191
|
+
export function isSharpAvailable() {
|
|
192
|
+
return sharp !== null;
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
/**
|
|
196
|
+
* Get cropper summary for logging
|
|
197
|
+
* @param {Object} result - Result from cropSections
|
|
198
|
+
* @returns {Object} Summary object
|
|
199
|
+
*/
|
|
200
|
+
export function getCropperSummary(result) {
|
|
201
|
+
return {
|
|
202
|
+
cropped: result.sections.length,
|
|
203
|
+
skipped: result.skipped.length,
|
|
204
|
+
directory: result.directory,
|
|
205
|
+
totalSize: result.sections.reduce((sum, s) => sum + (s.bounds.width * s.bounds.height), 0)
|
|
206
|
+
};
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
export { DEFAULT_OPTIONS };
|