similarbuild 0.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 (76) hide show
  1. package/CHANGELOG.md +110 -0
  2. package/LICENSE +21 -0
  3. package/README.md +301 -0
  4. package/bin/install.js +256 -0
  5. package/lib/copy-templates.mjs +52 -0
  6. package/lib/install-deps.mjs +62 -0
  7. package/lib/prompt-config.mjs +83 -0
  8. package/lib/verify-env.mjs +19 -0
  9. package/package.json +63 -0
  10. package/scripts/sync-templates.mjs +71 -0
  11. package/templates/commands/build-page.md +490 -0
  12. package/templates/commands/build-site.md +548 -0
  13. package/templates/commands/clip-section.md +519 -0
  14. package/templates/memory/anti-patterns.md +212 -0
  15. package/templates/memory/design-knowledge.md +225 -0
  16. package/templates/memory/fixes.md +163 -0
  17. package/templates/memory/patterns.md +681 -0
  18. package/templates/presets/shopify-section.yaml +51 -0
  19. package/templates/presets/wp-elementor.yaml +49 -0
  20. package/templates/reports/fixtures/mock-run-1.json +115 -0
  21. package/templates/reports/fixtures/mock-run-2.json +72 -0
  22. package/templates/reports/report-renderer.mjs +218 -0
  23. package/templates/reports/report-template.html +571 -0
  24. package/templates/skills/sb-build-shopify/SKILL.md +104 -0
  25. package/templates/skills/sb-build-shopify/references/shopify-build-rules.md +563 -0
  26. package/templates/skills/sb-build-shopify/scripts/build-shopify.mjs +637 -0
  27. package/templates/skills/sb-build-shopify/scripts/tests/test-build-shopify.mjs +424 -0
  28. package/templates/skills/sb-build-wp/SKILL.md +83 -0
  29. package/templates/skills/sb-build-wp/references/wp-build-rules.md +376 -0
  30. package/templates/skills/sb-build-wp/scripts/build-wp.mjs +327 -0
  31. package/templates/skills/sb-build-wp/scripts/tests/test-build-wp.mjs +224 -0
  32. package/templates/skills/sb-compare-visual/SKILL.md +121 -0
  33. package/templates/skills/sb-compare-visual/scripts/compare-visual.mjs +387 -0
  34. package/templates/skills/sb-compare-visual/scripts/lib/compare-tokens.mjs +273 -0
  35. package/templates/skills/sb-compare-visual/scripts/tests/test-compare-tokens.mjs +350 -0
  36. package/templates/skills/sb-compare-visual/scripts/tests/test-compare-visual.mjs +626 -0
  37. package/templates/skills/sb-crawl-and-list/SKILL.md +99 -0
  38. package/templates/skills/sb-crawl-and-list/scripts/crawl-and-list.mjs +437 -0
  39. package/templates/skills/sb-crawl-and-list/scripts/lib/blocklist-filter.mjs +176 -0
  40. package/templates/skills/sb-crawl-and-list/scripts/lib/fallback-crawler.mjs +107 -0
  41. package/templates/skills/sb-crawl-and-list/scripts/lib/page-classifier.mjs +89 -0
  42. package/templates/skills/sb-crawl-and-list/scripts/lib/sitemap-parser.mjs +118 -0
  43. package/templates/skills/sb-crawl-and-list/scripts/tests/test-blocklist-filter.mjs +204 -0
  44. package/templates/skills/sb-crawl-and-list/scripts/tests/test-crawl-and-list.mjs +276 -0
  45. package/templates/skills/sb-crawl-and-list/scripts/tests/test-fallback-crawler.mjs +243 -0
  46. package/templates/skills/sb-crawl-and-list/scripts/tests/test-page-classifier.mjs +120 -0
  47. package/templates/skills/sb-crawl-and-list/scripts/tests/test-sitemap-parser.mjs +157 -0
  48. package/templates/skills/sb-extract-assets/SKILL.md +112 -0
  49. package/templates/skills/sb-extract-assets/scripts/extract-assets.mjs +484 -0
  50. package/templates/skills/sb-extract-assets/scripts/tests/test-extract-assets.mjs +112 -0
  51. package/templates/skills/sb-inspect-live/SKILL.md +105 -0
  52. package/templates/skills/sb-inspect-live/scripts/inspect-live.mjs +693 -0
  53. package/templates/skills/sb-inspect-live/scripts/tests/test-inspect-live.mjs +181 -0
  54. package/templates/skills/sb-review-checks/SKILL.md +113 -0
  55. package/templates/skills/sb-review-checks/references/review-rules.md +195 -0
  56. package/templates/skills/sb-review-checks/scripts/lib/anti-patterns.mjs +379 -0
  57. package/templates/skills/sb-review-checks/scripts/lib/cross-reference.mjs +115 -0
  58. package/templates/skills/sb-review-checks/scripts/lib/design-quality.mjs +541 -0
  59. package/templates/skills/sb-review-checks/scripts/review-checks.mjs +250 -0
  60. package/templates/skills/sb-review-checks/scripts/tests/test-anti-patterns.mjs +343 -0
  61. package/templates/skills/sb-review-checks/scripts/tests/test-cross-reference.mjs +170 -0
  62. package/templates/skills/sb-review-checks/scripts/tests/test-design-quality.mjs +493 -0
  63. package/templates/skills/sb-review-checks/scripts/tests/test-review-checks.mjs +267 -0
  64. package/templates/skills/sb-tweak/SKILL.md +130 -0
  65. package/templates/skills/sb-tweak/references/tweak-patterns.md +157 -0
  66. package/templates/skills/sb-tweak/scripts/lib/diff-summarizer.mjs +140 -0
  67. package/templates/skills/sb-tweak/scripts/lib/element-locator.mjs +507 -0
  68. package/templates/skills/sb-tweak/scripts/lib/intent-parser.mjs +324 -0
  69. package/templates/skills/sb-tweak/scripts/tests/test-diff-summarizer.mjs +248 -0
  70. package/templates/skills/sb-tweak/scripts/tests/test-element-locator.mjs +418 -0
  71. package/templates/skills/sb-tweak/scripts/tests/test-intent-parser.mjs +496 -0
  72. package/templates/skills/sb-tweak/scripts/tests/test-tweak.mjs +407 -0
  73. package/templates/skills/sb-tweak/scripts/tweak.mjs +656 -0
  74. package/templates/skills/sb-validate-render/SKILL.md +120 -0
  75. package/templates/skills/sb-validate-render/scripts/tests/test-validate-render.mjs +304 -0
  76. package/templates/skills/sb-validate-render/scripts/validate-render.mjs +645 -0
@@ -0,0 +1,693 @@
1
+ #!/usr/bin/env node
2
+ // inspect-live.mjs — Capture a live page's DOM, computed tokens, screenshot,
3
+ // pseudo-elements, and image URLs for SimilarBuild visual cloning.
4
+ //
5
+ // Mobile-first by default (390x844). Stealth chromium with realistic iPhone UA.
6
+ // Triggers lazy-load via step-scroll + force-eager rewrite of <img loading="lazy">.
7
+ // Outputs a single JSON object to stdout AND writes inspection.json + screenshot.png
8
+ // into --output-dir. Logs progress to stderr.
9
+
10
+ import { parseArgs } from 'node:util'
11
+ import { mkdir, writeFile } from 'node:fs/promises'
12
+ import { join, resolve } from 'node:path'
13
+ import { statSync } from 'node:fs'
14
+
15
+ // playwright-extra + puppeteer-extra-plugin-stealth are imported lazily inside
16
+ // main() so --help and arg validation work without the deps installed.
17
+
18
+ const HELP = `
19
+ inspect-live.mjs — Inspect a live URL for SimilarBuild visual cloning.
20
+
21
+ Required:
22
+ --url <url> Absolute URL to inspect.
23
+ --output-dir <dir> Directory for screenshot.png + inspection.json.
24
+
25
+ Optional:
26
+ --viewport-width <px> Default 390 (mobile-first).
27
+ --viewport-height <px> Default 844.
28
+ --selector <css> Scope inspection to one element + descendants.
29
+ --wait-strategy <name> lazy-load (default) | auto | kaching-bundles | judge-me
30
+ --max-depth <n> DOM walk max depth (default 8).
31
+ --max-children <n> Max children kept per node (default 60).
32
+ --max-text <n> Max chars of direct text per node (default 240).
33
+ --timeout <ms> Per-step timeout (default 30000).
34
+ --help Show this message.
35
+
36
+ Exit codes: 0=ok, 1=script error, 2=invalid args.
37
+ `
38
+
39
+ function fail(msg, code = 2) {
40
+ process.stderr.write(`[sb-inspect-live] ${msg}\n`)
41
+ process.exit(code)
42
+ }
43
+
44
+ function log(msg) {
45
+ process.stderr.write(`[sb-inspect-live] ${msg}\n`)
46
+ }
47
+
48
+ const { values } = parseArgs({
49
+ options: {
50
+ url: { type: 'string' },
51
+ 'viewport-width': { type: 'string', default: '390' },
52
+ 'viewport-height': { type: 'string', default: '844' },
53
+ selector: { type: 'string' },
54
+ 'wait-strategy': { type: 'string', default: 'lazy-load' },
55
+ 'output-dir': { type: 'string' },
56
+ 'max-depth': { type: 'string', default: '8' },
57
+ 'max-children': { type: 'string', default: '60' },
58
+ 'max-text': { type: 'string', default: '240' },
59
+ timeout: { type: 'string', default: '30000' },
60
+ help: { type: 'boolean', default: false },
61
+ },
62
+ strict: false,
63
+ })
64
+
65
+ if (values.help) {
66
+ process.stdout.write(HELP)
67
+ process.exit(0)
68
+ }
69
+
70
+ if (!values.url) fail('missing --url')
71
+ if (!values['output-dir']) fail('missing --output-dir')
72
+
73
+ const URL = values.url
74
+ const VIEWPORT_W = parseInt(values['viewport-width'], 10)
75
+ const VIEWPORT_H = parseInt(values['viewport-height'], 10)
76
+ const SELECTOR = values.selector || null
77
+ const WAIT_STRATEGY = values['wait-strategy']
78
+ const OUTPUT_DIR = resolve(values['output-dir'])
79
+ const MAX_DEPTH = parseInt(values['max-depth'], 10)
80
+ const MAX_CHILDREN = parseInt(values['max-children'], 10)
81
+ const MAX_TEXT = parseInt(values['max-text'], 10)
82
+ const TIMEOUT = parseInt(values.timeout, 10)
83
+
84
+ if (!Number.isFinite(VIEWPORT_W) || !Number.isFinite(VIEWPORT_H)) fail('viewport must be numeric')
85
+
86
+ // Known widget waits — selectors for third-party scripts whose content arrives
87
+ // after the main page load. Hardcoded list, evolved as new widgets are encountered.
88
+ const WIDGET_WAITS = {
89
+ 'kaching-bundles': ['.kaching-bundles', '[class*="kaching"]', '[id*="kaching"]'],
90
+ 'judge-me': ['.jdgm-rev-widg', '.jdgm-widget', '.jdgm-prev-badge', '[id*="jdgm"]'],
91
+ }
92
+
93
+ // Bot-challenge / blocked-page heuristics. If body text matches any of these
94
+ // after settle, the script flags widgetBlocked: true so the orchestrator can
95
+ // invoke Plan B instead of building from a placeholder.
96
+ const BLOCK_PATTERNS = [
97
+ /just a moment/i,
98
+ /checking your browser/i,
99
+ /enable javascript( and cookies)? to continue/i,
100
+ /cloudflare/i,
101
+ /access denied/i,
102
+ /verifying you are human/i,
103
+ /please complete the security check/i,
104
+ /captcha/i,
105
+ /forbidden/i,
106
+ ]
107
+
108
+ // Title-only block patterns (Cloudflare etc. often put the challenge in
109
+ // <title> with a near-empty body — pattern #23 of the plan covers this).
110
+ const BLOCK_TITLE_PATTERNS = [
111
+ /just a moment/i,
112
+ /attention required/i,
113
+ /access denied/i,
114
+ /forbidden/i,
115
+ /captcha/i,
116
+ ]
117
+
118
+ async function main() {
119
+ await mkdir(OUTPUT_DIR, { recursive: true })
120
+
121
+ const screenshotPath = join(OUTPUT_DIR, 'screenshot.png')
122
+ const jsonPath = join(OUTPUT_DIR, 'inspection.json')
123
+
124
+ let chromium, devices, StealthPlugin
125
+ try {
126
+ ;({ chromium, devices } = await import('playwright-extra'))
127
+ StealthPlugin = (await import('puppeteer-extra-plugin-stealth')).default
128
+ } catch (err) {
129
+ process.stderr.write(
130
+ `[sb-inspect-live] missing dependency: ${err?.message || err}\n` +
131
+ `Install with: npm i playwright-extra puppeteer-extra-plugin-stealth playwright && npx playwright install chromium\n`,
132
+ )
133
+ process.exit(1)
134
+ }
135
+ chromium.use(StealthPlugin())
136
+
137
+ log(`launching chromium (stealth) for ${URL} @ ${VIEWPORT_W}x${VIEWPORT_H}`)
138
+
139
+ const browser = await chromium.launch({ headless: true })
140
+
141
+ // Use Playwright's iPhone 14 device profile as the base, but force the requested
142
+ // viewport so mobile-first 390 default is honored even if the device profile differs.
143
+ const iphone = devices['iPhone 14'] || {}
144
+ const context = await browser.newContext({
145
+ ...iphone,
146
+ viewport: { width: VIEWPORT_W, height: VIEWPORT_H },
147
+ userAgent:
148
+ iphone.userAgent ||
149
+ 'Mozilla/5.0 (iPhone; CPU iPhone OS 17_2 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.2 Mobile/15E148 Safari/604.1',
150
+ deviceScaleFactor: iphone.deviceScaleFactor || 3,
151
+ isMobile: true,
152
+ hasTouch: true,
153
+ locale: 'en-US',
154
+ })
155
+
156
+ const page = await context.newPage()
157
+ page.setDefaultTimeout(TIMEOUT)
158
+
159
+ const result = {
160
+ url: URL,
161
+ viewport: { width: VIEWPORT_W, height: VIEWPORT_H },
162
+ sectionType: 'unknown',
163
+ sectionBoundingBox: null,
164
+ tokens: {},
165
+ dom: [],
166
+ pseudoElements: [],
167
+ imgUrls: [],
168
+ screenshot: screenshotPath,
169
+ widgetBlocked: false,
170
+ }
171
+
172
+ try {
173
+ log('navigating (waitUntil: domcontentloaded)')
174
+ await page.goto(URL, { waitUntil: 'domcontentloaded', timeout: TIMEOUT })
175
+
176
+ // Initial settle. Many themes hydrate ~2-3s after DCL.
177
+ await page.waitForTimeout(3500)
178
+
179
+ // Step-scroll to trigger lazy-load observers. Range 0→12000 covers
180
+ // most long mobile pages; pages shorter than that scroll past the bottom
181
+ // and the browser clamps — harmless.
182
+ log('step-scrolling to trigger lazy-load')
183
+ await page.evaluate(async () => {
184
+ const sleep = (ms) => new Promise((r) => setTimeout(r, ms))
185
+ for (let y = 0; y <= 12000; y += 400) {
186
+ window.scrollTo(0, y)
187
+ await sleep(150)
188
+ }
189
+ window.scrollTo(0, 0)
190
+ })
191
+
192
+ // Force-eager: any <img loading="lazy"> that didn't intersect during scroll
193
+ // gets rewritten to eager and re-fetched. <picture> sources too.
194
+ log('forcing eager image fetch')
195
+ await page.evaluate(async () => {
196
+ const sleep = (ms) => new Promise((r) => setTimeout(r, ms))
197
+ const imgs = Array.from(document.querySelectorAll('img'))
198
+ for (const img of imgs) {
199
+ try {
200
+ img.loading = 'eager'
201
+ if (!img.complete || img.naturalWidth === 0) {
202
+ const src = img.currentSrc || img.src
203
+ if (src) {
204
+ img.src = ''
205
+ img.src = src
206
+ }
207
+ }
208
+ } catch (_) {}
209
+ }
210
+ await sleep(1200)
211
+ })
212
+
213
+ // Apply additional widget waits if requested.
214
+ const widgetsToWait =
215
+ WAIT_STRATEGY === 'auto'
216
+ ? Object.values(WIDGET_WAITS).flat()
217
+ : WIDGET_WAITS[WAIT_STRATEGY] || []
218
+
219
+ for (const sel of widgetsToWait) {
220
+ try {
221
+ await page.waitForSelector(sel, { timeout: 4000, state: 'attached' })
222
+ log(`widget appeared: ${sel}`)
223
+ } catch (_) {
224
+ // widget not present on this page — fine
225
+ }
226
+ }
227
+
228
+ // Final settle to let any post-widget reflow finish.
229
+ await page.waitForTimeout(800)
230
+
231
+ // Detect block / challenge pages with a composite signal — Pattern #23 of
232
+ // the plan. The old `bodyHtmlLen < 1024` heuristic alone caused false
233
+ // positives on legitimately minimalist pages (example.com 528B, info.cern.ch
234
+ // 646B). New rule:
235
+ // 1. extreme-tiny body (< 256B) → blocked (real pages always have at
236
+ // least a meta/h1/p stack that exceeds this)
237
+ // 2. pattern in body text → blocked (covers Cloudflare/CAPTCHA prose)
238
+ // 3. pattern in <title> → blocked (Cloudflare often hides challenge in
239
+ // the title alone with near-empty body)
240
+ // 4. body 256-1024B AND meaningful-children count < 2 → blocked
241
+ // (small AND structurally bare = challenge page; small alone = OK)
242
+ const docState = await page.evaluate(() => ({
243
+ title: (document.title || '').slice(0, 200),
244
+ bodyText: (document.body?.innerText || '').slice(0, 4000),
245
+ bodyHtmlLen: (document.body?.innerHTML || '').length,
246
+ meaningfulChildren: (() => {
247
+ if (!document.body) return 0
248
+ let n = 0
249
+ for (const el of document.body.children) {
250
+ const tag = (el.tagName || '').toLowerCase()
251
+ if (tag === 'script' || tag === 'style' || tag === 'noscript') continue
252
+ n++
253
+ }
254
+ return n
255
+ })(),
256
+ }))
257
+ const blockedByBodyPattern = BLOCK_PATTERNS.some((re) => re.test(docState.bodyText))
258
+ const blockedByTitlePattern = BLOCK_TITLE_PATTERNS.some((re) => re.test(docState.title))
259
+ const extremeTiny = docState.bodyHtmlLen < 256
260
+ const smallAndBare =
261
+ docState.bodyHtmlLen >= 256 &&
262
+ docState.bodyHtmlLen < 1024 &&
263
+ docState.meaningfulChildren < 2
264
+ if (blockedByBodyPattern || blockedByTitlePattern || extremeTiny || smallAndBare) {
265
+ result.widgetBlocked = true
266
+ log(
267
+ `widgetBlocked=true (bodyPattern=${blockedByBodyPattern}, titlePattern=${blockedByTitlePattern}, extremeTiny=${extremeTiny}, smallAndBare=${smallAndBare}, bodyLen=${docState.bodyHtmlLen}, children=${docState.meaningfulChildren}, title=${JSON.stringify(docState.title.slice(0, 60))})`,
268
+ )
269
+ }
270
+
271
+ // Screenshot — full page if no selector, element-only otherwise.
272
+ log(`capturing screenshot → ${screenshotPath}`)
273
+ if (SELECTOR) {
274
+ const el = await page.$(SELECTOR)
275
+ if (!el) {
276
+ log(`selector ${SELECTOR} matched no elements; falling back to full-page screenshot`)
277
+ await page.screenshot({ path: screenshotPath, fullPage: true })
278
+ } else {
279
+ await el.screenshot({ path: screenshotPath })
280
+ }
281
+ } else {
282
+ await page.screenshot({ path: screenshotPath, fullPage: true })
283
+ }
284
+
285
+ // The big in-page extraction. Everything below runs in the browser context.
286
+ log('extracting DOM, tokens, pseudo-elements, image URLs')
287
+ const extracted = await page.evaluate(extractInPage, {
288
+ selector: SELECTOR,
289
+ maxDepth: MAX_DEPTH,
290
+ maxChildren: MAX_CHILDREN,
291
+ maxText: MAX_TEXT,
292
+ })
293
+
294
+ Object.assign(result, extracted)
295
+
296
+ // Sanity check — DOM came back empty even though body had content.
297
+ if (!result.widgetBlocked && Array.isArray(result.dom) && result.dom.length === 0 && docState.bodyHtmlLen > 1024) {
298
+ log('warning: extracted DOM is empty but body has content — selector may be wrong')
299
+ }
300
+ } catch (err) {
301
+ process.stderr.write(`[sb-inspect-live] error: ${err?.stack ? err.stack : err}\n`)
302
+ await browser.close().catch(() => {})
303
+ process.exit(1)
304
+ }
305
+
306
+ await browser.close()
307
+
308
+ // Persist + emit.
309
+ await writeFile(jsonPath, JSON.stringify(result, null, 2), 'utf8')
310
+
311
+ // Tiny screenshots are a soft signal of failed render even when not flagged.
312
+ try {
313
+ const sz = statSync(screenshotPath).size
314
+ if (sz < 4096 && !result.widgetBlocked) {
315
+ log(`warning: screenshot is only ${sz} bytes — page may not have rendered`)
316
+ result.widgetBlocked = true
317
+ result._reason = 'tiny_screenshot'
318
+ await writeFile(jsonPath, JSON.stringify(result, null, 2), 'utf8')
319
+ }
320
+ } catch (_) {}
321
+
322
+ process.stdout.write(JSON.stringify(result))
323
+ process.stdout.write('\n')
324
+ }
325
+
326
+ // =====================================================================
327
+ // In-page extraction. This function is serialized to a string and runs
328
+ // inside the browser via page.evaluate, so it must be self-contained.
329
+ // =====================================================================
330
+ function extractInPage({ selector, maxDepth, maxChildren, maxText }) {
331
+ const root = selector ? document.querySelector(selector) : document.body
332
+ if (!root) {
333
+ return {
334
+ sectionType: 'unknown',
335
+ sectionBoundingBox: null,
336
+ tokens: {},
337
+ dom: [],
338
+ pseudoElements: [],
339
+ imgUrls: [],
340
+ }
341
+ }
342
+
343
+ // ---- Section-type heuristic ----
344
+ // Class/role-based first; falls back to heading presence. Lives here for
345
+ // the standalone build; production framework will source from
346
+ // <plugin>/memory/patterns.md and pass via flags.
347
+ function classifySection(el) {
348
+ const cls = (typeof el.className === 'string' ? el.className : '').toLowerCase()
349
+ const tag = el.tagName.toLowerCase()
350
+ const id = (el.id || '').toLowerCase()
351
+ const blob = `${tag} ${cls} ${id}`
352
+ if (/\bhero\b/.test(blob)) return 'hero'
353
+ if (/\bbanner\b/.test(blob)) return 'banner'
354
+ if (/\bproduct(s|-grid|-list|-card)?\b/.test(blob)) return 'product-grid'
355
+ if (/\b(testimonial|review|jdgm)/.test(blob)) return 'testimonials'
356
+ if (/\b(cta|call.?to.?action)\b/.test(blob)) return 'cta'
357
+ if (/\bfooter\b/.test(blob) || tag === 'footer') return 'footer'
358
+ if (/\b(header|nav|navbar)\b/.test(blob) || tag === 'header' || tag === 'nav') return 'header'
359
+ if (/\bfeature/.test(blob)) return 'features'
360
+ if (/\b(faq|accordion)\b/.test(blob)) return 'faq'
361
+ if (/\b(bundle|kaching)\b/.test(blob)) return 'bundle'
362
+ if (/\bgallery\b/.test(blob)) return 'gallery'
363
+ return null
364
+ }
365
+
366
+ // ---- Computed-style picker ----
367
+ // Whitelist of properties we care about. Reading getComputedStyle is cheap;
368
+ // building giant objects is not — so we stick to tokens that drive layout
369
+ // and visual fidelity for downstream reconstruction.
370
+ const TYPO_PROPS = [
371
+ 'font-family',
372
+ 'font-size',
373
+ 'font-weight',
374
+ 'font-style',
375
+ 'line-height',
376
+ 'letter-spacing',
377
+ 'text-transform',
378
+ 'text-align',
379
+ 'color',
380
+ ]
381
+ const BOX_PROPS = [
382
+ 'display',
383
+ 'position',
384
+ 'top',
385
+ 'right',
386
+ 'bottom',
387
+ 'left',
388
+ 'width',
389
+ 'height',
390
+ 'max-width',
391
+ 'min-height',
392
+ 'margin',
393
+ 'padding',
394
+ 'gap',
395
+ 'flex-direction',
396
+ 'justify-content',
397
+ 'align-items',
398
+ 'flex-wrap',
399
+ 'grid-template-columns',
400
+ 'grid-template-rows',
401
+ 'overflow',
402
+ 'z-index',
403
+ ]
404
+ const VISUAL_PROPS = [
405
+ 'background-color',
406
+ 'background-image',
407
+ 'background-size',
408
+ 'background-position',
409
+ 'background-repeat',
410
+ 'border',
411
+ 'border-radius',
412
+ 'box-shadow',
413
+ 'opacity',
414
+ 'transform',
415
+ 'filter',
416
+ ]
417
+ const PSEUDO_PROPS = [
418
+ 'content',
419
+ 'color',
420
+ 'background-color',
421
+ 'background-image',
422
+ 'font-size',
423
+ 'font-weight',
424
+ 'position',
425
+ 'top',
426
+ 'right',
427
+ 'bottom',
428
+ 'left',
429
+ 'width',
430
+ 'height',
431
+ 'transform',
432
+ 'opacity',
433
+ 'border-radius',
434
+ 'box-shadow',
435
+ 'display',
436
+ ]
437
+
438
+ function pick(style, props) {
439
+ const out = {}
440
+ for (const p of props) {
441
+ const v = style.getPropertyValue(p)
442
+ if (v && v !== 'normal' && v !== 'auto') out[p] = v.trim()
443
+ }
444
+ return out
445
+ }
446
+
447
+ function bbox(el) {
448
+ const r = el.getBoundingClientRect()
449
+ return {
450
+ x: Math.round(r.x + window.scrollX),
451
+ y: Math.round(r.y + window.scrollY),
452
+ w: Math.round(r.width),
453
+ h: Math.round(r.height),
454
+ }
455
+ }
456
+
457
+ function directText(el) {
458
+ let t = ''
459
+ for (const n of el.childNodes) {
460
+ if (n.nodeType === 3) t += n.nodeValue
461
+ }
462
+ t = t.replace(/\s+/g, ' ').trim()
463
+ if (t.length > maxText) t = `${t.slice(0, maxText)}…`
464
+ return t
465
+ }
466
+
467
+ // Skip script/style/noscript/template — they don't contribute to visual output.
468
+ const SKIP_TAGS = new Set(['script', 'style', 'noscript', 'template', 'meta', 'link'])
469
+
470
+ function walk(el, depth) {
471
+ if (depth > maxDepth) return null
472
+ if (SKIP_TAGS.has(el.tagName.toLowerCase())) return null
473
+ const style = window.getComputedStyle(el)
474
+ if (style.display === 'none' || style.visibility === 'hidden') return null
475
+
476
+ const node = {
477
+ tag: el.tagName.toLowerCase(),
478
+ classes: typeof el.className === 'string' ? el.className.split(/\s+/).filter(Boolean) : [],
479
+ id: el.id || null,
480
+ attrs: {},
481
+ text: directText(el),
482
+ bbox: bbox(el),
483
+ computed: {
484
+ ...pick(style, TYPO_PROPS),
485
+ ...pick(style, BOX_PROPS),
486
+ ...pick(style, VISUAL_PROPS),
487
+ },
488
+ children: [],
489
+ }
490
+
491
+ // Capture salient attributes only — keeps payload small while preserving
492
+ // semantic info the builder needs.
493
+ for (const name of ['href', 'src', 'srcset', 'alt', 'role', 'aria-label', 'data-section-id', 'data-section-type', 'data-block-id']) {
494
+ const v = el.getAttribute?.(name)
495
+ if (v) node.attrs[name] = v
496
+ }
497
+
498
+ let count = 0
499
+ for (const child of el.children) {
500
+ if (count >= maxChildren) {
501
+ node.children.push({ tag: '_truncated_', remaining: el.children.length - count })
502
+ break
503
+ }
504
+ const c = walk(child, depth + 1)
505
+ if (c) {
506
+ node.children.push(c)
507
+ count++
508
+ }
509
+ }
510
+
511
+ return node
512
+ }
513
+
514
+ // ---- Pseudo-elements walk ----
515
+ // For every visible element, check ::before and ::after. Only keep those
516
+ // with non-empty content — getComputedStyle returns 'none' for absent
517
+ // pseudo-elements, so this filters automatically.
518
+ const pseudoElements = []
519
+ function pseudoSelectorFor(el) {
520
+ if (el.id) return `#${el.id}`
521
+ const cls = typeof el.className === 'string' ? el.className.trim().split(/\s+/).filter(Boolean) : []
522
+ if (cls.length) return `${el.tagName.toLowerCase()}.${cls.slice(0, 3).join('.')}`
523
+ return el.tagName.toLowerCase()
524
+ }
525
+ function walkPseudo(el) {
526
+ if (SKIP_TAGS.has(el.tagName.toLowerCase())) return
527
+ for (const which of ['::before', '::after']) {
528
+ try {
529
+ const ps = window.getComputedStyle(el, which)
530
+ const content = ps.getPropertyValue('content')
531
+ if (content && content !== 'none' && content !== 'normal') {
532
+ pseudoElements.push({
533
+ selector: pseudoSelectorFor(el),
534
+ pseudo: which,
535
+ content,
536
+ computed: pick(ps, PSEUDO_PROPS),
537
+ })
538
+ }
539
+ } catch (_) {}
540
+ }
541
+ for (const child of el.children) walkPseudo(child)
542
+ }
543
+
544
+ // ---- Image URL collection ----
545
+ const seen = new Set()
546
+ const imgUrls = []
547
+ function nearestSection(el) {
548
+ let cur = el
549
+ while (cur && cur !== document.body) {
550
+ const cls = (typeof cur.className === 'string' ? cur.className : '').toLowerCase()
551
+ if (cur.id || /section|hero|banner|footer|header|product|testimonial|gallery|feature|faq|cta|bundle/.test(cls)) {
552
+ return cur.id || cls.split(/\s+/).filter(Boolean)[0] || cur.tagName.toLowerCase()
553
+ }
554
+ cur = cur.parentElement
555
+ }
556
+ return 'page'
557
+ }
558
+ function pushImg(url, type, contextEl, alt) {
559
+ if (!url) return
560
+ const trimmed = url.trim()
561
+ if (!trimmed || trimmed.startsWith('data:')) return
562
+ let abs
563
+ try {
564
+ abs = new URL(trimmed, location.href).toString()
565
+ } catch (_) {
566
+ return
567
+ }
568
+ const key = `${type}|${abs}`
569
+ if (seen.has(key)) return
570
+ seen.add(key)
571
+ imgUrls.push({
572
+ url: abs,
573
+ type,
574
+ context: nearestSection(contextEl),
575
+ alt: alt || '',
576
+ bbox: bbox(contextEl),
577
+ })
578
+ }
579
+ // <img> elements
580
+ for (const img of root.querySelectorAll('img')) {
581
+ pushImg(img.currentSrc || img.src, 'img', img, img.alt)
582
+ if (img.srcset) {
583
+ for (const part of img.srcset.split(',')) {
584
+ const u = part.trim().split(/\s+/)[0]
585
+ pushImg(u, 'img-srcset', img, img.alt)
586
+ }
587
+ }
588
+ }
589
+ // <source> inside <picture>
590
+ for (const source of root.querySelectorAll('source')) {
591
+ if (source.srcset) {
592
+ for (const part of source.srcset.split(',')) {
593
+ const u = part.trim().split(/\s+/)[0]
594
+ pushImg(u, 'source-srcset', source.parentElement || source, source.getAttribute('alt') || '')
595
+ }
596
+ }
597
+ if (source.src) pushImg(source.src, 'source', source.parentElement || source, '')
598
+ }
599
+ // background-image on every visible element
600
+ const allEls = root === document.body ? document.querySelectorAll('*') : root.querySelectorAll('*')
601
+ const elsToScan = root === document.body ? [...allEls] : [root, ...allEls]
602
+ for (const el of elsToScan) {
603
+ const bg = window.getComputedStyle(el).getPropertyValue('background-image')
604
+ if (!bg || bg === 'none') continue
605
+ const matches = bg.matchAll(/url\((['"]?)([^'")]+)\1\)/g)
606
+ for (const m of matches) {
607
+ pushImg(m[2], 'background', el, el.getAttribute('alt') || '')
608
+ }
609
+ }
610
+
611
+ // ---- Token aggregation ----
612
+ // Pick representative elements and capture the typography/color/layout
613
+ // signature. These feed sb-build-output's preset-resolution pass.
614
+ function token(el, props) {
615
+ if (!el) return null
616
+ return pick(window.getComputedStyle(el), props)
617
+ }
618
+ const firstButton = root.querySelector('button, .btn, [role="button"], a.button, a.btn')
619
+ const firstContainer = root.querySelector('.container, .wrapper, main, section')
620
+ const tokens = {
621
+ typography: {
622
+ h1: token(root.querySelector('h1'), TYPO_PROPS),
623
+ h2: token(root.querySelector('h2'), TYPO_PROPS),
624
+ h3: token(root.querySelector('h3'), TYPO_PROPS),
625
+ body: token(root.querySelector('p') || root, TYPO_PROPS),
626
+ button: token(firstButton, TYPO_PROPS),
627
+ link: token(root.querySelector('a'), TYPO_PROPS),
628
+ },
629
+ colors: {
630
+ background: window.getComputedStyle(document.body).getPropertyValue('background-color').trim(),
631
+ text: window.getComputedStyle(document.body).getPropertyValue('color').trim(),
632
+ primary: firstButton ? window.getComputedStyle(firstButton).getPropertyValue('background-color').trim() : null,
633
+ accent: token(root.querySelector('a'), ['color'])?.color || null,
634
+ },
635
+ layout: {
636
+ container: token(firstContainer, ['max-width', 'padding', 'margin', 'width']),
637
+ section: token(root, ['padding', 'margin', 'gap']),
638
+ },
639
+ }
640
+
641
+ // ---- Section element + bounding box ----
642
+ // The full-page screenshot covers the whole document, so a sectionType label
643
+ // alone leaves the comparator with no way to crop the live to match a
644
+ // section-only build. Anti-pattern #10. Surface the actual section bbox so
645
+ // the comparator can extract it before pixelmatch.
646
+ //
647
+ // Rules:
648
+ // - With explicit --selector, the live screenshot is element-only already
649
+ // (page.$(selector).screenshot at line ~236), so no crop is needed and
650
+ // sectionBoundingBox is null.
651
+ // - Without --selector, search root → descendants for the first element
652
+ // with a known section class/tag signature; expose its document-relative
653
+ // bbox in CSS pixels.
654
+ // - If only the h1 fallback fires (sectionType=hero by heading presence),
655
+ // bbox stays null — we don't know which subtree is "the hero" with
656
+ // enough confidence to crop on it.
657
+ function findSectionAndBox(rootEl, hasExplicitSelector) {
658
+ if (hasExplicitSelector) {
659
+ const direct = classifySection(rootEl)
660
+ const sectionType = direct || (rootEl.querySelector('h1') ? 'hero' : 'section')
661
+ return { sectionType, sectionBoundingBox: null }
662
+ }
663
+ const direct = classifySection(rootEl)
664
+ if (direct) {
665
+ return { sectionType: direct, sectionBoundingBox: bbox(rootEl) }
666
+ }
667
+ const queue = [...rootEl.children]
668
+ while (queue.length) {
669
+ const el = queue.shift()
670
+ if (SKIP_TAGS.has(el.tagName.toLowerCase())) continue
671
+ const t = classifySection(el)
672
+ if (t) {
673
+ return { sectionType: t, sectionBoundingBox: bbox(el) }
674
+ }
675
+ for (const c of el.children) queue.push(c)
676
+ }
677
+ if (rootEl.querySelector('h1')) {
678
+ return { sectionType: 'hero', sectionBoundingBox: null }
679
+ }
680
+ return { sectionType: 'section', sectionBoundingBox: null }
681
+ }
682
+
683
+ walkPseudo(root)
684
+ const dom = [walk(root, 0)].filter(Boolean)
685
+ const { sectionType, sectionBoundingBox } = findSectionAndBox(root, !!selector)
686
+
687
+ return { sectionType, sectionBoundingBox, tokens, dom, pseudoElements, imgUrls }
688
+ }
689
+
690
+ main().catch((err) => {
691
+ process.stderr.write(`[sb-inspect-live] fatal: ${err?.stack ? err.stack : err}\n`)
692
+ process.exit(1)
693
+ })