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.
- package/CHANGELOG.md +110 -0
- package/LICENSE +21 -0
- package/README.md +301 -0
- package/bin/install.js +256 -0
- package/lib/copy-templates.mjs +52 -0
- package/lib/install-deps.mjs +62 -0
- package/lib/prompt-config.mjs +83 -0
- package/lib/verify-env.mjs +19 -0
- package/package.json +63 -0
- package/scripts/sync-templates.mjs +71 -0
- package/templates/commands/build-page.md +490 -0
- package/templates/commands/build-site.md +548 -0
- package/templates/commands/clip-section.md +519 -0
- package/templates/memory/anti-patterns.md +212 -0
- package/templates/memory/design-knowledge.md +225 -0
- package/templates/memory/fixes.md +163 -0
- package/templates/memory/patterns.md +681 -0
- package/templates/presets/shopify-section.yaml +51 -0
- package/templates/presets/wp-elementor.yaml +49 -0
- package/templates/reports/fixtures/mock-run-1.json +115 -0
- package/templates/reports/fixtures/mock-run-2.json +72 -0
- package/templates/reports/report-renderer.mjs +218 -0
- package/templates/reports/report-template.html +571 -0
- package/templates/skills/sb-build-shopify/SKILL.md +104 -0
- package/templates/skills/sb-build-shopify/references/shopify-build-rules.md +563 -0
- package/templates/skills/sb-build-shopify/scripts/build-shopify.mjs +637 -0
- package/templates/skills/sb-build-shopify/scripts/tests/test-build-shopify.mjs +424 -0
- package/templates/skills/sb-build-wp/SKILL.md +83 -0
- package/templates/skills/sb-build-wp/references/wp-build-rules.md +376 -0
- package/templates/skills/sb-build-wp/scripts/build-wp.mjs +327 -0
- package/templates/skills/sb-build-wp/scripts/tests/test-build-wp.mjs +224 -0
- package/templates/skills/sb-compare-visual/SKILL.md +121 -0
- package/templates/skills/sb-compare-visual/scripts/compare-visual.mjs +387 -0
- package/templates/skills/sb-compare-visual/scripts/lib/compare-tokens.mjs +273 -0
- package/templates/skills/sb-compare-visual/scripts/tests/test-compare-tokens.mjs +350 -0
- package/templates/skills/sb-compare-visual/scripts/tests/test-compare-visual.mjs +626 -0
- package/templates/skills/sb-crawl-and-list/SKILL.md +99 -0
- package/templates/skills/sb-crawl-and-list/scripts/crawl-and-list.mjs +437 -0
- package/templates/skills/sb-crawl-and-list/scripts/lib/blocklist-filter.mjs +176 -0
- package/templates/skills/sb-crawl-and-list/scripts/lib/fallback-crawler.mjs +107 -0
- package/templates/skills/sb-crawl-and-list/scripts/lib/page-classifier.mjs +89 -0
- package/templates/skills/sb-crawl-and-list/scripts/lib/sitemap-parser.mjs +118 -0
- package/templates/skills/sb-crawl-and-list/scripts/tests/test-blocklist-filter.mjs +204 -0
- package/templates/skills/sb-crawl-and-list/scripts/tests/test-crawl-and-list.mjs +276 -0
- package/templates/skills/sb-crawl-and-list/scripts/tests/test-fallback-crawler.mjs +243 -0
- package/templates/skills/sb-crawl-and-list/scripts/tests/test-page-classifier.mjs +120 -0
- package/templates/skills/sb-crawl-and-list/scripts/tests/test-sitemap-parser.mjs +157 -0
- package/templates/skills/sb-extract-assets/SKILL.md +112 -0
- package/templates/skills/sb-extract-assets/scripts/extract-assets.mjs +484 -0
- package/templates/skills/sb-extract-assets/scripts/tests/test-extract-assets.mjs +112 -0
- package/templates/skills/sb-inspect-live/SKILL.md +105 -0
- package/templates/skills/sb-inspect-live/scripts/inspect-live.mjs +693 -0
- package/templates/skills/sb-inspect-live/scripts/tests/test-inspect-live.mjs +181 -0
- package/templates/skills/sb-review-checks/SKILL.md +113 -0
- package/templates/skills/sb-review-checks/references/review-rules.md +195 -0
- package/templates/skills/sb-review-checks/scripts/lib/anti-patterns.mjs +379 -0
- package/templates/skills/sb-review-checks/scripts/lib/cross-reference.mjs +115 -0
- package/templates/skills/sb-review-checks/scripts/lib/design-quality.mjs +541 -0
- package/templates/skills/sb-review-checks/scripts/review-checks.mjs +250 -0
- package/templates/skills/sb-review-checks/scripts/tests/test-anti-patterns.mjs +343 -0
- package/templates/skills/sb-review-checks/scripts/tests/test-cross-reference.mjs +170 -0
- package/templates/skills/sb-review-checks/scripts/tests/test-design-quality.mjs +493 -0
- package/templates/skills/sb-review-checks/scripts/tests/test-review-checks.mjs +267 -0
- package/templates/skills/sb-tweak/SKILL.md +130 -0
- package/templates/skills/sb-tweak/references/tweak-patterns.md +157 -0
- package/templates/skills/sb-tweak/scripts/lib/diff-summarizer.mjs +140 -0
- package/templates/skills/sb-tweak/scripts/lib/element-locator.mjs +507 -0
- package/templates/skills/sb-tweak/scripts/lib/intent-parser.mjs +324 -0
- package/templates/skills/sb-tweak/scripts/tests/test-diff-summarizer.mjs +248 -0
- package/templates/skills/sb-tweak/scripts/tests/test-element-locator.mjs +418 -0
- package/templates/skills/sb-tweak/scripts/tests/test-intent-parser.mjs +496 -0
- package/templates/skills/sb-tweak/scripts/tests/test-tweak.mjs +407 -0
- package/templates/skills/sb-tweak/scripts/tweak.mjs +656 -0
- package/templates/skills/sb-validate-render/SKILL.md +120 -0
- package/templates/skills/sb-validate-render/scripts/tests/test-validate-render.mjs +304 -0
- 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) => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' })[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
|
+
})
|