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.
Files changed (66) hide show
  1. package/README.md +26 -12
  2. package/bin/commands/clone-site.js +75 -10
  3. package/bin/commands/init.js +33 -1
  4. package/bin/commands/verify.js +5 -3
  5. package/bin/utils/validate.js +24 -8
  6. package/docs/cli-reference.md +200 -2
  7. package/docs/codebase-summary.md +309 -0
  8. package/docs/design-clone-architecture.md +259 -42
  9. package/docs/pixel-perfect.md +35 -4
  10. package/docs/project-roadmap.md +382 -0
  11. package/docs/troubleshooting.md +5 -4
  12. package/package.json +10 -8
  13. package/src/ai/__pycache__/analyze-structure.cpython-313.pyc +0 -0
  14. package/src/ai/__pycache__/extract-design-tokens.cpython-313.pyc +0 -0
  15. package/src/ai/analyze-structure.py +73 -3
  16. package/src/ai/extract-design-tokens.py +356 -13
  17. package/src/ai/prompts/__pycache__/design_tokens.cpython-313.pyc +0 -0
  18. package/src/ai/prompts/__pycache__/structure_analysis.cpython-313.pyc +0 -0
  19. package/src/ai/prompts/__pycache__/ux_audit.cpython-313.pyc +0 -0
  20. package/src/ai/prompts/design_tokens.py +133 -0
  21. package/src/ai/prompts/structure_analysis.py +329 -10
  22. package/src/ai/prompts/ux_audit.py +198 -0
  23. package/src/ai/ux-audit.js +596 -0
  24. package/src/core/app-state-snapshot.js +511 -0
  25. package/src/core/content-counter.js +342 -0
  26. package/src/core/cookie-handler.js +1 -1
  27. package/src/core/css-extractor.js +4 -4
  28. package/src/core/dimension-extractor.js +93 -21
  29. package/src/core/dimension-output.js +103 -6
  30. package/src/core/discover-pages.js +242 -14
  31. package/src/core/dom-tree-analyzer.js +298 -0
  32. package/src/core/extract-assets.js +1 -1
  33. package/src/core/framework-detector.js +538 -0
  34. package/src/core/html-extractor.js +45 -4
  35. package/src/core/lazy-loader.js +7 -7
  36. package/src/core/multi-page-screenshot.js +9 -6
  37. package/src/core/page-readiness.js +8 -8
  38. package/src/core/screenshot.js +138 -9
  39. package/src/core/section-cropper.js +209 -0
  40. package/src/core/section-detector.js +386 -0
  41. package/src/core/semantic-enhancer.js +492 -0
  42. package/src/core/state-capture.js +18 -22
  43. package/src/core/tests/test-section-cropper.js +177 -0
  44. package/src/core/tests/test-section-detector.js +55 -0
  45. package/src/core/video-capture.js +152 -146
  46. package/src/route-discoverers/angular-discoverer.js +157 -0
  47. package/src/route-discoverers/astro-discoverer.js +123 -0
  48. package/src/route-discoverers/base-discoverer.js +242 -0
  49. package/src/route-discoverers/index.js +106 -0
  50. package/src/route-discoverers/next-discoverer.js +130 -0
  51. package/src/route-discoverers/nuxt-discoverer.js +138 -0
  52. package/src/route-discoverers/react-discoverer.js +139 -0
  53. package/src/route-discoverers/svelte-discoverer.js +109 -0
  54. package/src/route-discoverers/universal-discoverer.js +227 -0
  55. package/src/route-discoverers/vue-discoverer.js +118 -0
  56. package/src/utils/__init__.py +1 -1
  57. package/src/utils/__pycache__/__init__.cpython-313.pyc +0 -0
  58. package/src/utils/browser.js +11 -37
  59. package/src/utils/playwright.js +213 -0
  60. package/src/verification/generate-audit-report.js +398 -0
  61. package/src/verification/verify-footer.js +493 -0
  62. package/src/verification/verify-header.js +486 -0
  63. package/src/verification/verify-layout.js +2 -2
  64. package/src/verification/verify-menu.js +4 -20
  65. package/src/verification/verify-slider.js +533 -0
  66. 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 { extractCleanHtml, JS_FRAMEWORK_PATTERNS, MAX_HTML_SIZE } from './html-extractor.js';
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 - Puppeteer page instance
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: ['load', 'networkidle0'],
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 htmlResult = await extractCleanHtml(page, JS_FRAMEWORK_PATTERNS);
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 - Puppeteer 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 - Puppeteer 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 - Puppeteer 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 - Puppeteer 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));
@@ -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.setViewport(VIEWPORTS[viewport]);
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.waitForNetworkIdle({ timeout: NETWORK_IDLE_TIMEOUT });
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.setViewport(VIEWPORTS.desktop);
218
- await page.goto(navigateUrl, { waitUntil: ['load', 'networkidle0'], timeout: 60000 });
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 htmlResult = await extractCleanHtml(page, JS_FRAMEWORK_PATTERNS);
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 = { path: path.resolve(htmlPath), size: htmlSize, elementCount: htmlResult.elementCount };
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 Puppeteer #5255)
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.setViewport(VIEWPORTS.desktop);
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 };