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.
Files changed (177) hide show
  1. package/README.md +13 -34
  2. package/SKILL.md +69 -45
  3. package/bin/cli.js +22 -4
  4. package/bin/commands/clone-site.js +31 -171
  5. package/bin/commands/help.js +19 -6
  6. package/bin/commands/init.js +9 -86
  7. package/bin/commands/uninstall.js +105 -0
  8. package/bin/commands/update.js +70 -0
  9. package/bin/commands/verify.js +7 -14
  10. package/bin/utils/paths.js +28 -0
  11. package/bin/utils/validate.js +2 -22
  12. package/bin/utils/version.js +23 -0
  13. package/docs/code-standards.md +789 -0
  14. package/docs/codebase-summary.md +533 -286
  15. package/docs/index.md +74 -0
  16. package/docs/project-overview-pdr.md +797 -0
  17. package/docs/system-architecture.md +718 -0
  18. package/package.json +14 -17
  19. package/src/ai/prompts/design-tokens/basic.md +80 -0
  20. package/src/ai/prompts/design-tokens/section-with-css.md +41 -0
  21. package/src/ai/prompts/design-tokens/section.md +48 -0
  22. package/src/ai/prompts/design-tokens/with-css.md +87 -0
  23. package/src/ai/prompts/structure-analysis/basic.md +55 -0
  24. package/src/ai/prompts/structure-analysis/with-context.md +59 -0
  25. package/src/ai/prompts/structure-analysis/with-dimensions.md +63 -0
  26. package/src/ai/prompts/structure-analysis/with-hierarchy.md +73 -0
  27. package/src/ai/prompts/ux-audit/aggregation.md +42 -0
  28. package/src/ai/prompts/ux-audit/desktop.md +92 -0
  29. package/src/ai/prompts/ux-audit/mobile.md +93 -0
  30. package/src/ai/prompts/ux-audit/tablet.md +92 -0
  31. package/src/core/animation/animation-extractor-ast.js +183 -0
  32. package/src/core/animation/animation-extractor-output.js +152 -0
  33. package/src/core/animation/animation-extractor.js +178 -0
  34. package/src/core/animation/state-capture-detection.js +200 -0
  35. package/src/core/animation/state-capture.js +193 -0
  36. package/src/core/capture/browser-context-pool.js +96 -0
  37. package/src/core/capture/multi-page-screenshot-page.js +110 -0
  38. package/src/core/capture/multi-page-screenshot.js +208 -0
  39. package/src/core/capture/screenshot-extraction.js +186 -0
  40. package/src/core/capture/screenshot-helpers.js +175 -0
  41. package/src/core/capture/screenshot-orchestrator.js +174 -0
  42. package/src/core/capture/screenshot-viewport.js +93 -0
  43. package/src/core/capture/screenshot.js +192 -0
  44. package/src/core/content/content-counter-dom.js +191 -0
  45. package/src/core/content/content-counter.js +76 -0
  46. package/src/core/css/breakpoint-detector.js +66 -0
  47. package/src/core/css/chromium-defaults.json +23 -0
  48. package/src/core/css/computed-style-extractor.js +102 -0
  49. package/src/core/css/css-chunker.js +103 -0
  50. package/src/core/css/filter-css-dead-code.js +120 -0
  51. package/src/core/css/filter-css-html-analyzer.js +110 -0
  52. package/src/core/css/filter-css-selector-matcher.js +172 -0
  53. package/src/core/css/filter-css.js +206 -0
  54. package/src/core/css/merge-css-atrule-processor.js +158 -0
  55. package/src/core/css/merge-css-file-io.js +68 -0
  56. package/src/core/css/merge-css.js +148 -0
  57. package/src/core/detection/framework-detector-routing.js +68 -0
  58. package/src/core/detection/framework-detector-signals.js +65 -0
  59. package/src/core/detection/framework-detector.js +198 -0
  60. package/src/core/dimension/dimension-extractor-card-detector.js +82 -0
  61. package/src/core/dimension/dimension-extractor.js +317 -0
  62. package/src/core/dimension/dimension-output-ai-summary.js +111 -0
  63. package/src/core/dimension/dimension-output.js +173 -0
  64. package/src/core/dimension/dom-tree-analyzer-tree-builders.js +95 -0
  65. package/src/core/dimension/dom-tree-analyzer.js +191 -0
  66. package/src/core/discovery/app-state-snapshot-capture.js +195 -0
  67. package/src/core/discovery/app-state-snapshot-utils.js +178 -0
  68. package/src/core/discovery/app-state-snapshot.js +131 -0
  69. package/src/core/discovery/discover-pages-routes.js +84 -0
  70. package/src/core/discovery/discover-pages-utils.js +177 -0
  71. package/src/core/discovery/discover-pages.js +191 -0
  72. package/src/core/html/html-extractor-inline-styler.js +70 -0
  73. package/src/core/html/html-extractor.js +147 -0
  74. package/src/core/html/semantic-enhancer-mappings.js +200 -0
  75. package/src/core/html/semantic-enhancer-page.js +148 -0
  76. package/src/core/html/semantic-enhancer.js +135 -0
  77. package/src/core/links/rewrite-links-css-rewriter.js +53 -0
  78. package/src/core/links/rewrite-links.js +173 -0
  79. package/src/core/media/asset-validator.js +118 -0
  80. package/src/core/media/extract-assets-downloader.js +187 -0
  81. package/src/core/media/extract-assets-page-scraper.js +115 -0
  82. package/src/core/media/extract-assets.js +159 -0
  83. package/src/core/media/video-capture-convert.js +200 -0
  84. package/src/core/media/video-capture.js +201 -0
  85. package/src/core/{lazy-loader.js → page-prep/lazy-loader.js} +37 -39
  86. package/src/core/section/section-cropper-helpers.js +43 -0
  87. package/src/core/{section-cropper.js → section/section-cropper.js} +11 -88
  88. package/src/core/section/section-detector-strategies.js +139 -0
  89. package/src/core/section/section-detector-utils.js +100 -0
  90. package/src/core/section/section-detector.js +88 -0
  91. package/src/core/tests/test-section-cropper.js +2 -2
  92. package/src/core/tests/test-section-detector.js +2 -2
  93. package/src/post-process/enhance-assets.js +29 -4
  94. package/src/post-process/fetch-images-unsplash-client.js +123 -0
  95. package/src/post-process/fetch-images.js +60 -263
  96. package/src/post-process/inject-gosnap.js +88 -0
  97. package/src/post-process/inject-icons-svg-replacer.js +76 -0
  98. package/src/post-process/inject-icons.js +47 -200
  99. package/src/route-discoverers/base-discoverer-utils.js +137 -0
  100. package/src/route-discoverers/base-discoverer.js +29 -118
  101. package/src/route-discoverers/index.js +1 -1
  102. package/src/shared/config.js +38 -0
  103. package/src/shared/error-codes.js +31 -0
  104. package/src/shared/viewports.js +46 -0
  105. package/src/utils/browser.js +0 -7
  106. package/src/utils/helpers.js +4 -0
  107. package/src/utils/log.js +12 -0
  108. package/src/utils/playwright-loader.js +76 -0
  109. package/src/utils/playwright.js +3 -69
  110. package/src/utils/progress.js +32 -0
  111. package/src/verification/generate-audit-report-css-fixes.js +52 -0
  112. package/src/verification/generate-audit-report-sections.js +158 -0
  113. package/src/verification/generate-audit-report.js +5 -281
  114. package/src/verification/quality-scorer.js +92 -0
  115. package/src/verification/verify-footer-checks.js +103 -0
  116. package/src/verification/verify-footer-helpers.js +178 -0
  117. package/src/verification/verify-footer.js +23 -381
  118. package/src/verification/verify-header-checks.js +104 -0
  119. package/src/verification/verify-header-helpers.js +156 -0
  120. package/src/verification/verify-header.js +23 -365
  121. package/src/verification/verify-layout-report.js +101 -0
  122. package/src/verification/verify-layout.js +13 -259
  123. package/src/verification/verify-menu-checks.js +104 -0
  124. package/src/verification/verify-menu-helpers.js +112 -0
  125. package/src/verification/verify-menu.js +17 -285
  126. package/src/verification/verify-slider-checks.js +115 -0
  127. package/src/verification/verify-slider-constants.js +65 -0
  128. package/src/verification/verify-slider-helpers.js +164 -0
  129. package/src/verification/verify-slider.js +23 -414
  130. package/.env.example +0 -14
  131. package/docs/basic-clone.md +0 -63
  132. package/docs/cli-reference.md +0 -316
  133. package/docs/design-clone-architecture.md +0 -492
  134. package/docs/pixel-perfect.md +0 -117
  135. package/docs/project-roadmap.md +0 -382
  136. package/docs/troubleshooting.md +0 -170
  137. package/requirements.txt +0 -5
  138. package/src/ai/__pycache__/analyze-structure.cpython-313.pyc +0 -0
  139. package/src/ai/__pycache__/extract-design-tokens.cpython-313.pyc +0 -0
  140. package/src/ai/analyze-structure.py +0 -375
  141. package/src/ai/extract-design-tokens.py +0 -782
  142. package/src/ai/prompts/__init__.py +0 -2
  143. package/src/ai/prompts/__pycache__/__init__.cpython-313.pyc +0 -0
  144. package/src/ai/prompts/__pycache__/design_tokens.cpython-313.pyc +0 -0
  145. package/src/ai/prompts/__pycache__/structure_analysis.cpython-313.pyc +0 -0
  146. package/src/ai/prompts/__pycache__/ux_audit.cpython-313.pyc +0 -0
  147. package/src/ai/prompts/design_tokens.py +0 -316
  148. package/src/ai/prompts/structure_analysis.py +0 -592
  149. package/src/ai/prompts/ux_audit.py +0 -198
  150. package/src/ai/ux-audit.js +0 -596
  151. package/src/core/animation-extractor.js +0 -526
  152. package/src/core/app-state-snapshot.js +0 -511
  153. package/src/core/content-counter.js +0 -342
  154. package/src/core/design-tokens.js +0 -103
  155. package/src/core/dimension-extractor.js +0 -438
  156. package/src/core/dimension-output.js +0 -305
  157. package/src/core/discover-pages.js +0 -542
  158. package/src/core/dom-tree-analyzer.js +0 -298
  159. package/src/core/extract-assets.js +0 -468
  160. package/src/core/filter-css.js +0 -499
  161. package/src/core/framework-detector.js +0 -538
  162. package/src/core/html-extractor.js +0 -212
  163. package/src/core/merge-css.js +0 -407
  164. package/src/core/multi-page-screenshot.js +0 -380
  165. package/src/core/rewrite-links.js +0 -226
  166. package/src/core/screenshot.js +0 -701
  167. package/src/core/section-detector.js +0 -386
  168. package/src/core/semantic-enhancer.js +0 -492
  169. package/src/core/state-capture.js +0 -598
  170. package/src/core/video-capture.js +0 -546
  171. package/src/utils/__init__.py +0 -16
  172. package/src/utils/__pycache__/__init__.cpython-313.pyc +0 -0
  173. package/src/utils/__pycache__/env.cpython-313.pyc +0 -0
  174. package/src/utils/env.py +0 -134
  175. /package/src/core/{css-extractor.js → css/css-extractor.js} +0 -0
  176. /package/src/core/{cookie-handler.js → page-prep/cookie-handler.js} +0 -0
  177. /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
+ }