design-clone 2.1.0 → 3.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/README.md +13 -34
- package/SKILL.md +69 -45
- package/bin/cli.js +22 -4
- package/bin/commands/clone-site.js +31 -171
- package/bin/commands/help.js +19 -6
- package/bin/commands/init.js +9 -86
- package/bin/commands/uninstall.js +105 -0
- package/bin/commands/update.js +70 -0
- package/bin/commands/verify.js +7 -14
- package/bin/utils/paths.js +28 -0
- package/bin/utils/validate.js +2 -22
- package/bin/utils/version.js +23 -0
- package/docs/code-standards.md +789 -0
- package/docs/codebase-summary.md +533 -286
- package/docs/index.md +74 -0
- package/docs/project-overview-pdr.md +797 -0
- package/docs/system-architecture.md +718 -0
- package/package.json +14 -17
- package/src/ai/prompts/design-tokens/basic.md +80 -0
- package/src/ai/prompts/design-tokens/section-with-css.md +41 -0
- package/src/ai/prompts/design-tokens/section.md +48 -0
- package/src/ai/prompts/design-tokens/with-css.md +87 -0
- package/src/ai/prompts/structure-analysis/basic.md +55 -0
- package/src/ai/prompts/structure-analysis/with-context.md +59 -0
- package/src/ai/prompts/structure-analysis/with-dimensions.md +63 -0
- package/src/ai/prompts/structure-analysis/with-hierarchy.md +73 -0
- package/src/ai/prompts/ux-audit/aggregation.md +42 -0
- package/src/ai/prompts/ux-audit/desktop.md +92 -0
- package/src/ai/prompts/ux-audit/mobile.md +93 -0
- package/src/ai/prompts/ux-audit/tablet.md +92 -0
- package/src/core/animation/animation-extractor-ast.js +183 -0
- package/src/core/animation/animation-extractor-output.js +152 -0
- package/src/core/animation/animation-extractor.js +178 -0
- package/src/core/animation/state-capture-detection.js +200 -0
- package/src/core/animation/state-capture.js +193 -0
- package/src/core/capture/browser-context-pool.js +96 -0
- package/src/core/capture/multi-page-screenshot-page.js +110 -0
- package/src/core/capture/multi-page-screenshot.js +208 -0
- package/src/core/capture/screenshot-extraction.js +186 -0
- package/src/core/capture/screenshot-helpers.js +175 -0
- package/src/core/capture/screenshot-orchestrator.js +174 -0
- package/src/core/capture/screenshot-viewport.js +93 -0
- package/src/core/capture/screenshot.js +192 -0
- package/src/core/content/content-counter-dom.js +191 -0
- package/src/core/content/content-counter.js +76 -0
- package/src/core/css/breakpoint-detector.js +66 -0
- package/src/core/css/chromium-defaults.json +23 -0
- package/src/core/css/computed-style-extractor.js +102 -0
- package/src/core/css/css-chunker.js +103 -0
- package/src/core/css/filter-css-dead-code.js +120 -0
- package/src/core/css/filter-css-html-analyzer.js +110 -0
- package/src/core/css/filter-css-selector-matcher.js +172 -0
- package/src/core/css/filter-css.js +206 -0
- package/src/core/css/merge-css-atrule-processor.js +158 -0
- package/src/core/css/merge-css-file-io.js +68 -0
- package/src/core/css/merge-css.js +148 -0
- package/src/core/detection/framework-detector-routing.js +68 -0
- package/src/core/detection/framework-detector-signals.js +65 -0
- package/src/core/detection/framework-detector.js +198 -0
- package/src/core/dimension/dimension-extractor-card-detector.js +82 -0
- package/src/core/dimension/dimension-extractor.js +317 -0
- package/src/core/dimension/dimension-output-ai-summary.js +111 -0
- package/src/core/dimension/dimension-output.js +173 -0
- package/src/core/dimension/dom-tree-analyzer-tree-builders.js +95 -0
- package/src/core/dimension/dom-tree-analyzer.js +191 -0
- package/src/core/discovery/app-state-snapshot-capture.js +195 -0
- package/src/core/discovery/app-state-snapshot-utils.js +178 -0
- package/src/core/discovery/app-state-snapshot.js +131 -0
- package/src/core/discovery/discover-pages-routes.js +84 -0
- package/src/core/discovery/discover-pages-utils.js +177 -0
- package/src/core/discovery/discover-pages.js +191 -0
- package/src/core/html/html-extractor-inline-styler.js +70 -0
- package/src/core/html/html-extractor.js +147 -0
- package/src/core/html/semantic-enhancer-mappings.js +200 -0
- package/src/core/html/semantic-enhancer-page.js +148 -0
- package/src/core/html/semantic-enhancer.js +135 -0
- package/src/core/links/rewrite-links-css-rewriter.js +53 -0
- package/src/core/links/rewrite-links.js +173 -0
- package/src/core/media/asset-validator.js +118 -0
- package/src/core/media/extract-assets-downloader.js +187 -0
- package/src/core/media/extract-assets-page-scraper.js +115 -0
- package/src/core/media/extract-assets.js +159 -0
- package/src/core/media/video-capture-convert.js +200 -0
- package/src/core/media/video-capture.js +201 -0
- package/src/core/{lazy-loader.js → page-prep/lazy-loader.js} +37 -39
- package/src/core/section/section-cropper-helpers.js +43 -0
- package/src/core/{section-cropper.js → section/section-cropper.js} +11 -88
- package/src/core/section/section-detector-strategies.js +139 -0
- package/src/core/section/section-detector-utils.js +100 -0
- package/src/core/section/section-detector.js +88 -0
- package/src/core/tests/test-section-cropper.js +2 -2
- package/src/core/tests/test-section-detector.js +2 -2
- package/src/post-process/enhance-assets.js +29 -4
- package/src/post-process/fetch-images-unsplash-client.js +123 -0
- package/src/post-process/fetch-images.js +60 -263
- package/src/post-process/inject-gosnap.js +88 -0
- package/src/post-process/inject-icons-svg-replacer.js +76 -0
- package/src/post-process/inject-icons.js +47 -200
- package/src/route-discoverers/base-discoverer-utils.js +137 -0
- package/src/route-discoverers/base-discoverer.js +29 -118
- package/src/route-discoverers/index.js +1 -1
- package/src/shared/config.js +38 -0
- package/src/shared/error-codes.js +31 -0
- package/src/shared/viewports.js +46 -0
- package/src/utils/browser.js +0 -7
- package/src/utils/helpers.js +4 -0
- package/src/utils/log.js +12 -0
- package/src/utils/playwright-loader.js +76 -0
- package/src/utils/playwright.js +3 -69
- package/src/utils/progress.js +32 -0
- package/src/verification/generate-audit-report-css-fixes.js +52 -0
- package/src/verification/generate-audit-report-sections.js +158 -0
- package/src/verification/generate-audit-report.js +5 -281
- package/src/verification/quality-scorer.js +92 -0
- package/src/verification/verify-footer-checks.js +103 -0
- package/src/verification/verify-footer-helpers.js +178 -0
- package/src/verification/verify-footer.js +23 -381
- package/src/verification/verify-header-checks.js +104 -0
- package/src/verification/verify-header-helpers.js +156 -0
- package/src/verification/verify-header.js +23 -365
- package/src/verification/verify-layout-report.js +101 -0
- package/src/verification/verify-layout.js +13 -259
- package/src/verification/verify-menu-checks.js +104 -0
- package/src/verification/verify-menu-helpers.js +112 -0
- package/src/verification/verify-menu.js +17 -285
- package/src/verification/verify-slider-checks.js +115 -0
- package/src/verification/verify-slider-constants.js +65 -0
- package/src/verification/verify-slider-helpers.js +164 -0
- package/src/verification/verify-slider.js +23 -414
- package/.env.example +0 -14
- package/docs/basic-clone.md +0 -63
- package/docs/cli-reference.md +0 -316
- package/docs/design-clone-architecture.md +0 -492
- package/docs/pixel-perfect.md +0 -117
- package/docs/project-roadmap.md +0 -382
- package/docs/troubleshooting.md +0 -170
- package/requirements.txt +0 -5
- 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 +0 -375
- package/src/ai/extract-design-tokens.py +0 -782
- package/src/ai/prompts/__init__.py +0 -2
- package/src/ai/prompts/__pycache__/__init__.cpython-313.pyc +0 -0
- 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 +0 -316
- package/src/ai/prompts/structure_analysis.py +0 -592
- package/src/ai/prompts/ux_audit.py +0 -198
- package/src/ai/ux-audit.js +0 -596
- package/src/core/animation-extractor.js +0 -526
- package/src/core/app-state-snapshot.js +0 -511
- package/src/core/content-counter.js +0 -342
- package/src/core/design-tokens.js +0 -103
- package/src/core/dimension-extractor.js +0 -438
- package/src/core/dimension-output.js +0 -305
- package/src/core/discover-pages.js +0 -542
- package/src/core/dom-tree-analyzer.js +0 -298
- package/src/core/extract-assets.js +0 -468
- package/src/core/filter-css.js +0 -499
- package/src/core/framework-detector.js +0 -538
- package/src/core/html-extractor.js +0 -212
- package/src/core/merge-css.js +0 -407
- package/src/core/multi-page-screenshot.js +0 -380
- package/src/core/rewrite-links.js +0 -226
- package/src/core/screenshot.js +0 -701
- package/src/core/section-detector.js +0 -386
- package/src/core/semantic-enhancer.js +0 -492
- package/src/core/state-capture.js +0 -598
- package/src/core/video-capture.js +0 -546
- package/src/utils/__init__.py +0 -16
- package/src/utils/__pycache__/__init__.cpython-313.pyc +0 -0
- package/src/utils/__pycache__/env.cpython-313.pyc +0 -0
- package/src/utils/env.py +0 -134
- /package/src/core/{css-extractor.js → css/css-extractor.js} +0 -0
- /package/src/core/{cookie-handler.js → page-prep/cookie-handler.js} +0 -0
- /package/src/core/{page-readiness.js → page-prep/page-readiness.js} +0 -0
|
@@ -0,0 +1,174 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Screenshot Orchestrator
|
|
3
|
+
*
|
|
4
|
+
* Post-capture orchestration: hover state capture, video recording,
|
|
5
|
+
* section-based cropping, and component dimension output assembly.
|
|
6
|
+
* Called after per-viewport screenshots are complete.
|
|
7
|
+
*
|
|
8
|
+
* @module screenshot-orchestrator
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
import path from 'path';
|
|
12
|
+
import fs from 'fs/promises';
|
|
13
|
+
import { captureAllHoverStates, generateHoverCss } from '../animation/state-capture.js';
|
|
14
|
+
import { captureVideo, hasFfmpeg, FFMPEG_REQUIRED_FORMATS } from '../media/video-capture.js';
|
|
15
|
+
import { buildDimensionsOutput, generateAISummary } from '../dimension/dimension-output.js';
|
|
16
|
+
import { VIEWPORTS } from '../../shared/viewports.js';
|
|
17
|
+
import { logInfo, logWarn } from '../../utils/log.js';
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* Capture hover states with headed→headless fallback.
|
|
21
|
+
* Writes hover.css to output if captures succeed.
|
|
22
|
+
*/
|
|
23
|
+
export async function runHoverCapture(
|
|
24
|
+
browserMgr, extraction, output, url,
|
|
25
|
+
initBrowser, getHeadlessForViewport, requestedViewports
|
|
26
|
+
) {
|
|
27
|
+
const readCss = async () => extraction?.css?.path ? fs.readFile(extraction.css.path, 'utf-8') : null;
|
|
28
|
+
try {
|
|
29
|
+
const wasHeadless = browserMgr.getCurrentHeadless();
|
|
30
|
+
let hoverResult = null;
|
|
31
|
+
let hoverCaptureSuccess = false;
|
|
32
|
+
|
|
33
|
+
if (!wasHeadless) {
|
|
34
|
+
try {
|
|
35
|
+
hoverResult = await captureAllHoverStates(browserMgr.getPage(), await readCss(), output);
|
|
36
|
+
hoverCaptureSuccess = hoverResult.captured > 0;
|
|
37
|
+
} catch (headedError) {
|
|
38
|
+
logWarn(`Headed hover capture failed, switching to headless: ${headedError.message}`);
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
if (!hoverCaptureSuccess) {
|
|
43
|
+
if (!browserMgr.getCurrentHeadless()) await initBrowser(true, url);
|
|
44
|
+
hoverResult = await captureAllHoverStates(browserMgr.getPage(), await readCss(), output);
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
if (hoverResult?.elements && hoverResult.captured > 0) {
|
|
48
|
+
const hoverCssPath = path.join(output, 'hover.css');
|
|
49
|
+
await fs.writeFile(hoverCssPath, generateHoverCss(hoverResult.elements), 'utf-8');
|
|
50
|
+
hoverResult.generatedCss = path.resolve(hoverCssPath);
|
|
51
|
+
}
|
|
52
|
+
if (hoverResult) logInfo(`Hover states: ${hoverResult.captured}/${hoverResult.detected} captured`);
|
|
53
|
+
if (!wasHeadless && browserMgr.getCurrentHeadless() && requestedViewports.some(v => !getHeadlessForViewport(v))) {
|
|
54
|
+
await initBrowser(false, url);
|
|
55
|
+
}
|
|
56
|
+
return hoverResult;
|
|
57
|
+
} catch (error) {
|
|
58
|
+
logWarn(`Hover capture failed: ${error.message}`);
|
|
59
|
+
return { error: error.message, failed: true };
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/** Record a scroll video (WebM → optionally MP4/GIF). Returns error object on failure. */
|
|
64
|
+
export async function runVideoCapture(browserMgr, output, videoFormat, videoDuration) {
|
|
65
|
+
try {
|
|
66
|
+
if (FFMPEG_REQUIRED_FORMATS.includes(videoFormat)) {
|
|
67
|
+
const hasFf = await hasFfmpeg();
|
|
68
|
+
if (!hasFf) {
|
|
69
|
+
logWarn(`ffmpeg not found. Will output WebM instead of ${videoFormat}`);
|
|
70
|
+
logWarn('Install: npm install fluent-ffmpeg @ffmpeg-installer/ffmpeg');
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
const videoPage = browserMgr.getPage();
|
|
75
|
+
await videoPage.setViewportSize(VIEWPORTS.desktop);
|
|
76
|
+
await new Promise(r => setTimeout(r, 1000));
|
|
77
|
+
|
|
78
|
+
logInfo(`Recording video (${videoDuration / 1000}s)...`);
|
|
79
|
+
|
|
80
|
+
const videoResult = await captureVideo(videoPage, output, {
|
|
81
|
+
format: videoFormat,
|
|
82
|
+
duration: videoDuration,
|
|
83
|
+
filename: 'preview'
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
const outputFormat = videoResult.output.split('.').pop();
|
|
87
|
+
logInfo(`Video saved: ${outputFormat} (${(videoResult.duration / 1000).toFixed(1)}s)`);
|
|
88
|
+
|
|
89
|
+
return videoResult;
|
|
90
|
+
} catch (error) {
|
|
91
|
+
logWarn(`Video capture failed: ${error.message}`);
|
|
92
|
+
return { error: error.message, failed: true };
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/** Detect sections and crop desktop screenshot into individual section images. */
|
|
97
|
+
export async function runSectionCapture(browserMgr, desktopScreenshot, output) {
|
|
98
|
+
try {
|
|
99
|
+
const { detectSections } = await import('../section/section-detector.js');
|
|
100
|
+
const { cropSections } = await import('../section/section-cropper.js');
|
|
101
|
+
|
|
102
|
+
const sectionPage = browserMgr.getPage();
|
|
103
|
+
await sectionPage.setViewportSize(VIEWPORTS.desktop);
|
|
104
|
+
await new Promise(r => setTimeout(r, 500));
|
|
105
|
+
|
|
106
|
+
logInfo('Detecting sections...');
|
|
107
|
+
|
|
108
|
+
const sections = await detectSections(sectionPage, {
|
|
109
|
+
padding: 40,
|
|
110
|
+
minSections: 3,
|
|
111
|
+
fallbackToViewport: true
|
|
112
|
+
});
|
|
113
|
+
|
|
114
|
+
logInfo(`Found ${sections.length} sections, cropping...`);
|
|
115
|
+
|
|
116
|
+
const croppedResult = await cropSections(desktopScreenshot.path, sections, output);
|
|
117
|
+
|
|
118
|
+
logInfo(`Sections: ${croppedResult.sections.length} cropped, ${croppedResult.skipped.length} skipped`);
|
|
119
|
+
|
|
120
|
+
return {
|
|
121
|
+
enabled: true,
|
|
122
|
+
count: croppedResult.sections.length,
|
|
123
|
+
skipped: croppedResult.skipped.length,
|
|
124
|
+
sections: croppedResult.sections.map(s => ({
|
|
125
|
+
index: s.index,
|
|
126
|
+
name: s.name,
|
|
127
|
+
path: s.relativePath,
|
|
128
|
+
bounds: s.bounds,
|
|
129
|
+
role: s.role
|
|
130
|
+
})),
|
|
131
|
+
directory: croppedResult.directory,
|
|
132
|
+
summary: croppedResult.summary
|
|
133
|
+
};
|
|
134
|
+
} catch (err) {
|
|
135
|
+
logWarn(`Section processing failed: ${err.message}`);
|
|
136
|
+
return { enabled: true, error: err.message };
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
/** Build component-dimensions.json + dimensions-summary.json; return stats. */
|
|
141
|
+
export async function runDimensionOutput(screenshots, url, output) {
|
|
142
|
+
const allViewportDimensions = {};
|
|
143
|
+
for (const screenshot of screenshots) {
|
|
144
|
+
if (screenshot.componentDimensions) {
|
|
145
|
+
allViewportDimensions[screenshot.viewport] = screenshot.componentDimensions;
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
const dimensionsOutput = buildDimensionsOutput(allViewportDimensions, url);
|
|
150
|
+
const dimensionsPath = path.join(output, 'component-dimensions.json');
|
|
151
|
+
await fs.writeFile(dimensionsPath, JSON.stringify(dimensionsOutput, null, 2));
|
|
152
|
+
|
|
153
|
+
const aiSummary = generateAISummary(dimensionsOutput);
|
|
154
|
+
const summaryPath = path.join(output, 'dimensions-summary.json');
|
|
155
|
+
await fs.writeFile(summaryPath, JSON.stringify(aiSummary, null, 2));
|
|
156
|
+
|
|
157
|
+
const vpValues = Object.values(dimensionsOutput.viewports);
|
|
158
|
+
const sum = (key) => vpValues.reduce((s, vp) => s + (vp[key]?.length || 0), 0);
|
|
159
|
+
const stats = { containers: sum('containers'), cards: sum('cards'), gridLayouts: sum('gridLayouts'), typography: sum('typography') };
|
|
160
|
+
|
|
161
|
+
logInfo(`Extracted: ${stats.containers} containers, ${stats.cards} card groups, ${stats.gridLayouts} grid layouts`);
|
|
162
|
+
|
|
163
|
+
return { dimensionsOutput, dimensionsPath: path.resolve(dimensionsPath), summaryPath: path.resolve(summaryPath), stats };
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
/** Write dom-hierarchy.json from desktopScreenshot.domHierarchy. Returns path or null. */
|
|
167
|
+
export async function writeDomHierarchy(desktopScreenshot, output) {
|
|
168
|
+
if (!desktopScreenshot?.domHierarchy) return null;
|
|
169
|
+
const hierarchyPath = path.join(output, 'dom-hierarchy.json');
|
|
170
|
+
await fs.writeFile(hierarchyPath, JSON.stringify(desktopScreenshot.domHierarchy, null, 2));
|
|
171
|
+
const s = desktopScreenshot.domHierarchy.stats || {};
|
|
172
|
+
logInfo(`DOM hierarchy: ${s.totalNodes || 0} nodes, ${s.landmarkCount || 0} landmarks, ${s.extractionTimeMs || 0}ms`);
|
|
173
|
+
return path.resolve(hierarchyPath);
|
|
174
|
+
}
|
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Screenshot Viewport Capture
|
|
3
|
+
*
|
|
4
|
+
* Single-viewport screenshot: sets viewport size, waits for DOM stability,
|
|
5
|
+
* triggers lazy-load, forces animated elements visible, then takes the screenshot.
|
|
6
|
+
*
|
|
7
|
+
* @module screenshot-viewport
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
import path from 'path';
|
|
11
|
+
|
|
12
|
+
import { waitForDomStable, waitForFontsLoaded, waitForStylesStable } from '../page-prep/page-readiness.js';
|
|
13
|
+
import { forceLazyImages, forceAnimatedElementsVisible, triggerLazyLoad, waitForAllImages, LAZY_LOAD_MAX_ITERATIONS } from '../page-prep/lazy-loader.js';
|
|
14
|
+
import { extractComponentDimensions } from '../dimension/dimension-extractor.js';
|
|
15
|
+
import { extractDOMHierarchy } from '../dimension/dom-tree-analyzer.js';
|
|
16
|
+
import { compressIfNeeded, VIEWPORT_SETTLE_DELAY, NETWORK_IDLE_TIMEOUT, DEFAULT_SCROLL_DELAY } from './screenshot-helpers.js';
|
|
17
|
+
import { VIEWPORTS } from '../../shared/viewports.js';
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* Capture screenshot for a single viewport.
|
|
21
|
+
* Handles DOM stability, lazy images, animations, and optional compression.
|
|
22
|
+
*
|
|
23
|
+
* @param {{page, viewport, outputPath, fullPage?, maxSize?, scrollDelay?}} options
|
|
24
|
+
* @returns {Promise<{viewport, path, dimensions, componentDimensions, domHierarchy, scrollInfo, imageStats, size, compressed}>}
|
|
25
|
+
*/
|
|
26
|
+
export async function captureViewport(options) {
|
|
27
|
+
const {
|
|
28
|
+
page,
|
|
29
|
+
viewport,
|
|
30
|
+
outputPath,
|
|
31
|
+
fullPage = true,
|
|
32
|
+
maxSize = 5,
|
|
33
|
+
scrollDelay = DEFAULT_SCROLL_DELAY,
|
|
34
|
+
viewportMap = VIEWPORTS
|
|
35
|
+
} = options;
|
|
36
|
+
|
|
37
|
+
await page.setViewportSize(viewportMap[viewport]);
|
|
38
|
+
await new Promise(r => setTimeout(r, VIEWPORT_SETTLE_DELAY));
|
|
39
|
+
await waitForDomStable(page, 300, 5000);
|
|
40
|
+
await waitForFontsLoaded(page, 3000);
|
|
41
|
+
await waitForStylesStable(page, 200, 2000);
|
|
42
|
+
|
|
43
|
+
const componentDimensions = await extractComponentDimensions(page, viewport);
|
|
44
|
+
|
|
45
|
+
// Extract DOM hierarchy on desktop only (perf: skip on tablet/mobile)
|
|
46
|
+
let domHierarchy = null;
|
|
47
|
+
if (viewport === 'desktop') {
|
|
48
|
+
try {
|
|
49
|
+
domHierarchy = await extractDOMHierarchy(page, { maxDepth: 8 });
|
|
50
|
+
} catch (err) {
|
|
51
|
+
console.error(`[WARN] DOM hierarchy extraction failed: ${err.message}`);
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
await forceLazyImages(page);
|
|
56
|
+
const scrollInfo = await triggerLazyLoad(page, LAZY_LOAD_MAX_ITERATIONS, scrollDelay);
|
|
57
|
+
await forceLazyImages(page);
|
|
58
|
+
const imageStats = await waitForAllImages(page, 15000);
|
|
59
|
+
|
|
60
|
+
try {
|
|
61
|
+
await page.waitForLoadState('networkidle', { timeout: NETWORK_IDLE_TIMEOUT });
|
|
62
|
+
} catch {
|
|
63
|
+
// Acceptable: page may have long-polling connections
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
await new Promise(r => setTimeout(r, 2000));
|
|
67
|
+
await waitForDomStable(page, 300, 3000);
|
|
68
|
+
await waitForFontsLoaded(page, 2000);
|
|
69
|
+
await forceAnimatedElementsVisible(page);
|
|
70
|
+
await new Promise(r => setTimeout(r, 300));
|
|
71
|
+
|
|
72
|
+
await page.evaluate(() => {
|
|
73
|
+
window.scrollTo(0, 0);
|
|
74
|
+
document.documentElement.scrollTop = 0;
|
|
75
|
+
document.body.scrollTop = 0;
|
|
76
|
+
});
|
|
77
|
+
await new Promise(r => setTimeout(r, 500));
|
|
78
|
+
|
|
79
|
+
await page.screenshot({ path: outputPath, type: 'png', fullPage });
|
|
80
|
+
const compression = await compressIfNeeded(outputPath, maxSize);
|
|
81
|
+
|
|
82
|
+
return {
|
|
83
|
+
viewport,
|
|
84
|
+
path: path.resolve(outputPath),
|
|
85
|
+
dimensions: viewportMap[viewport],
|
|
86
|
+
componentDimensions,
|
|
87
|
+
domHierarchy,
|
|
88
|
+
scrollInfo,
|
|
89
|
+
imageStats,
|
|
90
|
+
size: compression.finalSize,
|
|
91
|
+
compressed: compression.compressed
|
|
92
|
+
};
|
|
93
|
+
}
|
|
@@ -0,0 +1,192 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* Multi-viewport screenshot capture for design cloning
|
|
4
|
+
*
|
|
5
|
+
* Usage: node screenshot.js --url <url> --output <dir> [options]
|
|
6
|
+
*
|
|
7
|
+
* Options:
|
|
8
|
+
* --url, --output, --viewports, --full-page, --max-size, --headless,
|
|
9
|
+
* --scroll-delay, --close, --extract-html, --extract-css, --filter-unused,
|
|
10
|
+
* --capture-hover, --video, --video-format, --video-duration,
|
|
11
|
+
* --section-mode, --no-semantic
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
import path from 'path';
|
|
15
|
+
import fs from 'fs/promises';
|
|
16
|
+
|
|
17
|
+
import { getBrowser, getPage, closeBrowser, disconnectBrowser } from '../../utils/browser.js';
|
|
18
|
+
import { parseArgs, outputJSON, outputError } from '../../utils/helpers.js';
|
|
19
|
+
import { parseScreenshotArgs, createBrowserManager, VIEWPORTS } from './screenshot-helpers.js';
|
|
20
|
+
import { runExtractionPipeline } from './screenshot-extraction.js';
|
|
21
|
+
import { runHoverCapture, runVideoCapture, runSectionCapture, runDimensionOutput, writeDomHierarchy } from './screenshot-orchestrator.js';
|
|
22
|
+
import { logInfo } from '../../utils/log.js';
|
|
23
|
+
import { createProgress } from '../../utils/progress.js';
|
|
24
|
+
import { captureViewport } from './screenshot-viewport.js';
|
|
25
|
+
|
|
26
|
+
// ============================================================================
|
|
27
|
+
// Main Orchestration
|
|
28
|
+
// ============================================================================
|
|
29
|
+
|
|
30
|
+
async function captureMultiViewport() {
|
|
31
|
+
const options = parseScreenshotArgs(process.argv.slice(2));
|
|
32
|
+
let {
|
|
33
|
+
url, output, viewports: requestedViewports,
|
|
34
|
+
fullPage, maxSize, scrollDelay,
|
|
35
|
+
extractHtml, extractCss, filterUnused,
|
|
36
|
+
captureHover, captureVideo: captureVideoFlag,
|
|
37
|
+
videoFormat, videoDuration, sectionMode,
|
|
38
|
+
enhanceSemantic, extractAnimations: extractAnimationsFlag,
|
|
39
|
+
headless: cliHeadless, close: shouldClose
|
|
40
|
+
} = options;
|
|
41
|
+
|
|
42
|
+
try {
|
|
43
|
+
await fs.mkdir(output, { recursive: true });
|
|
44
|
+
|
|
45
|
+
const browserMgr = createBrowserManager(cliHeadless);
|
|
46
|
+
const { getHeadlessForViewport, init: initBrowser } = browserMgr;
|
|
47
|
+
await initBrowser(getHeadlessForViewport(requestedViewports[0]), url);
|
|
48
|
+
|
|
49
|
+
const cookieResult = browserMgr.getCookieResult();
|
|
50
|
+
|
|
51
|
+
// HTML/CSS/animation extraction
|
|
52
|
+
let extraction = null;
|
|
53
|
+
if (extractHtml || extractCss) {
|
|
54
|
+
extraction = await runExtractionPipeline(browserMgr.getPage(), url, output, {
|
|
55
|
+
extractHtml, extractCss, filterUnused, enhanceSemantic,
|
|
56
|
+
extractAnimations: extractAnimationsFlag,
|
|
57
|
+
extractComputed: options.extractComputed,
|
|
58
|
+
aggressiveFilter: options.aggressiveFilter
|
|
59
|
+
});
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
// Hover state capture
|
|
63
|
+
let hoverResult = null;
|
|
64
|
+
if (captureHover) {
|
|
65
|
+
hoverResult = await runHoverCapture(
|
|
66
|
+
browserMgr, extraction, output, url,
|
|
67
|
+
initBrowser, getHeadlessForViewport, requestedViewports
|
|
68
|
+
);
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
// Breakpoint detection: override viewports with CSS-detected breakpoints
|
|
72
|
+
// Use a local copy to avoid mutating the global VIEWPORTS object (side-effect prevention)
|
|
73
|
+
let localViewports = { ...VIEWPORTS };
|
|
74
|
+
let detectedBreakpoints = null;
|
|
75
|
+
if (options.detectBreakpoints && extraction?.css?.path && !extraction.css.failed) {
|
|
76
|
+
try {
|
|
77
|
+
const rawCss = await fs.readFile(extraction.css.path, 'utf-8');
|
|
78
|
+
const { detectBreakpoints, mergeWithFixed } = await import('../css/breakpoint-detector.js');
|
|
79
|
+
detectedBreakpoints = detectBreakpoints(rawCss);
|
|
80
|
+
const bpPath = path.join(output, 'breakpoints.json');
|
|
81
|
+
await fs.writeFile(bpPath, JSON.stringify(detectedBreakpoints, null, 2));
|
|
82
|
+
if (detectedBreakpoints.breakpoints.length > 0) {
|
|
83
|
+
const mergedViewports = mergeWithFixed(detectedBreakpoints.breakpoints);
|
|
84
|
+
requestedViewports = Object.keys(mergedViewports);
|
|
85
|
+
for (const [name, config] of Object.entries(mergedViewports)) {
|
|
86
|
+
if (!localViewports[name]) localViewports[name] = config;
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
} catch { /* breakpoint detection optional, continue with defaults */ }
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
// Per-viewport screenshots
|
|
93
|
+
const screenshots = [];
|
|
94
|
+
const browserRestarts = [];
|
|
95
|
+
const allHeadless = requestedViewports.every(vp => getHeadlessForViewport(vp));
|
|
96
|
+
const vpProgress = createProgress();
|
|
97
|
+
vpProgress.start(requestedViewports.length, 'Viewport capture');
|
|
98
|
+
|
|
99
|
+
if (allHeadless) {
|
|
100
|
+
// Fast path: single browser session, just resize viewport
|
|
101
|
+
for (const viewport of requestedViewports) {
|
|
102
|
+
vpProgress.step(`Capturing ${viewport}`, `${localViewports[viewport].width}px`);
|
|
103
|
+
screenshots.push(await captureViewport({
|
|
104
|
+
page: browserMgr.getPage(), viewport,
|
|
105
|
+
outputPath: path.join(output, `${viewport}.png`),
|
|
106
|
+
fullPage, maxSize, scrollDelay, viewportMap: localViewports
|
|
107
|
+
}));
|
|
108
|
+
}
|
|
109
|
+
} else {
|
|
110
|
+
// Restart path for mixed headless/headed scenarios
|
|
111
|
+
for (const viewport of requestedViewports) {
|
|
112
|
+
vpProgress.step(`Capturing ${viewport}`, `${localViewports[viewport].width}px`);
|
|
113
|
+
const viewportHeadless = getHeadlessForViewport(viewport);
|
|
114
|
+
if (browserMgr.getCurrentHeadless() !== viewportHeadless) {
|
|
115
|
+
browserRestarts.push({ viewport, from: browserMgr.getCurrentHeadless() ? 'headless' : 'headed', to: viewportHeadless ? 'headless' : 'headed' });
|
|
116
|
+
logInfo(`Switching to ${viewportHeadless ? 'headless' : 'headed'} for ${viewport}`);
|
|
117
|
+
await initBrowser(viewportHeadless, url);
|
|
118
|
+
}
|
|
119
|
+
screenshots.push(await captureViewport({
|
|
120
|
+
page: browserMgr.getPage(), viewport,
|
|
121
|
+
outputPath: path.join(output, `${viewport}.png`),
|
|
122
|
+
fullPage, maxSize, scrollDelay, viewportMap: localViewports
|
|
123
|
+
}));
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
vpProgress.complete(`${screenshots.length} viewports captured`);
|
|
127
|
+
|
|
128
|
+
// Optional: video, dimensions, DOM hierarchy, sections
|
|
129
|
+
const videoResult = captureVideoFlag
|
|
130
|
+
? await runVideoCapture(browserMgr, output, videoFormat, videoDuration)
|
|
131
|
+
: null;
|
|
132
|
+
|
|
133
|
+
const dimResult = await runDimensionOutput(screenshots, url, output);
|
|
134
|
+
const desktopScreenshot = screenshots.find(s => s.viewport === 'desktop');
|
|
135
|
+
const hierarchyPath = await writeDomHierarchy(desktopScreenshot, output);
|
|
136
|
+
const sectionResult = (sectionMode && desktopScreenshot)
|
|
137
|
+
? await runSectionCapture(browserMgr, desktopScreenshot, output)
|
|
138
|
+
: null;
|
|
139
|
+
|
|
140
|
+
// Quality score: auto for clone-px, opt-in for basic clone
|
|
141
|
+
const isClonePx = captureHover || options.extractAssets;
|
|
142
|
+
let qualityScore = null;
|
|
143
|
+
if (isClonePx || options.qualityScore) {
|
|
144
|
+
try {
|
|
145
|
+
const { scoreCapture } = await import('../../verification/quality-scorer.js');
|
|
146
|
+
qualityScore = await scoreCapture({
|
|
147
|
+
extraction, screenshots, assetStats: null, outputDir: output
|
|
148
|
+
});
|
|
149
|
+
const scorePath = path.join(output, 'quality-score.json');
|
|
150
|
+
await fs.writeFile(scorePath, JSON.stringify(qualityScore, null, 2));
|
|
151
|
+
} catch { /* quality scoring optional */ }
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
outputJSON({
|
|
155
|
+
success: true, url, outputDir: path.resolve(output), cookieHandling: cookieResult,
|
|
156
|
+
extraction,
|
|
157
|
+
qualityScore: qualityScore || undefined,
|
|
158
|
+
hoverStates: hoverResult && !hoverResult.failed
|
|
159
|
+
? { directory: hoverResult.directory, detected: hoverResult.detected, captured: hoverResult.captured, summaryPath: hoverResult.summaryPath, generatedCss: hoverResult.generatedCss }
|
|
160
|
+
: (hoverResult?.error ? { error: hoverResult.error } : undefined),
|
|
161
|
+
video: videoResult && !videoResult.failed
|
|
162
|
+
? { path: videoResult.output, format: videoResult.output.split('.').pop(), duration: videoResult.duration, pageHeight: videoResult.pageHeight, webm: videoResult.webm, mp4: videoResult.mp4, gif: videoResult.gif, conversionError: videoResult.conversionError }
|
|
163
|
+
: (videoResult?.error ? { error: videoResult.error } : undefined),
|
|
164
|
+
componentDimensions: { full: dimResult.dimensionsPath, summary: dimResult.summaryPath, viewports: Object.keys(dimResult.dimensionsOutput.viewports), stats: dimResult.stats },
|
|
165
|
+
domHierarchy: desktopScreenshot?.domHierarchy ? { path: hierarchyPath, stats: desktopScreenshot.domHierarchy.stats } : undefined,
|
|
166
|
+
sections: sectionResult, screenshots,
|
|
167
|
+
browserRestarts: browserRestarts.length > 0 ? browserRestarts : undefined,
|
|
168
|
+
scrollDelay, totalSize: screenshots.reduce((sum, s) => sum + s.size, 0),
|
|
169
|
+
capturedAt: new Date().toISOString()
|
|
170
|
+
});
|
|
171
|
+
|
|
172
|
+
if (shouldClose) await closeBrowser(); else await disconnectBrowser();
|
|
173
|
+
process.exit(0);
|
|
174
|
+
} catch (error) {
|
|
175
|
+
outputError(error);
|
|
176
|
+
process.exit(1);
|
|
177
|
+
} finally {
|
|
178
|
+
try { await closeBrowser(); } catch { /* ignore */ }
|
|
179
|
+
}
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
// ============================================================================
|
|
183
|
+
// Exports (backward-compatible)
|
|
184
|
+
// ============================================================================
|
|
185
|
+
|
|
186
|
+
export { captureViewport } from './screenshot-viewport.js';
|
|
187
|
+
export { compressIfNeeded, parseScreenshotArgs, createBrowserManager, VIEWPORT_SETTLE_DELAY, DEFAULT_SCROLL_DELAY, VIEWPORTS } from './screenshot-helpers.js';
|
|
188
|
+
|
|
189
|
+
// Run if called directly
|
|
190
|
+
if (process.argv[1]?.endsWith('screenshot.js') || process.argv[1]?.includes('screenshot')) {
|
|
191
|
+
captureMultiViewport();
|
|
192
|
+
}
|
|
@@ -0,0 +1,191 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* DOM content counting logic executed inside page.evaluate.
|
|
3
|
+
*
|
|
4
|
+
* Exports a single serializable function string (domCountingScript) that
|
|
5
|
+
* is passed into page.evaluate() by content-counter.js. Keeping it separate
|
|
6
|
+
* allows the browser-side counting logic to be maintained independently of
|
|
7
|
+
* the Node.js orchestration and summary-generation code.
|
|
8
|
+
*
|
|
9
|
+
* All code inside the function runs in browser context — no imports allowed.
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* Browser-side DOM counting function.
|
|
14
|
+
* Returns structured content counts for sections, grids, repeated items,
|
|
15
|
+
* navigation, media, and interactive elements.
|
|
16
|
+
*
|
|
17
|
+
* @returns {Object} content counts
|
|
18
|
+
*/
|
|
19
|
+
export function domCountingFn() {
|
|
20
|
+
const counts = {
|
|
21
|
+
extractedAt: new Date().toISOString(),
|
|
22
|
+
sections: { total: 0, withBackground: 0, details: [] },
|
|
23
|
+
grids: { total: 0, details: [] },
|
|
24
|
+
repeatedItems: { total: 0, byType: {} },
|
|
25
|
+
navigation: { headerLinks: 0, footerLinks: 0, allLinks: 0 },
|
|
26
|
+
media: { images: 0, videos: 0, svgIcons: 0 },
|
|
27
|
+
interactive: { buttons: 0, inputs: 0, forms: 0 }
|
|
28
|
+
};
|
|
29
|
+
|
|
30
|
+
const isVisible = (el) => {
|
|
31
|
+
if (!el) return false;
|
|
32
|
+
const style = getComputedStyle(el);
|
|
33
|
+
return style.display !== 'none' &&
|
|
34
|
+
style.visibility !== 'hidden' &&
|
|
35
|
+
!el.hasAttribute('hidden');
|
|
36
|
+
};
|
|
37
|
+
|
|
38
|
+
const getSelector = (el) => {
|
|
39
|
+
if (el.id) return `#${el.id}`;
|
|
40
|
+
const classes = [...el.classList].filter(c =>
|
|
41
|
+
!c.match(/^(js-|is-|has-|data-)/) && c.length > 2
|
|
42
|
+
).slice(0, 3).join('.');
|
|
43
|
+
return classes ? `.${classes}` : el.tagName.toLowerCase();
|
|
44
|
+
};
|
|
45
|
+
|
|
46
|
+
// 1. Count sections
|
|
47
|
+
const sectionSelectors = [
|
|
48
|
+
'section',
|
|
49
|
+
'[class*="section"]',
|
|
50
|
+
'[class*="py-lg"]', '[class*="py-xl"]', '[class*="py-2xl"]',
|
|
51
|
+
'[class*="py-md"]',
|
|
52
|
+
'[class*="bg-background"]',
|
|
53
|
+
'[class*="bg-white"]',
|
|
54
|
+
'[class*="bg-gray"]'
|
|
55
|
+
];
|
|
56
|
+
|
|
57
|
+
const sectionElements = new Set();
|
|
58
|
+
const MAX_SECTION_DETAILS = 30;
|
|
59
|
+
|
|
60
|
+
sectionSelectors.forEach(sel => {
|
|
61
|
+
try {
|
|
62
|
+
document.querySelectorAll(sel).forEach(el => {
|
|
63
|
+
if (sectionElements.size >= MAX_SECTION_DETAILS) return;
|
|
64
|
+
const rect = el.getBoundingClientRect();
|
|
65
|
+
const isSignificant = rect.height > 100 && rect.width > 200;
|
|
66
|
+
const parent = el.parentElement;
|
|
67
|
+
const isTopLevel = parent?.tagName === 'BODY' ||
|
|
68
|
+
parent?.tagName === 'MAIN' ||
|
|
69
|
+
parent?.id === 'root' ||
|
|
70
|
+
parent?.id === '__next' ||
|
|
71
|
+
parent?.classList.contains('container');
|
|
72
|
+
if (isTopLevel || (isSignificant && isVisible(el))) {
|
|
73
|
+
sectionElements.add(el);
|
|
74
|
+
}
|
|
75
|
+
});
|
|
76
|
+
} catch (e) { /* invalid selector */ }
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
sectionElements.forEach(el => {
|
|
80
|
+
const style = getComputedStyle(el);
|
|
81
|
+
const hasBg = style.backgroundColor !== 'rgba(0, 0, 0, 0)' &&
|
|
82
|
+
style.backgroundColor !== 'transparent';
|
|
83
|
+
counts.sections.total++;
|
|
84
|
+
if (hasBg) counts.sections.withBackground++;
|
|
85
|
+
counts.sections.details.push({
|
|
86
|
+
selector: getSelector(el),
|
|
87
|
+
visible: isVisible(el),
|
|
88
|
+
hasBackground: hasBg,
|
|
89
|
+
childCount: el.children.length
|
|
90
|
+
});
|
|
91
|
+
});
|
|
92
|
+
|
|
93
|
+
// 2. Count grid/flex containers
|
|
94
|
+
const gridSelectors = ['[class*="grid"]', '[style*="display: grid"]'];
|
|
95
|
+
const flexSelectors = [
|
|
96
|
+
'[class*="flex"][class*="gap"]',
|
|
97
|
+
'[class*="flex"][class*="wrap"]',
|
|
98
|
+
'[class*="flex"][class*="col"]'
|
|
99
|
+
];
|
|
100
|
+
|
|
101
|
+
const processedGrids = new Set();
|
|
102
|
+
const MIN_ITEMS_FOR_GRID = 2;
|
|
103
|
+
const MAX_GRID_DETAILS = 50;
|
|
104
|
+
|
|
105
|
+
[...gridSelectors, ...flexSelectors].forEach(sel => {
|
|
106
|
+
try {
|
|
107
|
+
document.querySelectorAll(sel).forEach(el => {
|
|
108
|
+
if (processedGrids.has(el)) return;
|
|
109
|
+
if (counts.grids.details.length >= MAX_GRID_DETAILS) return;
|
|
110
|
+
|
|
111
|
+
const style = getComputedStyle(el);
|
|
112
|
+
if (style.display === 'grid' || style.display === 'flex' ||
|
|
113
|
+
style.display === 'inline-grid' || style.display === 'inline-flex') {
|
|
114
|
+
processedGrids.add(el);
|
|
115
|
+
const items = [...el.children].filter(child =>
|
|
116
|
+
child.tagName !== 'SCRIPT' && child.tagName !== 'STYLE'
|
|
117
|
+
);
|
|
118
|
+
if (items.length < MIN_ITEMS_FOR_GRID) return;
|
|
119
|
+
const visibleItems = items.filter(isVisible);
|
|
120
|
+
const hiddenItems = items.filter(i => !isVisible(i));
|
|
121
|
+
if (visibleItems.length >= MIN_ITEMS_FOR_GRID) {
|
|
122
|
+
counts.grids.total++;
|
|
123
|
+
counts.grids.details.push({
|
|
124
|
+
selector: getSelector(el),
|
|
125
|
+
display: style.display,
|
|
126
|
+
totalItems: items.length,
|
|
127
|
+
visibleItems: visibleItems.length,
|
|
128
|
+
hiddenItems: hiddenItems.length,
|
|
129
|
+
gridCols: style.gridTemplateColumns || null,
|
|
130
|
+
visible: isVisible(el)
|
|
131
|
+
});
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
});
|
|
135
|
+
} catch (e) { /* invalid selector */ }
|
|
136
|
+
});
|
|
137
|
+
|
|
138
|
+
// 3. Count repeated items
|
|
139
|
+
const repeatPatterns = [
|
|
140
|
+
{ name: 'cards', selectors: ['[class*="card"]', '[class*="Card"]'] },
|
|
141
|
+
{ name: 'listItems', selectors: ['li', '[class*="item"]', '[class*="Item"]'] },
|
|
142
|
+
{ name: 'services', selectors: ['[class*="service"]', '[class*="Service"]'] },
|
|
143
|
+
{ name: 'features', selectors: ['[class*="feature"]', '[class*="Feature"]'] },
|
|
144
|
+
{ name: 'testimonials', selectors: ['[class*="testimonial"]', '[class*="review"]'] },
|
|
145
|
+
{ name: 'teamMembers', selectors: ['[class*="team"]', '[class*="member"]', '[class*="person"]'] },
|
|
146
|
+
{ name: 'faqItems', selectors: ['[class*="faq"]', '[class*="accordion"]', 'details'] },
|
|
147
|
+
{ name: 'pricingCards', selectors: ['[class*="pricing"]', '[class*="plan"]'] },
|
|
148
|
+
{ name: 'blogPosts', selectors: ['[class*="post"]', '[class*="article"]', 'article'] },
|
|
149
|
+
{ name: 'products', selectors: ['[class*="product"]', '[class*="Product"]'] },
|
|
150
|
+
{ name: 'categories', selectors: ['[class*="category"]', '[class*="Category"]'] }
|
|
151
|
+
];
|
|
152
|
+
|
|
153
|
+
repeatPatterns.forEach(({ name, selectors }) => {
|
|
154
|
+
let total = 0, visible = 0;
|
|
155
|
+
selectors.forEach(sel => {
|
|
156
|
+
try { document.querySelectorAll(sel).forEach(el => { total++; if (isVisible(el)) visible++; }); }
|
|
157
|
+
catch (e) { /* invalid selector */ }
|
|
158
|
+
});
|
|
159
|
+
if (total > 0) {
|
|
160
|
+
counts.repeatedItems.byType[name] = { total, visible, hidden: total - visible };
|
|
161
|
+
counts.repeatedItems.total += total;
|
|
162
|
+
}
|
|
163
|
+
});
|
|
164
|
+
|
|
165
|
+
// 4. Navigation
|
|
166
|
+
const hdr = document.querySelector('header, [class*="header"], nav');
|
|
167
|
+
const ftr = document.querySelector('footer, [class*="footer"]');
|
|
168
|
+
if (hdr) counts.navigation.headerLinks = hdr.querySelectorAll('a').length;
|
|
169
|
+
if (ftr) counts.navigation.footerLinks = ftr.querySelectorAll('a').length;
|
|
170
|
+
counts.navigation.allLinks = document.querySelectorAll('a').length;
|
|
171
|
+
|
|
172
|
+
// 5. Media & interactive
|
|
173
|
+
counts.media.images = document.querySelectorAll('img, picture').length;
|
|
174
|
+
counts.media.videos = document.querySelectorAll('video, iframe[src*="youtube"], iframe[src*="vimeo"]').length;
|
|
175
|
+
counts.media.svgIcons = document.querySelectorAll('svg').length;
|
|
176
|
+
counts.interactive.buttons = document.querySelectorAll('button, [role="button"], input[type="submit"], input[type="button"]').length;
|
|
177
|
+
counts.interactive.inputs = document.querySelectorAll('input, textarea, select').length;
|
|
178
|
+
counts.interactive.forms = document.querySelectorAll('form').length;
|
|
179
|
+
|
|
180
|
+
// 6. Summary
|
|
181
|
+
counts.summary = {
|
|
182
|
+
majorSections: counts.sections.total, gridContainers: counts.grids.total,
|
|
183
|
+
totalRepeatedItems: counts.repeatedItems.total, totalLinks: counts.navigation.allLinks,
|
|
184
|
+
totalImages: counts.media.images, totalButtons: counts.interactive.buttons,
|
|
185
|
+
recommendedItemCounts: {}
|
|
186
|
+
};
|
|
187
|
+
counts.grids.details.forEach(g => { if (g.visibleItems >= 3) counts.summary.recommendedItemCounts[g.selector] = g.visibleItems; });
|
|
188
|
+
Object.entries(counts.repeatedItems.byType).forEach(([t, d]) => { if (d.visible >= 2) counts.summary.recommendedItemCounts[t] = d.visible; });
|
|
189
|
+
|
|
190
|
+
return counts;
|
|
191
|
+
}
|