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,645 @@
1
+ #!/usr/bin/env node
2
+ // validate-render.mjs — Render a built fragment locally with the destination's
3
+ // theme reset injected, capture a screenshot, and probe key tokens + geometry.
4
+ //
5
+ // This closes the build → validate loop for SimilarBuild: take whatever
6
+ // `sb-build-wp` (HTML) or a future `sb-build-shopify` (Liquid) produced,
7
+ // simulate what happens when the user pastes it into the destination CMS by
8
+ // wrapping with the destination's blanket selectors and forcing the destination's
9
+ // !important-laced typography rules over the fragment, then measure how the
10
+ // browser actually renders it.
11
+ //
12
+ // Mobile-first: viewport 390x844 by default. Uses Playwright `setContent` (NOT
13
+ // `goto`) — the HTML is in hand. Outputs a single JSON object to stdout AND
14
+ // writes render.json + screenshot.png into --output-dir. Logs progress to stderr.
15
+
16
+ import { parseArgs } from 'node:util'
17
+ import { mkdir, writeFile, readFile, access } from 'node:fs/promises'
18
+ import { join, resolve, dirname } from 'node:path'
19
+ import { fileURLToPath } from 'node:url'
20
+ import { statSync } from 'node:fs'
21
+
22
+ // playwright, liquidjs, js-yaml are imported lazily inside main() so --help and
23
+ // arg validation work without the deps installed, and so the Shopify-only deps
24
+ // are not required for the WP path.
25
+
26
+ const HELP = `
27
+ validate-render.mjs — Render a built fragment locally with theme reset injected.
28
+
29
+ Required:
30
+ --file <path> HTML (preset=wp-elementor) or Liquid (preset=shopify-section).
31
+ --preset <name> wp-elementor | shopify-section
32
+ --output-dir <dir> Directory for screenshot.png + render.json.
33
+
34
+ Optional:
35
+ --viewport-width <px> Default 390 (mobile-first).
36
+ --viewport-height <px> Default 844.
37
+ --preset-yaml <path> Override preset YAML (otherwise auto-detects
38
+ <plugin>/presets/{preset}.yaml, falls back to baked-in).
39
+ --selector <css> Scope token probing + section bbox to one element.
40
+ Default: the wrapper root (.elementor or section).
41
+ --assets-map-path <path> (preset=shopify-section only) Path to the
42
+ assets-map.json from sb-extract-assets. When provided,
43
+ image_picker settings without 'default' get populated
44
+ from matching assetsMap entries (by context heuristic)
45
+ so the rendered fragment shows real imagery instead of
46
+ the {% else %} placeholder. Pattern #32 of the plan.
47
+ --timeout <ms> Per-step timeout (default 30000).
48
+ --help Show this message.
49
+
50
+ Exit codes: 0=ok, 1=script error, 2=invalid args.
51
+ `
52
+
53
+ function fail(msg, code = 2) {
54
+ process.stderr.write(`[sb-validate-render] ${msg}\n`)
55
+ process.exit(code)
56
+ }
57
+
58
+ function log(msg) {
59
+ process.stderr.write(`[sb-validate-render] ${msg}\n`)
60
+ }
61
+
62
+ const { values } = parseArgs({
63
+ options: {
64
+ file: { type: 'string' },
65
+ preset: { type: 'string' },
66
+ 'viewport-width': { type: 'string', default: '390' },
67
+ 'viewport-height': { type: 'string', default: '844' },
68
+ 'preset-yaml': { type: 'string' },
69
+ 'output-dir': { type: 'string' },
70
+ selector: { type: 'string' },
71
+ 'assets-map-path': { type: 'string' },
72
+ timeout: { type: 'string', default: '30000' },
73
+ help: { type: 'boolean', default: false },
74
+ },
75
+ strict: false,
76
+ })
77
+
78
+ if (values.help) {
79
+ process.stdout.write(HELP)
80
+ process.exit(0)
81
+ }
82
+
83
+ if (!values.file) fail('missing --file')
84
+ if (!values.preset) fail('missing --preset')
85
+ if (!values['output-dir']) fail('missing --output-dir')
86
+
87
+ const FILE = resolve(values.file)
88
+ const PRESET = values.preset
89
+ const VIEWPORT_W = parseInt(values['viewport-width'], 10)
90
+ const VIEWPORT_H = parseInt(values['viewport-height'], 10)
91
+ const PRESET_YAML = values['preset-yaml'] ? resolve(values['preset-yaml']) : null
92
+ const OUTPUT_DIR = resolve(values['output-dir'])
93
+ const SELECTOR = values.selector || null
94
+ const ASSETS_MAP_PATH = values['assets-map-path'] ? resolve(values['assets-map-path']) : null
95
+ const TIMEOUT = parseInt(values.timeout, 10)
96
+
97
+ if (!Number.isFinite(VIEWPORT_W) || !Number.isFinite(VIEWPORT_H)) fail('viewport must be numeric')
98
+ if (PRESET !== 'wp-elementor' && PRESET !== 'shopify-section') {
99
+ fail(`unknown preset "${PRESET}" — expected wp-elementor or shopify-section`)
100
+ }
101
+
102
+ // ============================================================================
103
+ // Hardcoded preset fallbacks. Populated when <plugin>/presets/{preset}.yaml is
104
+ // missing — that file gets written by item #14 of the visual-migration plan,
105
+ // and users can customize it for their specific theme (Astra, Hello, custom Dawn).
106
+ // Keep these in sync with the plan brief: aggressive enough to simulate what a
107
+ // real theme actually does to a pasted fragment.
108
+ // ============================================================================
109
+ const FALLBACK_PRESETS = {
110
+ 'wp-elementor': {
111
+ wrapper: { tag: 'div', class: 'elementor' },
112
+ reset_css: `
113
+ .elementor, .elementor * { font-weight: 300 !important; font-size: 18px !important; font-family: 'Roboto', sans-serif !important; }
114
+ .elementor h1, .elementor h2, .elementor h3 { font-weight: 300 !important; font-size: 24px !important; }
115
+ .elementor img, img { max-width: 100% !important; height: auto !important; border-radius: 0 !important; }
116
+ .elementor button, button { background: transparent !important; border: 0 !important; padding: 0 !important; }
117
+ `.trim(),
118
+ // Fonts injected before reset so Roboto is actually available when the reset
119
+ // forces font-family. Without this, the reset names a font the page doesn't have.
120
+ head_extras: `<link rel="preconnect" href="https://fonts.googleapis.com">
121
+ <link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
122
+ <link rel="stylesheet" href="https://fonts.googleapis.com/css2?family=Roboto:wght@300;400;700&display=swap">`,
123
+ // Default selector for token probing + section bbox if --selector not passed.
124
+ default_probe_root: '.elementor',
125
+ },
126
+ 'shopify-section': {
127
+ wrapper: { tag: 'div', class: 'page-width' },
128
+ reset_css: `
129
+ body { font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; font-size: 16px; line-height: 1.5; color: rgb(18, 18, 18); margin: 0; }
130
+ .page-width { max-width: 120rem; padding: 0 1.5rem; margin: 0 auto; box-sizing: border-box; }
131
+ h1 { font-size: 4rem; font-weight: 400; line-height: 1.1; letter-spacing: -0.05rem; margin: 0 0 1.5rem; }
132
+ h2 { font-size: 3rem; font-weight: 400; line-height: 1.15; letter-spacing: -0.04rem; margin: 0 0 1.25rem; }
133
+ h3 { font-size: 2.4rem; font-weight: 400; line-height: 1.2; letter-spacing: -0.03rem; margin: 0 0 1rem; }
134
+ button, .button { background: rgb(18, 18, 18); color: rgb(255, 255, 255); border: 0.1rem solid rgb(18, 18, 18); border-radius: 0; padding: 1.5rem 3rem; font-size: 1.5rem; font-weight: 500; cursor: pointer; }
135
+ img { max-width: 100%; height: auto; display: block; }
136
+ * { box-sizing: border-box; }
137
+ html { font-size: 62.5%; }
138
+ `.trim(),
139
+ head_extras: '',
140
+ default_probe_root: '.page-width',
141
+ },
142
+ }
143
+
144
+ async function loadPreset() {
145
+ // Resolve order: --preset-yaml override → <plugin>/presets/{preset}.yaml → hardcoded.
146
+ const candidatePaths = []
147
+ if (PRESET_YAML) candidatePaths.push(PRESET_YAML)
148
+ // <plugin> = two levels up from this script's <skill-root>/scripts/.
149
+ // .claude/skills/sb-validate-render/scripts/ → .claude/presets/{preset}.yaml
150
+ const here = dirname(fileURLToPath(import.meta.url))
151
+ const pluginPresets = resolve(here, '..', '..', '..', 'presets', `${PRESET}.yaml`)
152
+ candidatePaths.push(pluginPresets)
153
+
154
+ for (const p of candidatePaths) {
155
+ try {
156
+ await access(p)
157
+ log(`loading preset YAML: ${p}`)
158
+ const raw = await readFile(p, 'utf8')
159
+ let yaml
160
+ try {
161
+ yaml = (await import('js-yaml')).default
162
+ } catch (_) {
163
+ log(`js-yaml not installed; cannot parse ${p} — falling back to hardcoded preset`)
164
+ break
165
+ }
166
+ const parsed = yaml.load(raw)
167
+ // Merge with hardcoded so a partial YAML override still works.
168
+ return { ...FALLBACK_PRESETS[PRESET], ...parsed }
169
+ } catch (_) {
170
+ // file doesn't exist — try next
171
+ }
172
+ }
173
+ log(`using hardcoded fallback preset for ${PRESET}`)
174
+ return FALLBACK_PRESETS[PRESET]
175
+ }
176
+
177
+ // ============================================================================
178
+ // Liquid rendering. Only invoked when preset=shopify-section.
179
+ // Parses {% schema %}...{% endschema %} JSON, builds mock context from setting
180
+ // defaults + presets[0].blocks (or schema.default.blocks), strips the schema
181
+ // from source, hands the rest to liquidjs.
182
+ // ============================================================================
183
+ function extractSchema(liquidSrc) {
184
+ const m = liquidSrc.match(/\{%-?\s*schema\s*-?%\}([\s\S]*?)\{%-?\s*endschema\s*-?%\}/)
185
+ if (!m) return { schema: null, body: liquidSrc }
186
+ let schema = null
187
+ try {
188
+ schema = JSON.parse(m[1])
189
+ } catch (err) {
190
+ log(`schema JSON parse failed: ${err.message} — proceeding with empty mock context`)
191
+ }
192
+ const body = liquidSrc.slice(0, m.index) + liquidSrc.slice(m.index + m[0].length)
193
+ return { schema, body }
194
+ }
195
+
196
+ // Match an image_picker setting (no default) to an entry in assetsMap.assets[]
197
+ // using context heuristics. Setting id keywords map to known context substrings
198
+ // emitted by sb-inspect-live (e.g. "ai-hero" for the hero, "ai-hdr" for header
199
+ // logo, "ai-fp__gallery" for product gallery). Returns the matching asset or
200
+ // null. Pattern #32 of the plan: a placeholder mock against a real photo
201
+ // inflates diff% by ~30pp that isn't actionable; populating the mock with the
202
+ // real asset that the merchant will eventually pick removes the noise.
203
+ const IMAGE_PICKER_ID_HEURISTICS = [
204
+ { idRe: /(^|[_-])(hero|banner|cover)([_-]|$)/i, ctxKeywords: ['hero', 'ai-hero', 'banner', 'cover'] },
205
+ { idRe: /^(hero|banner|cover)(_image)?$/i, ctxKeywords: ['hero', 'ai-hero', 'banner', 'cover'] },
206
+ { idRe: /(^|[_-])(logo|brand)([_-]|$)/i, ctxKeywords: ['header', 'ai-hdr', 'logo', 'brand'] },
207
+ { idRe: /^(logo|brand)$/i, ctxKeywords: ['header', 'ai-hdr', 'logo', 'brand'] },
208
+ { idRe: /(^|[_-])(product|gallery|featured)([_-]|$)/i, ctxKeywords: ['ai-fp__gallery', 'product', 'gallery', 'featured'] },
209
+ { idRe: /^(product_image|gallery_image|featured_image)$/i, ctxKeywords: ['ai-fp__gallery', 'product', 'gallery', 'featured'] },
210
+ ]
211
+
212
+ function findAssetForImagePicker(settingId, assetsMap) {
213
+ if (!settingId || !assetsMap || !assetsMap.assets || typeof assetsMap.assets !== 'object') return null
214
+ const id = String(settingId).toLowerCase()
215
+ // sb-extract-assets emits assets as Object (URL → asset map), not Array.
216
+ const assetEntries = Array.isArray(assetsMap.assets) ? assetsMap.assets : Object.values(assetsMap.assets)
217
+ for (const rule of IMAGE_PICKER_ID_HEURISTICS) {
218
+ if (!rule.idRe.test(id)) continue
219
+ for (const a of assetEntries) {
220
+ const c = String(a?.context || '').toLowerCase()
221
+ if (!c) continue
222
+ if (rule.ctxKeywords.some((kw) => c.includes(kw))) return a
223
+ }
224
+ }
225
+ // Fallback: first asset with non-empty context.
226
+ for (const a of assetEntries) {
227
+ if (String(a?.context || '').trim()) return a
228
+ }
229
+ return null
230
+ }
231
+
232
+ const MIME_BY_EXT = {
233
+ jpg: 'image/jpeg',
234
+ jpeg: 'image/jpeg',
235
+ png: 'image/png',
236
+ webp: 'image/webp',
237
+ avif: 'image/avif',
238
+ gif: 'image/gif',
239
+ svg: 'image/svg+xml',
240
+ }
241
+
242
+ async function readAssetAsDataUrl(asset) {
243
+ if (!asset || !asset.localPath) return null
244
+ try {
245
+ const buf = await readFile(asset.localPath)
246
+ const ext = String(asset.ext || asset.localPath.split('.').pop() || 'jpg').toLowerCase()
247
+ const mime = MIME_BY_EXT[ext] || 'image/jpeg'
248
+ return `data:${mime};base64,${buf.toString('base64')}`
249
+ } catch (err) {
250
+ log(`failed to read asset ${asset.localPath}: ${err.message}`)
251
+ return null
252
+ }
253
+ }
254
+
255
+ function buildMockContext(schema) {
256
+ if (!schema) return { section: { id: 'mock', settings: {}, blocks: [] } }
257
+ const settings = {}
258
+ for (const s of schema.settings || []) {
259
+ if (s?.id && 'default' in s) settings[s.id] = s.default
260
+ }
261
+ // Prefer presets[0].blocks (storefront-installable preset) then schema.default.blocks.
262
+ const rawBlocks =
263
+ schema.presets?.[0]?.blocks ||
264
+ schema.default?.blocks ||
265
+ []
266
+ const blockTypeDefaults = {}
267
+ for (const b of schema.blocks || []) {
268
+ if (!b?.type) continue
269
+ const bs = {}
270
+ for (const s of b.settings || []) {
271
+ if (s?.id && 'default' in s) bs[s.id] = s.default
272
+ }
273
+ blockTypeDefaults[b.type] = bs
274
+ }
275
+ const blocks = rawBlocks.map((b, i) => ({
276
+ id: b.id || `block-${i}`,
277
+ type: b.type,
278
+ settings: { ...(blockTypeDefaults[b.type] || {}), ...(b.settings || {}) },
279
+ shopify_attributes: '',
280
+ }))
281
+ return {
282
+ section: { id: 'mock', settings, blocks, blocks_count: blocks.length },
283
+ }
284
+ }
285
+
286
+ async function renderLiquid(liquidSrc, assetsMap = null) {
287
+ let Liquid
288
+ try {
289
+ ;({ Liquid } = await import('liquidjs'))
290
+ } catch (_) {
291
+ process.stderr.write(
292
+ `[sb-validate-render] liquidjs is required for preset=shopify-section but not installed.\n` +
293
+ `Install with: npm i liquidjs\n`,
294
+ )
295
+ process.exit(1)
296
+ }
297
+ const { schema, body } = extractSchema(liquidSrc)
298
+ const ctx = buildMockContext(schema)
299
+ // Pattern #32 of the plan: when assetsMap is supplied, populate image_picker
300
+ // settings without 'default' from matching assetsMap entries (data-URL inline).
301
+ // Without this, the {% else %} branch fires and the rendered fragment shows a
302
+ // neutral placeholder where the live page shows a real photo, inflating the
303
+ // pixel diff by tens of points with no actionable fix.
304
+ const mockResult = { populated: [], skipped: [] }
305
+ if (assetsMap && schema && Array.isArray(schema.settings)) {
306
+ for (const s of schema.settings) {
307
+ if (s?.type !== 'image_picker') continue
308
+ if (!s.id) continue
309
+ if ('default' in s) continue
310
+ const asset = findAssetForImagePicker(s.id, assetsMap)
311
+ if (!asset) {
312
+ mockResult.skipped.push(s.id)
313
+ continue
314
+ }
315
+ const dataUrl = await readAssetAsDataUrl(asset)
316
+ if (!dataUrl) {
317
+ mockResult.skipped.push(s.id)
318
+ continue
319
+ }
320
+ ctx.section.settings[s.id] = dataUrl
321
+ mockResult.populated.push(s.id)
322
+ }
323
+ if (mockResult.populated.length) {
324
+ log(`mock context: populated image_picker [${mockResult.populated.join(', ')}] from assetsMap`)
325
+ }
326
+ if (mockResult.skipped.length) {
327
+ log(`mock context: skipped image_picker [${mockResult.skipped.join(', ')}] (no matching asset)`)
328
+ }
329
+ }
330
+ // Normalize Shopify-native block tags ({% style %}, {% javascript %},
331
+ // {% stylesheet %}) into plain HTML wrappers so vanilla liquidjs can parse
332
+ // them. Inner {{ ... }} and {% if %} expressions are preserved and still
333
+ // evaluated. Without this, liquidjs throws `tag "style" not found` and the
334
+ // entire Shopify render path is dead.
335
+ const normalizedBody = body
336
+ .replace(/\{%-?\s*style\s*-?%\}/g, '<style>')
337
+ .replace(/\{%-?\s*endstyle\s*-?%\}/g, '</style>')
338
+ .replace(/\{%-?\s*stylesheet(?:\s+[^%]*?)?-?%\}/g, '<style>')
339
+ .replace(/\{%-?\s*endstylesheet\s*-?%\}/g, '</style>')
340
+ .replace(/\{%-?\s*javascript\s*-?%\}/g, '<script>')
341
+ .replace(/\{%-?\s*endjavascript\s*-?%\}/g, '</script>')
342
+ const engine = new Liquid({ strictFilters: false, strictVariables: false })
343
+ // Minimal Shopify filter shims so common storefront filters don't blow up the render.
344
+ engine.registerFilter('asset_url', (v) => `/assets/${v || ''}`)
345
+ engine.registerFilter('img_url', (v) => v || '')
346
+ engine.registerFilter('img_tag', (v, alt = '') => `<img src="${v || ''}" alt="${alt}">`)
347
+ engine.registerFilter('image_url', (v) => v || '')
348
+ engine.registerFilter('image_tag', (v, alt = '') => `<img src="${v || ''}" alt="${alt}">`)
349
+ engine.registerFilter('money', (v) => `$${v || 0}`)
350
+ engine.registerFilter('t', (v) => v)
351
+ engine.registerFilter('default', (v, d) => (v == null || v === '' ? d : v))
352
+ engine.registerFilter('escape', (v) => String(v ?? '').replace(/[&<>"']/g, (c) => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' })[c]))
353
+ engine.registerFilter('handleize', (v) => String(v ?? '').toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-|-$/g, ''))
354
+ const html = await engine.parseAndRender(normalizedBody, ctx)
355
+ return { html, mockContext: mockResult }
356
+ }
357
+
358
+ async function main() {
359
+ await mkdir(OUTPUT_DIR, { recursive: true })
360
+
361
+ const screenshotPath = join(OUTPUT_DIR, 'screenshot.png')
362
+ const jsonPath = join(OUTPUT_DIR, 'render.json')
363
+
364
+ const preset = await loadPreset()
365
+
366
+ log(`reading source file: ${FILE}`)
367
+ const sourceRaw = await readFile(FILE, 'utf8')
368
+
369
+ // Pattern #32: load assets-map.json when --assets-map-path was supplied so
370
+ // image_picker mock-context can resolve to real assets instead of placeholder.
371
+ let assetsMap = null
372
+ if (ASSETS_MAP_PATH && PRESET === 'shopify-section') {
373
+ try {
374
+ const raw = await readFile(ASSETS_MAP_PATH, 'utf8')
375
+ assetsMap = JSON.parse(raw)
376
+ log(`loaded assets-map: ${ASSETS_MAP_PATH} (${(assetsMap.assets || []).length} assets)`)
377
+ } catch (err) {
378
+ log(`failed to load assets-map "${ASSETS_MAP_PATH}": ${err.message} — proceeding without`)
379
+ }
380
+ }
381
+
382
+ let bodyContent
383
+ let mockContext = null
384
+ if (PRESET === 'shopify-section') {
385
+ log('rendering Liquid with mock schema-default context')
386
+ const rendered = await renderLiquid(sourceRaw, assetsMap)
387
+ bodyContent = rendered.html
388
+ mockContext = rendered.mockContext
389
+ } else {
390
+ bodyContent = sourceRaw
391
+ }
392
+
393
+ // Wrap with the preset's destination wrapper so the destination's blanket
394
+ // selectors (.elementor *, .page-width) actually match the fragment.
395
+ const wrapTag = preset.wrapper?.tag || 'div'
396
+ const wrapClass = preset.wrapper?.class || ''
397
+ const wrapped = `<${wrapTag} class="${wrapClass}">\n${bodyContent}\n</${wrapTag}>`
398
+
399
+ const fullHtml = `<!doctype html>
400
+ <html lang="en">
401
+ <head>
402
+ <meta charset="utf-8">
403
+ <meta name="viewport" content="width=device-width, initial-scale=1">
404
+ <title>sb-validate-render</title>
405
+ ${preset.head_extras || ''}
406
+ <style id="sb-theme-reset">
407
+ ${preset.reset_css || ''}
408
+ </style>
409
+ </head>
410
+ <body>
411
+ ${wrapped}
412
+ </body>
413
+ </html>`
414
+
415
+ let chromium
416
+ try {
417
+ ;({ chromium } = await import('playwright'))
418
+ } catch (err) {
419
+ process.stderr.write(
420
+ `[sb-validate-render] missing dependency: ${err?.message || err}\n` +
421
+ `Install with: npm i playwright && npx playwright install chromium\n`,
422
+ )
423
+ process.exit(1)
424
+ }
425
+
426
+ log(`launching chromium @ ${VIEWPORT_W}x${VIEWPORT_H}`)
427
+ const browser = await chromium.launch({ headless: true })
428
+ const context = await browser.newContext({
429
+ viewport: { width: VIEWPORT_W, height: VIEWPORT_H },
430
+ // DPR 3 to match sb-inspect-live's iPhone 14 capture (Pattern #22 in plan):
431
+ // when these diverge, sb-compare-visual sees positional drift dominate the
432
+ // pixel diff even with token-perfect builds.
433
+ deviceScaleFactor: 3,
434
+ isMobile: true,
435
+ hasTouch: true,
436
+ userAgent:
437
+ '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',
438
+ })
439
+ const page = await context.newPage()
440
+ page.setDefaultTimeout(TIMEOUT)
441
+
442
+ const probeRoot = SELECTOR || preset.default_probe_root || 'body'
443
+ const result = {
444
+ file: FILE,
445
+ preset: PRESET,
446
+ viewport: { width: VIEWPORT_W, height: VIEWPORT_H },
447
+ probeRoot,
448
+ screenshot: screenshotPath,
449
+ tokens: {},
450
+ geometry: {},
451
+ warnings: [],
452
+ mockContext,
453
+ }
454
+
455
+ try {
456
+ log('setContent (no goto — fragment is in hand)')
457
+ await page.setContent(fullHtml, { waitUntil: 'load', timeout: TIMEOUT })
458
+
459
+ // Wait for Google Fonts (preset.head_extras may inject a stylesheet) so the
460
+ // typography measurement reflects the loaded font, not the system fallback.
461
+ try {
462
+ await page.evaluate(() => document.fonts?.ready)
463
+ } catch (_) {}
464
+ await page.waitForTimeout(400)
465
+
466
+ log(`capturing screenshot → ${screenshotPath}`)
467
+ await page.screenshot({ path: screenshotPath, fullPage: true })
468
+
469
+ log(`probing tokens + geometry from ${probeRoot}`)
470
+ const probed = await page.evaluate(probeInPage, { probeRoot })
471
+ Object.assign(result, probed)
472
+ } catch (err) {
473
+ process.stderr.write(`[sb-validate-render] error: ${err?.stack ? err.stack : err}\n`)
474
+ await browser.close().catch(() => {})
475
+ process.exit(1)
476
+ }
477
+
478
+ await browser.close()
479
+
480
+ // Sanity flag — tiny screenshots usually mean the page didn't render.
481
+ try {
482
+ const sz = statSync(screenshotPath).size
483
+ if (sz < 4096) {
484
+ result.warnings.push(`screenshot is only ${sz} bytes — render may have failed`)
485
+ }
486
+ } catch (_) {}
487
+
488
+ await writeFile(jsonPath, JSON.stringify(result, null, 2), 'utf8')
489
+ process.stdout.write(JSON.stringify(result))
490
+ process.stdout.write('\n')
491
+ }
492
+
493
+ // =====================================================================
494
+ // In-page probe. Serialized to a string and runs inside the browser via
495
+ // page.evaluate, so it must be self-contained.
496
+ // =====================================================================
497
+ function probeInPage({ probeRoot }) {
498
+ const root = document.querySelector(probeRoot) || document.body
499
+
500
+ function pick(el, props) {
501
+ if (!el) return null
502
+ const cs = getComputedStyle(el)
503
+ const out = {}
504
+ for (const p of props) {
505
+ const v = cs.getPropertyValue(p)
506
+ if (v) out[p] = v.trim()
507
+ }
508
+ return out
509
+ }
510
+
511
+ function bbox(el) {
512
+ if (!el) return null
513
+ const r = el.getBoundingClientRect()
514
+ return {
515
+ x: Math.round(r.x + window.scrollX),
516
+ y: Math.round(r.y + window.scrollY),
517
+ w: Math.round(r.width),
518
+ h: Math.round(r.height),
519
+ }
520
+ }
521
+
522
+ // Tokens — pick first-instance for each heading; aggregate stats for body/img.
523
+ const h1 = root.querySelector('h1')
524
+ const h2 = root.querySelector('h2')
525
+ const h3 = root.querySelector('h3')
526
+ const button = root.querySelector('button, .button, [role="button"]')
527
+
528
+ // Body probe must reflect the SCOPE's body-like typography, not the host
529
+ // <body> default. The Pattern #24 bug: probing document.body returned UA
530
+ // defaults ("Times New Roman", 16px) for any fragment whose scope had its
531
+ // own body-text rules, corrupting tokens.body in render.json. Search order:
532
+ // 1. first <p>/<span>/<div> inside scope that holds direct text content
533
+ // 2. the scope root itself
534
+ // 3. document.body (last-resort fallback)
535
+ function findBodyLikeInScope(scopeRoot) {
536
+ if (!scopeRoot || scopeRoot === document.body) return null
537
+ const candidates = scopeRoot.querySelectorAll('p, span, div, li')
538
+ for (const el of candidates) {
539
+ // "direct text content" = text inside this element that isn't just
540
+ // descendant whitespace; we accept anything ≥ 1 visible char.
541
+ let direct = ''
542
+ for (const node of el.childNodes) {
543
+ if (node.nodeType === 3) direct += node.nodeValue || ''
544
+ }
545
+ if (direct.replace(/\s+/g, '').length >= 1) return el
546
+ }
547
+ return null
548
+ }
549
+ const bodyEl = findBodyLikeInScope(root) || (root !== document.body ? root : document.body)
550
+ const bodyStyle = getComputedStyle(bodyEl)
551
+
552
+ // Image probing: report the first image's height + a per-image rundown so the
553
+ // orchestrator can spot the "default vs override" split (Anti-pattern #5 territory).
554
+ const imgs = Array.from(root.querySelectorAll('img'))
555
+ const imgReport = imgs.slice(0, 20).map((img) => {
556
+ const r = img.getBoundingClientRect()
557
+ const cs = getComputedStyle(img)
558
+ return {
559
+ src: img.getAttribute('src') || '',
560
+ alt: img.getAttribute('alt') || '',
561
+ computedHeight: cs.height,
562
+ computedMaxWidth: cs.maxWidth,
563
+ renderedW: Math.round(r.width),
564
+ renderedH: Math.round(r.height),
565
+ hasInlineStyle: !!img.getAttribute('style'),
566
+ }
567
+ })
568
+
569
+ // Container width: walk up from probe root looking for the first ancestor with
570
+ // an explicit max-width or width that's not 'none'/'auto'.
571
+ let containerEl = root
572
+ let containerWidth = null
573
+ while (containerEl && containerEl !== document.body) {
574
+ const cs = getComputedStyle(containerEl)
575
+ const mw = cs.maxWidth
576
+ const w = cs.width
577
+ if ((mw && mw !== 'none') || (w && w !== 'auto')) {
578
+ containerWidth = { maxWidth: mw, width: w, computedRenderedW: Math.round(containerEl.getBoundingClientRect().width) }
579
+ break
580
+ }
581
+ containerEl = containerEl.parentElement
582
+ }
583
+ if (!containerWidth) {
584
+ containerWidth = { maxWidth: 'none', width: 'auto', computedRenderedW: Math.round(root.getBoundingClientRect().width) }
585
+ }
586
+
587
+ // Geometry — total height, viewport overflow, first-section dims.
588
+ const docEl = document.documentElement
589
+ const totalHeight = Math.max(docEl.scrollHeight, document.body.scrollHeight)
590
+ const totalWidth = Math.max(docEl.scrollWidth, document.body.scrollWidth)
591
+ const viewportOverflow = totalWidth > window.innerWidth + 1
592
+
593
+ const sections = Array.from(document.querySelectorAll('section, .elementor-section, [class*="section"]')).slice(0, 6).map((s) => ({
594
+ tag: s.tagName.toLowerCase(),
595
+ classes: (typeof s.className === 'string' ? s.className : '').trim().split(/\s+/).filter(Boolean).slice(0, 8),
596
+ bbox: bbox(s),
597
+ }))
598
+
599
+ return {
600
+ tokens: {
601
+ h1: h1
602
+ ? { present: true, text: (h1.textContent || '').trim().slice(0, 80), ...pick(h1, ['font-size', 'font-weight', 'font-family', 'line-height', 'color']) }
603
+ : { present: false },
604
+ h2: h2
605
+ ? { present: true, text: (h2.textContent || '').trim().slice(0, 80), ...pick(h2, ['font-size', 'font-weight', 'font-family', 'line-height', 'color']) }
606
+ : { present: false },
607
+ h3: h3
608
+ ? { present: true, text: (h3.textContent || '').trim().slice(0, 80), ...pick(h3, ['font-size', 'font-weight', 'font-family', 'line-height', 'color']) }
609
+ : { present: false },
610
+ body: {
611
+ 'font-size': bodyStyle.getPropertyValue('font-size').trim(),
612
+ 'font-weight': bodyStyle.getPropertyValue('font-weight').trim(),
613
+ 'font-family': bodyStyle.getPropertyValue('font-family').trim(),
614
+ 'line-height': bodyStyle.getPropertyValue('line-height').trim(),
615
+ color: bodyStyle.getPropertyValue('color').trim(),
616
+ },
617
+ button: button
618
+ ? {
619
+ present: true,
620
+ text: (button.textContent || '').trim().slice(0, 60),
621
+ ...pick(button, ['height', 'min-height', 'padding', 'border', 'border-radius', 'background-color', 'color', 'font-size', 'font-weight']),
622
+ renderedH: Math.round(button.getBoundingClientRect().height),
623
+ }
624
+ : { present: false },
625
+ images: {
626
+ count: imgs.length,
627
+ sampled: imgReport,
628
+ },
629
+ container: containerWidth,
630
+ },
631
+ geometry: {
632
+ totalHeight,
633
+ totalWidth,
634
+ viewportInner: { width: window.innerWidth, height: window.innerHeight },
635
+ viewportOverflow,
636
+ probeRoot: bbox(root),
637
+ sections,
638
+ },
639
+ }
640
+ }
641
+
642
+ main().catch((err) => {
643
+ process.stderr.write(`[sb-validate-render] fatal: ${err?.stack || err}\n`)
644
+ process.exit(1)
645
+ })