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,424 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// test-build-shopify.mjs — Smoke tests for build-shopify.mjs.
|
|
3
|
+
// Validates: --help works, missing args fail with exit 2, validate catches the
|
|
4
|
+
// canonical Liquid/schema/anti-pattern violations (exit 3), validate passes a
|
|
5
|
+
// clean fragment (exit 0), write persists to the requested path. No prettier
|
|
6
|
+
// or liquidjs dependency required for tests to pass.
|
|
7
|
+
|
|
8
|
+
import { spawnSync } from 'node:child_process'
|
|
9
|
+
import { fileURLToPath } from 'node:url'
|
|
10
|
+
import { dirname, resolve, join } from 'node:path'
|
|
11
|
+
import { mkdirSync, writeFileSync, readFileSync, rmSync, existsSync } from 'node:fs'
|
|
12
|
+
import { tmpdir } from 'node:os'
|
|
13
|
+
import { strict as assert } from 'node:assert'
|
|
14
|
+
|
|
15
|
+
const here = dirname(fileURLToPath(import.meta.url))
|
|
16
|
+
const SCRIPT = resolve(here, '..', 'build-shopify.mjs')
|
|
17
|
+
const TMP = join(tmpdir(), `sb-build-shopify-test-${process.pid}`)
|
|
18
|
+
|
|
19
|
+
mkdirSync(TMP, { recursive: true })
|
|
20
|
+
|
|
21
|
+
let passed = 0
|
|
22
|
+
let failed = 0
|
|
23
|
+
|
|
24
|
+
function test(name, fn) {
|
|
25
|
+
try {
|
|
26
|
+
fn()
|
|
27
|
+
process.stdout.write(`ok - ${name}\n`)
|
|
28
|
+
passed++
|
|
29
|
+
} catch (err) {
|
|
30
|
+
process.stdout.write(`not ok - ${name}\n ${err.message}\n`)
|
|
31
|
+
failed++
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
async function testAsync(name, fn) {
|
|
36
|
+
try {
|
|
37
|
+
await fn()
|
|
38
|
+
process.stdout.write(`ok - ${name}\n`)
|
|
39
|
+
passed++
|
|
40
|
+
} catch (err) {
|
|
41
|
+
process.stdout.write(`not ok - ${name}\n ${err.message}\n`)
|
|
42
|
+
failed++
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
function run(args, input) {
|
|
47
|
+
return spawnSync('node', [SCRIPT, ...args], {
|
|
48
|
+
encoding: 'utf8',
|
|
49
|
+
input: input ?? undefined,
|
|
50
|
+
})
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
function writeTmp(name, content) {
|
|
54
|
+
const p = join(TMP, name)
|
|
55
|
+
writeFileSync(p, content)
|
|
56
|
+
return p
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
const CLEAN_LIQUID = `
|
|
60
|
+
{% style %}
|
|
61
|
+
.sb-hero, .sb-hero * { box-sizing: border-box; margin: 0; padding: 0; }
|
|
62
|
+
.sb-hero { width: 100vw; margin-left: calc(50% - 50vw); }
|
|
63
|
+
.sb-hero .sb-hero__title { font-size: 32px !important; font-weight: 700 !important; }
|
|
64
|
+
.sb-hero .sb-hero__media { aspect-ratio: 1 / 1.3; max-height: 700px; }
|
|
65
|
+
@media (min-width: 1000px) {
|
|
66
|
+
.sb-hero .sb-hero__media { aspect-ratio: 16 / 9; }
|
|
67
|
+
}
|
|
68
|
+
{% endstyle %}
|
|
69
|
+
|
|
70
|
+
<section class="sb-hero">
|
|
71
|
+
{% if section.settings.hero_image %}
|
|
72
|
+
<img src="{{ section.settings.hero_image | image_url: width: 1200 }}" alt="{{ section.settings.heading | escape }}" loading="eager" fetchpriority="high">
|
|
73
|
+
{% else %}
|
|
74
|
+
<img src="/assets/abc123.jpg" alt="{{ section.settings.heading | escape }}" loading="eager" width="1200" height="900">
|
|
75
|
+
{% endif %}
|
|
76
|
+
<h1 class="sb-hero__title">{{ section.settings.heading | escape }}</h1>
|
|
77
|
+
<button type="button" class="sb-hero__cta" aria-label="Shop now">{{ section.settings.cta_label | escape }}</button>
|
|
78
|
+
<img src="/assets/def456.jpg" alt="" loading="lazy">
|
|
79
|
+
</section>
|
|
80
|
+
|
|
81
|
+
{% schema %}
|
|
82
|
+
{
|
|
83
|
+
"name": "Hero (cloned)",
|
|
84
|
+
"tag": "section",
|
|
85
|
+
"class": "sb-hero-wrapper",
|
|
86
|
+
"settings": [
|
|
87
|
+
{ "type": "header", "content": "Content" },
|
|
88
|
+
{ "type": "text", "id": "heading", "label": "Heading", "default": "Welcome" },
|
|
89
|
+
{ "type": "image_picker", "id": "hero_image", "label": "Hero image" },
|
|
90
|
+
{ "type": "text", "id": "cta_label", "label": "CTA label", "default": "Shop now" },
|
|
91
|
+
{ "type": "url", "id": "cta_link", "label": "CTA link" }
|
|
92
|
+
],
|
|
93
|
+
"presets": [
|
|
94
|
+
{ "name": "Hero — clone of source", "category": "Image" }
|
|
95
|
+
]
|
|
96
|
+
}
|
|
97
|
+
{% endschema %}
|
|
98
|
+
`
|
|
99
|
+
|
|
100
|
+
// ---- Help and arg validation ----
|
|
101
|
+
|
|
102
|
+
test('--help exits 0 and prints usage', () => {
|
|
103
|
+
const r = run(['--help'])
|
|
104
|
+
assert.equal(r.status, 0, `exit code was ${r.status}`)
|
|
105
|
+
assert.match(r.stdout, /build-shopify\.mjs/)
|
|
106
|
+
assert.match(r.stdout, /validate/)
|
|
107
|
+
assert.match(r.stdout, /write/)
|
|
108
|
+
})
|
|
109
|
+
|
|
110
|
+
test('no args exits 0 and shows help', () => {
|
|
111
|
+
const r = run([])
|
|
112
|
+
assert.equal(r.status, 0)
|
|
113
|
+
assert.match(r.stdout, /Subcommands/)
|
|
114
|
+
})
|
|
115
|
+
|
|
116
|
+
test('unknown subcommand exits 2', () => {
|
|
117
|
+
const r = run(['frobnicate'])
|
|
118
|
+
assert.equal(r.status, 2)
|
|
119
|
+
assert.match(r.stderr, /unknown subcommand/)
|
|
120
|
+
})
|
|
121
|
+
|
|
122
|
+
test('validate without --liquid-file exits 2', () => {
|
|
123
|
+
const r = run(['validate'])
|
|
124
|
+
assert.equal(r.status, 2)
|
|
125
|
+
assert.match(r.stderr, /missing --liquid-file/)
|
|
126
|
+
})
|
|
127
|
+
|
|
128
|
+
test('write without --output-path exits 2', () => {
|
|
129
|
+
const r = run(['write'], '<div></div>')
|
|
130
|
+
assert.equal(r.status, 2)
|
|
131
|
+
assert.match(r.stderr, /missing --output-path/)
|
|
132
|
+
})
|
|
133
|
+
|
|
134
|
+
test('write with empty stdin exits 2', () => {
|
|
135
|
+
const r = run(['write', '--output-path', join(TMP, 'never.liquid')], '')
|
|
136
|
+
assert.equal(r.status, 2)
|
|
137
|
+
assert.match(r.stderr, /stdin was empty/)
|
|
138
|
+
})
|
|
139
|
+
|
|
140
|
+
// ---- Validation: clean fragment passes ----
|
|
141
|
+
|
|
142
|
+
test('validate: clean fragment passes (exit 0)', () => {
|
|
143
|
+
const p = writeTmp('clean.liquid', CLEAN_LIQUID)
|
|
144
|
+
const r = run(['validate', '--liquid-file', p])
|
|
145
|
+
assert.equal(r.status, 0, `stderr: ${r.stderr}\nstdout: ${r.stdout}`)
|
|
146
|
+
const report = JSON.parse(r.stdout)
|
|
147
|
+
assert.equal(report.passed, true)
|
|
148
|
+
assert.equal(report.errorCount, 0)
|
|
149
|
+
assert.equal(report.schemaValidated, true)
|
|
150
|
+
})
|
|
151
|
+
|
|
152
|
+
// ---- Validation: schema rules ----
|
|
153
|
+
|
|
154
|
+
test('validate: missing {% schema %} reports error', () => {
|
|
155
|
+
const liquid = CLEAN_LIQUID.replace(/\{%\s*schema\s*%\}[\s\S]*?\{%\s*endschema\s*%\}/, '')
|
|
156
|
+
const p = writeTmp('no-schema.liquid', liquid)
|
|
157
|
+
const r = run(['validate', '--liquid-file', p])
|
|
158
|
+
assert.equal(r.status, 3)
|
|
159
|
+
const report = JSON.parse(r.stdout)
|
|
160
|
+
assert.ok(report.errors.find((e) => e.rule === 'schema-missing'))
|
|
161
|
+
})
|
|
162
|
+
|
|
163
|
+
test('validate: invalid schema JSON reports error', () => {
|
|
164
|
+
const liquid = CLEAN_LIQUID.replace(
|
|
165
|
+
'"name": "Hero (cloned)",',
|
|
166
|
+
'"name": "Hero (cloned)" THIS IS NOT JSON,',
|
|
167
|
+
)
|
|
168
|
+
const p = writeTmp('bad-json.liquid', liquid)
|
|
169
|
+
const r = run(['validate', '--liquid-file', p])
|
|
170
|
+
assert.equal(r.status, 3)
|
|
171
|
+
const report = JSON.parse(r.stdout)
|
|
172
|
+
assert.ok(report.errors.find((e) => e.rule === 'schema-invalid-json'))
|
|
173
|
+
})
|
|
174
|
+
|
|
175
|
+
test('validate: schema without name reports error', () => {
|
|
176
|
+
const liquid = CLEAN_LIQUID.replace('"name": "Hero (cloned)",', '')
|
|
177
|
+
const p = writeTmp('no-name.liquid', liquid)
|
|
178
|
+
const r = run(['validate', '--liquid-file', p])
|
|
179
|
+
assert.equal(r.status, 3)
|
|
180
|
+
const report = JSON.parse(r.stdout)
|
|
181
|
+
assert.ok(report.errors.find((e) => e.rule === 'schema-missing-name'))
|
|
182
|
+
})
|
|
183
|
+
|
|
184
|
+
test('validate: schema without presets reports error', () => {
|
|
185
|
+
const liquid = CLEAN_LIQUID.replace(
|
|
186
|
+
/"presets":\s*\[\s*\{[^}]+\}\s*\]/,
|
|
187
|
+
'"presets": []',
|
|
188
|
+
)
|
|
189
|
+
const p = writeTmp('no-presets.liquid', liquid)
|
|
190
|
+
const r = run(['validate', '--liquid-file', p])
|
|
191
|
+
assert.equal(r.status, 3)
|
|
192
|
+
const report = JSON.parse(r.stdout)
|
|
193
|
+
assert.ok(report.errors.find((e) => e.rule === 'schema-missing-presets'))
|
|
194
|
+
})
|
|
195
|
+
|
|
196
|
+
test('validate: image_picker with default reports error (anti-pattern #11)', () => {
|
|
197
|
+
const liquid = CLEAN_LIQUID.replace(
|
|
198
|
+
'{ "type": "image_picker", "id": "hero_image", "label": "Hero image" }',
|
|
199
|
+
'{ "type": "image_picker", "id": "hero_image", "label": "Hero image", "default": "/foo.jpg" }',
|
|
200
|
+
)
|
|
201
|
+
const p = writeTmp('image-default.liquid', liquid)
|
|
202
|
+
const r = run(['validate', '--liquid-file', p])
|
|
203
|
+
assert.equal(r.status, 3)
|
|
204
|
+
const report = JSON.parse(r.stdout)
|
|
205
|
+
assert.ok(report.errors.find((e) => e.rule === 'schema-image-picker-default'))
|
|
206
|
+
})
|
|
207
|
+
|
|
208
|
+
test('validate: duplicate setting id reports error', () => {
|
|
209
|
+
const liquid = CLEAN_LIQUID.replace(
|
|
210
|
+
'{ "type": "text", "id": "cta_label", "label": "CTA label", "default": "Shop now" },',
|
|
211
|
+
'{ "type": "text", "id": "heading", "label": "Duplicate", "default": "X" },',
|
|
212
|
+
)
|
|
213
|
+
const p = writeTmp('dup-id.liquid', liquid)
|
|
214
|
+
const r = run(['validate', '--liquid-file', p])
|
|
215
|
+
assert.equal(r.status, 3)
|
|
216
|
+
const report = JSON.parse(r.stdout)
|
|
217
|
+
assert.ok(report.errors.find((e) => e.rule === 'schema-setting-duplicate-id'))
|
|
218
|
+
})
|
|
219
|
+
|
|
220
|
+
// ---- Validation: Liquid tag balance ----
|
|
221
|
+
|
|
222
|
+
test('validate: unclosed {% if %} reports error', () => {
|
|
223
|
+
// Replace the {% endif %} that closes the hero_image conditional.
|
|
224
|
+
const liquid = CLEAN_LIQUID.replace('{% endif %}', '')
|
|
225
|
+
const p = writeTmp('unclosed-if.liquid', liquid)
|
|
226
|
+
const r = run(['validate', '--liquid-file', p])
|
|
227
|
+
assert.equal(r.status, 3)
|
|
228
|
+
const report = JSON.parse(r.stdout)
|
|
229
|
+
assert.ok(report.errors.find((e) => e.rule === 'liquid-unclosed-tag'))
|
|
230
|
+
})
|
|
231
|
+
|
|
232
|
+
test('validate: mismatched end tag reports error', () => {
|
|
233
|
+
const liquid = CLEAN_LIQUID.replace('{% endif %}', '{% endfor %}')
|
|
234
|
+
const p = writeTmp('mismatched.liquid', liquid)
|
|
235
|
+
const r = run(['validate', '--liquid-file', p])
|
|
236
|
+
assert.equal(r.status, 3)
|
|
237
|
+
const report = JSON.parse(r.stdout)
|
|
238
|
+
assert.ok(
|
|
239
|
+
report.errors.find((e) =>
|
|
240
|
+
e.rule === 'liquid-mismatched-end' || e.rule === 'liquid-unmatched-end' || e.rule === 'liquid-unclosed-tag',
|
|
241
|
+
),
|
|
242
|
+
)
|
|
243
|
+
})
|
|
244
|
+
|
|
245
|
+
// ---- Validation: surface anti-patterns ----
|
|
246
|
+
|
|
247
|
+
test('validate: <img> without alt reports error', () => {
|
|
248
|
+
const liquid = CLEAN_LIQUID.replace(
|
|
249
|
+
'<img src="/assets/def456.jpg" alt="" loading="lazy">',
|
|
250
|
+
'<img src="/assets/def456.jpg" loading="lazy">',
|
|
251
|
+
)
|
|
252
|
+
const p = writeTmp('no-alt.liquid', liquid)
|
|
253
|
+
const r = run(['validate', '--liquid-file', p])
|
|
254
|
+
assert.equal(r.status, 3)
|
|
255
|
+
const report = JSON.parse(r.stdout)
|
|
256
|
+
assert.ok(report.errors.find((e) => e.rule === 'img-missing-alt'))
|
|
257
|
+
})
|
|
258
|
+
|
|
259
|
+
test('validate: <button> without type reports error', () => {
|
|
260
|
+
const liquid = CLEAN_LIQUID.replace(
|
|
261
|
+
'<button type="button" class="sb-hero__cta" aria-label="Shop now">',
|
|
262
|
+
'<button class="sb-hero__cta" aria-label="Shop now">',
|
|
263
|
+
)
|
|
264
|
+
const p = writeTmp('no-type.liquid', liquid)
|
|
265
|
+
const r = run(['validate', '--liquid-file', p])
|
|
266
|
+
assert.equal(r.status, 3)
|
|
267
|
+
const report = JSON.parse(r.stdout)
|
|
268
|
+
assert.ok(report.errors.find((e) => e.rule === 'button-missing-type'))
|
|
269
|
+
})
|
|
270
|
+
|
|
271
|
+
test('validate: 100vh on height reports error (anti-pattern #1)', () => {
|
|
272
|
+
const liquid = CLEAN_LIQUID.replace('aspect-ratio: 1 / 1.3; max-height: 700px;', 'height: 100vh;')
|
|
273
|
+
const p = writeTmp('vh.liquid', liquid)
|
|
274
|
+
const r = run(['validate', '--liquid-file', p])
|
|
275
|
+
assert.equal(r.status, 3)
|
|
276
|
+
const report = JSON.parse(r.stdout)
|
|
277
|
+
assert.ok(report.errors.find((e) => e.rule === 'hero-100vh'))
|
|
278
|
+
})
|
|
279
|
+
|
|
280
|
+
test('validate: data:image/svg+xml in img src reports error (anti-pattern #8)', () => {
|
|
281
|
+
const liquid = CLEAN_LIQUID.replace(
|
|
282
|
+
'<img src="/assets/def456.jpg" alt="" loading="lazy">',
|
|
283
|
+
'<img src="data:image/svg+xml,%3Csvg%3E%3C/svg%3E" alt="" loading="lazy">',
|
|
284
|
+
)
|
|
285
|
+
const p = writeTmp('fake-svg.liquid', liquid)
|
|
286
|
+
const r = run(['validate', '--liquid-file', p])
|
|
287
|
+
assert.equal(r.status, 3)
|
|
288
|
+
const report = JSON.parse(r.stdout)
|
|
289
|
+
assert.ok(report.errors.find((e) => e.rule === 'fabricated-svg-data-uri'))
|
|
290
|
+
})
|
|
291
|
+
|
|
292
|
+
test('validate: Google Fonts <link rel="stylesheet"> reports error (anti-pattern #14)', () => {
|
|
293
|
+
const liquid = CLEAN_LIQUID.replace(
|
|
294
|
+
'<section class="sb-hero">',
|
|
295
|
+
'<link rel="stylesheet" href="https://fonts.googleapis.com/css2?family=Inter&display=swap">\n<section class="sb-hero">',
|
|
296
|
+
)
|
|
297
|
+
const p = writeTmp('fonts-link.liquid', liquid)
|
|
298
|
+
const r = run(['validate', '--liquid-file', p])
|
|
299
|
+
assert.equal(r.status, 3)
|
|
300
|
+
const report = JSON.parse(r.stdout)
|
|
301
|
+
assert.ok(report.errors.find((e) => e.rule === 'fonts-link-in-section'))
|
|
302
|
+
})
|
|
303
|
+
|
|
304
|
+
test('validate: block loop without {{ block.shopify_attributes }} reports error (anti-pattern #13)', () => {
|
|
305
|
+
// Inject a minimal block loop into a fresh liquid (and add a matching schema with blocks).
|
|
306
|
+
const minimalLiquid = `
|
|
307
|
+
{% style %}
|
|
308
|
+
.sb-list, .sb-list * { box-sizing: border-box; }
|
|
309
|
+
.sb-list .sb-list__item { padding: 8px; }
|
|
310
|
+
{% endstyle %}
|
|
311
|
+
<ul class="sb-list">
|
|
312
|
+
{% for block in section.blocks %}
|
|
313
|
+
<li class="sb-list__item">{{ block.settings.label }}</li>
|
|
314
|
+
{% endfor %}
|
|
315
|
+
</ul>
|
|
316
|
+
{% schema %}
|
|
317
|
+
{
|
|
318
|
+
"name": "List",
|
|
319
|
+
"tag": "section",
|
|
320
|
+
"settings": [],
|
|
321
|
+
"blocks": [
|
|
322
|
+
{ "type": "item", "name": "Item", "settings": [{ "type": "text", "id": "label", "label": "Label", "default": "x" }] }
|
|
323
|
+
],
|
|
324
|
+
"presets": [{ "name": "List", "category": "Text" }]
|
|
325
|
+
}
|
|
326
|
+
{% endschema %}
|
|
327
|
+
`
|
|
328
|
+
const p = writeTmp('no-shopify-attr.liquid', minimalLiquid)
|
|
329
|
+
const r = run(['validate', '--liquid-file', p])
|
|
330
|
+
assert.equal(r.status, 3)
|
|
331
|
+
const report = JSON.parse(r.stdout)
|
|
332
|
+
assert.ok(report.errors.find((e) => e.rule === 'block-missing-shopify-attributes'))
|
|
333
|
+
})
|
|
334
|
+
|
|
335
|
+
test('validate: missing reset emits warning (not error)', () => {
|
|
336
|
+
const minimal = `
|
|
337
|
+
{% style %}
|
|
338
|
+
.sb-x .sb-x__y { color: red; }
|
|
339
|
+
{% endstyle %}
|
|
340
|
+
<section class="sb-x"><span class="sb-x__y">hi</span></section>
|
|
341
|
+
{% schema %}
|
|
342
|
+
{ "name": "X", "settings": [], "presets": [{ "name": "X" }] }
|
|
343
|
+
{% endschema %}
|
|
344
|
+
`
|
|
345
|
+
const p = writeTmp('no-reset.liquid', minimal)
|
|
346
|
+
const r = run(['validate', '--liquid-file', p])
|
|
347
|
+
assert.equal(r.status, 0, `stderr: ${r.stderr}\nstdout: ${r.stdout}`)
|
|
348
|
+
const report = JSON.parse(r.stdout)
|
|
349
|
+
assert.ok(report.warnings.find((w) => w.rule === 'no-reset-box-sizing'))
|
|
350
|
+
})
|
|
351
|
+
|
|
352
|
+
// ---- write subcommand ----
|
|
353
|
+
|
|
354
|
+
test('write: persists stdin to output path', () => {
|
|
355
|
+
const out = join(TMP, 'nested', 'out.liquid')
|
|
356
|
+
const r = run(['write', '--output-path', out], CLEAN_LIQUID)
|
|
357
|
+
assert.equal(r.status, 0, `stderr: ${r.stderr}`)
|
|
358
|
+
const report = JSON.parse(r.stdout)
|
|
359
|
+
assert.equal(report.path, out)
|
|
360
|
+
assert.ok(report.bytes > 0)
|
|
361
|
+
assert.equal(report.validatedSchema, true)
|
|
362
|
+
assert.ok(existsSync(out))
|
|
363
|
+
const content = readFileSync(out, 'utf8')
|
|
364
|
+
assert.match(content, /\{%\s*schema\s*%\}/)
|
|
365
|
+
assert.match(content, /sb-hero/)
|
|
366
|
+
})
|
|
367
|
+
|
|
368
|
+
test('write: creates parent directories', () => {
|
|
369
|
+
const out = join(TMP, 'a', 'b', 'c', 'deep.liquid')
|
|
370
|
+
const r = run(['write', '--output-path', out], CLEAN_LIQUID)
|
|
371
|
+
assert.equal(r.status, 0)
|
|
372
|
+
assert.ok(existsSync(out))
|
|
373
|
+
})
|
|
374
|
+
|
|
375
|
+
test('write: reports validatedSchema=false on broken schema (still writes)', () => {
|
|
376
|
+
const out = join(TMP, 'broken.liquid')
|
|
377
|
+
const broken = CLEAN_LIQUID.replace('"name": "Hero (cloned)",', '')
|
|
378
|
+
const r = run(['write', '--output-path', out], broken)
|
|
379
|
+
assert.equal(r.status, 0)
|
|
380
|
+
const report = JSON.parse(r.stdout)
|
|
381
|
+
assert.equal(report.validatedSchema, false)
|
|
382
|
+
assert.ok(report.schemaErrors.length > 0)
|
|
383
|
+
assert.ok(existsSync(out))
|
|
384
|
+
})
|
|
385
|
+
|
|
386
|
+
// Pattern #29: prettier's HTML parser word-wraps long string literals inside
|
|
387
|
+
// {% schema %} JSON, inserting \n inside JSON strings. The fix splits the
|
|
388
|
+
// schema block out, formats its JSON with JSON.stringify, and re-injects after
|
|
389
|
+
// prettier. Strings ≥ printWidth (100) must survive intact.
|
|
390
|
+
test('write: schema with long-string default round-trips as parseable JSON', () => {
|
|
391
|
+
const longString = 'Revolutionizing Hair Care With Micro Infusion — Made for Men Who Care About Their Look And Confidence.'
|
|
392
|
+
const liquidWithLongString = CLEAN_LIQUID.replace(
|
|
393
|
+
'"default": "Welcome"',
|
|
394
|
+
`"default": "${longString}"`,
|
|
395
|
+
)
|
|
396
|
+
const out = join(TMP, 'long-string.liquid')
|
|
397
|
+
const r = run(['write', '--output-path', out], liquidWithLongString)
|
|
398
|
+
assert.equal(r.status, 0, `stderr: ${r.stderr}`)
|
|
399
|
+
const written = readFileSync(out, 'utf8')
|
|
400
|
+
const m = /\{%\s*schema\s*%\}([\s\S]*?)\{%\s*endschema\s*%\}/.exec(written)
|
|
401
|
+
assert.ok(m, 'schema block missing in written file')
|
|
402
|
+
// Must parse cleanly — no \n injected mid-string.
|
|
403
|
+
const parsed = JSON.parse(m[1].trim())
|
|
404
|
+
const heading = parsed.settings.find((s) => s.id === 'heading')
|
|
405
|
+
assert.equal(heading.default, longString, 'long-string default must survive verbatim')
|
|
406
|
+
// Re-validate the written file end-to-end — schema must pass.
|
|
407
|
+
const v = run(['validate', '--liquid-file', out])
|
|
408
|
+
assert.equal(v.status, 0, `re-validation failed:\nstderr: ${v.stderr}\nstdout: ${v.stdout}`)
|
|
409
|
+
const vReport = JSON.parse(v.stdout)
|
|
410
|
+
assert.equal(vReport.schemaValidated, true)
|
|
411
|
+
})
|
|
412
|
+
|
|
413
|
+
// ---- Cleanup ----
|
|
414
|
+
|
|
415
|
+
try {
|
|
416
|
+
rmSync(TMP, { recursive: true, force: true })
|
|
417
|
+
} catch {}
|
|
418
|
+
|
|
419
|
+
if (failed > 0) {
|
|
420
|
+
process.stdout.write(`\n${failed} failed, ${passed} passed\n`)
|
|
421
|
+
process.exit(1)
|
|
422
|
+
}
|
|
423
|
+
process.stdout.write(`\n${passed} passed\n`)
|
|
424
|
+
process.exit(0)
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: sb-build-wp
|
|
3
|
+
description: Composes a standalone WordPress/Elementor HTML fragment from a live-page inspection plus extracted assets, with defensive specificity, mobile-first CSS, and a11y/perf defaults baked in. Use when the SimilarBuild orchestrator (`/build-page`, `/build-site`, `/clip-section`) requests a WP build with `--target wp`, or when the user asks to 'build wp html' from an inspection + assets bundle.
|
|
4
|
+
---
|
|
5
|
+
|
|
6
|
+
# sb-build-wp
|
|
7
|
+
|
|
8
|
+
## Overview
|
|
9
|
+
|
|
10
|
+
Produces the single `.html` fragment a user pastes into an Elementor HTML widget — a `<style>` block scoped to one root class, the matching markup, and (when the section needs interactivity) a small `<script>` block. The fragment must survive Elementor's container constraints and the active theme's `!important`-laced overrides without any post-paste tweaking. Production-grade from the first build: every image has alt + correct loading hint, hero is preloaded, fonts use `display=swap`, buttons are `type="button"`, headings don't skip levels.
|
|
11
|
+
|
|
12
|
+
Unlike the upstream skills (`sb-inspect-live`, `sb-extract-assets`) where a script does ~95% of the work, **this skill is creative composition**. The LLM picks the pattern, names the classes, structures the markup, and decides where to apply defensive specificity. The `scripts/build-wp.mjs` helper handles only the deterministic plumbing around the composition: structural validation of the produced HTML and formatted persistence to disk.
|
|
13
|
+
|
|
14
|
+
Act as a WordPress/Elementor specialist. You know exactly which theme overrides bite (font-family, font-size, font-weight, color via `.elementor *` blanket rules; `img { height: auto !important }`; button background reset) and you defend against all of them with chained-scope selectors and surgical `!important` on the four font properties only. You do NOT touch Liquid, Shopify schema, or any other target — that's a different skill's job.
|
|
15
|
+
|
|
16
|
+
## Inputs
|
|
17
|
+
|
|
18
|
+
| Argument | Required | Notes |
|
|
19
|
+
| ----------------- | -------- | --------------------------------------------------------------------------------------------- |
|
|
20
|
+
| `inspection` | yes | Path to `inspection.json` from `sb-inspect-live`. Drives section type, tokens, DOM structure. |
|
|
21
|
+
| `assetsMap` | yes | Path to `assets-map.json` from `sb-extract-assets`. Source of every `<img src>` and inline SVG. |
|
|
22
|
+
| `outputPath` | yes | Where the final `.html` is written (parent dirs created automatically). |
|
|
23
|
+
| `designKnowledge` | no | Subset of `<plugin>/memory/design-knowledge.md` filtered by the orchestrator (a11y/perf rules relevant to this section). When absent, use the defaults in `references/wp-build-rules.md`. |
|
|
24
|
+
| `fixHints` | no | JSON array from `sb-review-checks` after a failed validation cycle. When present, you are patching a previous build, not building from scratch. |
|
|
25
|
+
| `previousHtmlPath` | no | Path to the previous build (only meaningful with `fixHints`). Read it, apply the targeted patches, do not refactor untouched code. |
|
|
26
|
+
|
|
27
|
+
The orchestrator passes these as concrete paths or inline values — there is no CLI for this skill, the LLM drives.
|
|
28
|
+
|
|
29
|
+
## Output
|
|
30
|
+
|
|
31
|
+
A single `.html` file written to `outputPath`. The file is a fragment — no `<html>`, `<head>`, or `<body>` wrappers — containing in order:
|
|
32
|
+
|
|
33
|
+
1. Optional `<link rel="preconnect">` (fonts.googleapis.com, fonts.gstatic.com), `<link rel="preload" as="image">` for the hero, `<link rel="stylesheet">` for the Google Font.
|
|
34
|
+
2. A `<style>` block: universal reset → scope container (full-bleed if applicable) → mobile-first element styles with chained-scope selectors → `@media (min-width: 1000px)` desktop overrides.
|
|
35
|
+
3. The scoped markup (`<section class="{scope}">...</section>` or appropriate semantic root).
|
|
36
|
+
4. Optional `<script>` block for vanilla JS interactivity.
|
|
37
|
+
|
|
38
|
+
## On Activation
|
|
39
|
+
|
|
40
|
+
1. **Read the inputs.** Parse `inspection.json` (capture `sectionType`, `tokens`, `dom`, `pseudoElements`, `imgUrls`) and `assets-map.json` (the URL → localPath / inline-SVG dictionary). If `fixHints` is given, also read `previousHtmlPath`.
|
|
41
|
+
|
|
42
|
+
2. **Load the build rules.** Read `references/wp-build-rules.md` — this file contains the composition contract, defensive-specificity rule, !important policy, full-bleed escape, mobile-first breakpoint, asset-substitution rule, hero rules (no 100vh), Google Fonts inlining, accessibility/performance defaults, the canonical patterns A-H, and the anti-patterns #1-9. Treat it as your reference manual for this build.
|
|
43
|
+
|
|
44
|
+
3. **Layer in user-curated knowledge if present.** When `<plugin>/memory/patterns.md`, `<plugin>/memory/anti-patterns.md`, or `<plugin>/presets/wp-elementor.yaml` exist in the host project, load them and let them OVERRIDE the matching defaults from `wp-build-rules.md` — they reflect the user's most recent learning. Skip silently when missing (the framework is early; these get populated over time).
|
|
45
|
+
|
|
46
|
+
4. **Compose the HTML.** Pick the pattern that matches `inspection.sectionType` (and the markup shape — inspect `dom` and `pseudoElements`). Apply every non-negotiable from `wp-build-rules.md`: defensive specificity on every critical selector, `!important` on font-family/font-size/font-weight/color, universal reset at the top, full-bleed container if the section spans viewport-edge, mobile-first base + desktop media query, every `<img src>` resolved through `assetsMap.assets[url].localPath`, SVGs inlined from `assetsMap.assets[url].inline`, no fabricated SVG, no `100vh` for hero, Google Fonts preconnect + display=swap, hero preload link, alt on every image, type="button" on every button, heading hierarchy unbroken. The `designKnowledge` subset (when passed) extends or refines these defaults — apply it.
|
|
47
|
+
|
|
48
|
+
5. **If `fixHints` are present, patch surgically.** Read the previous output, apply each fixHint to the specific selector or markup it names, and keep everything else untouched. The reviewer's diff should be the only diff. High-severity hints are mandatory; low-severity are optional unless they conflict.
|
|
49
|
+
|
|
50
|
+
6. **Validate.** Write the composed HTML to a temp file and run:
|
|
51
|
+
|
|
52
|
+
```bash
|
|
53
|
+
node {skill-root}/scripts/build-wp.mjs validate --html-file <tmp>
|
|
54
|
+
```
|
|
55
|
+
|
|
56
|
+
The script reports structural errors (missing `<style>`, missing alt/type, `data:image/svg+xml` raster impostors, `100vh` on heights, fonts without `display=swap`) and warnings (no preload, no preconnect, no defensive-specificity selectors, no reset). Exit code 0 means errors are clean. If it exits 1, fix the flagged issues and re-validate. Warnings are advisory — fix what you can without overstepping the inputs.
|
|
57
|
+
|
|
58
|
+
7. **Persist.** Pipe the validated HTML into:
|
|
59
|
+
|
|
60
|
+
```bash
|
|
61
|
+
node {skill-root}/scripts/build-wp.mjs write --output-path <outputPath>
|
|
62
|
+
```
|
|
63
|
+
|
|
64
|
+
The script formats with prettier when available (graceful skip when not installed) and writes to `outputPath`, creating parent directories as needed. It returns `{path, bytes, formatted}` JSON.
|
|
65
|
+
|
|
66
|
+
8. **Return the result** to the orchestrator: the absolute path to the written file, the validator's report (errors + warnings), and a short note on which pattern was applied. Do not return the file's contents — the orchestrator reads the file when it needs them.
|
|
67
|
+
|
|
68
|
+
## Failure modes
|
|
69
|
+
|
|
70
|
+
| Symptom | Likely cause | What to surface |
|
|
71
|
+
| ----------------------------------------------- | --------------------------------------------- | -------------------------------------------------------------------------- |
|
|
72
|
+
| Validator returns errors after first compose | Missed a non-negotiable from the rules | Fix and re-validate (up to 2 attempts). If still failing, surface the report. |
|
|
73
|
+
| `assetsMap.failed[]` includes a critical asset | Source 404 / network failure during extract | Drop the image (decorative) or use a literal-color placeholder (hero). Never fabricate. Comment in the output: `<!-- sb-build-wp: asset failed download -->`. |
|
|
74
|
+
| `inspection.widgetBlocked: true` | Bot challenge on inspect — DOM is unreliable | Stop. Tell the orchestrator the inspection is unusable. Do not compose from a blocked page. |
|
|
75
|
+
| Pattern doesn't match any section in `inspection.dom` | Section type detected but markup is ambiguous | Pick the closest pattern from A-H, document the choice in a comment, and surface to the orchestrator. |
|
|
76
|
+
| Prettier missing | Optional dep not installed | Non-fatal — `write` reports `formatted: false, formatterSkippedReason: "prettier-not-installed"`. Output is still written. |
|
|
77
|
+
|
|
78
|
+
## Conventions
|
|
79
|
+
|
|
80
|
+
- Bare paths (e.g. `scripts/build-wp.mjs`) resolve from the skill root.
|
|
81
|
+
- `{skill-root}` resolves to this skill's installed directory.
|
|
82
|
+
- `{project-root}` resolves to the project working directory.
|
|
83
|
+
- `<plugin>/memory/...` and `<plugin>/presets/...` are paths inside the SimilarBuild plugin install — read them when present, fall back to bundled defaults when absent.
|