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,637 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// build-shopify.mjs — Plumbing for sb-build-shopify.
|
|
3
|
+
//
|
|
4
|
+
// The LLM composes the .liquid file (Liquid + {% schema %} JSON + scoped CSS);
|
|
5
|
+
// this script handles the deterministic plumbing around that composition:
|
|
6
|
+
// structural validation of the produced Liquid (schema JSON parsing, Liquid
|
|
7
|
+
// tag balance, anti-pattern checks) and formatting + persistence to the output
|
|
8
|
+
// path.
|
|
9
|
+
//
|
|
10
|
+
// Two subcommands:
|
|
11
|
+
// validate --liquid-file <path> Check structural rules, JSON to stdout.
|
|
12
|
+
// Exit 0 if all errors clean, 3 if errors.
|
|
13
|
+
// write --output-path <path> Read .liquid from stdin, optional
|
|
14
|
+
// prettier format, write to disk,
|
|
15
|
+
// JSON metadata to stdout.
|
|
16
|
+
//
|
|
17
|
+
// Validation rules check the *mechanical* subset of the rules in
|
|
18
|
+
// references/shopify-build-rules.md — schema JSON well-formedness, Liquid tag
|
|
19
|
+
// balance, the canonical anti-patterns we can detect with regex, schema field
|
|
20
|
+
// requirements (presets, no `default` on image_picker, unique setting ids).
|
|
21
|
+
// Semantic correctness (right pattern, defensive specificity actually applied,
|
|
22
|
+
// alt text being meaningful, settings being well-named) is the LLM's job.
|
|
23
|
+
//
|
|
24
|
+
// prettier and liquidjs are imported lazily and are OPTIONAL. If they're not
|
|
25
|
+
// installed, validate and write still succeed — formatting and full Liquid
|
|
26
|
+
// parsing are skipped with a recorded reason.
|
|
27
|
+
//
|
|
28
|
+
// Exit codes: 0=ok, 1=script error, 2=invalid args, 3=validation failed.
|
|
29
|
+
|
|
30
|
+
import { parseArgs } from 'node:util'
|
|
31
|
+
import { mkdir, writeFile, readFile } from 'node:fs/promises'
|
|
32
|
+
import { dirname, resolve } from 'node:path'
|
|
33
|
+
|
|
34
|
+
const HELP = `
|
|
35
|
+
build-shopify.mjs — Validate and persist .liquid produced by sb-build-shopify.
|
|
36
|
+
|
|
37
|
+
Subcommands:
|
|
38
|
+
validate --liquid-file <path> Run structural lint on the .liquid file.
|
|
39
|
+
write --output-path <path> Read .liquid from stdin, format if prettier
|
|
40
|
+
is available, write to <output-path>.
|
|
41
|
+
|
|
42
|
+
Common flags:
|
|
43
|
+
--help Show this message.
|
|
44
|
+
--verbose Extra diagnostics to stderr.
|
|
45
|
+
|
|
46
|
+
Exit codes: 0=ok, 1=script error, 2=invalid args, 3=validation failed.
|
|
47
|
+
`
|
|
48
|
+
|
|
49
|
+
function fail(msg, code = 2) {
|
|
50
|
+
process.stderr.write(`[sb-build-shopify] ${msg}\n`)
|
|
51
|
+
process.exit(code)
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
function log(verbose, msg) {
|
|
55
|
+
if (verbose) process.stderr.write(`[sb-build-shopify] ${msg}\n`)
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
async function readStdin() {
|
|
59
|
+
const chunks = []
|
|
60
|
+
for await (const chunk of process.stdin) chunks.push(chunk)
|
|
61
|
+
return Buffer.concat(chunks).toString('utf8')
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
// --- Helpers ------------------------------------------------------------------
|
|
65
|
+
|
|
66
|
+
function findAll(re, str) {
|
|
67
|
+
const out = []
|
|
68
|
+
const flags = re.flags.includes('g') ? re.flags : `${re.flags}g`
|
|
69
|
+
const r = new RegExp(re.source, flags)
|
|
70
|
+
for (let m = r.exec(str); m !== null; m = r.exec(str)) {
|
|
71
|
+
out.push(m)
|
|
72
|
+
}
|
|
73
|
+
return out
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
// Strip {% schema %}...{% endschema %} so its JSON content (which has its own
|
|
77
|
+
// {% / %} characters in string literals occasionally, and braces) doesn't
|
|
78
|
+
// confuse downstream regex passes.
|
|
79
|
+
function stripSchemaBlock(liquid) {
|
|
80
|
+
return liquid.replace(/\{%\s*schema\s*%\}[\s\S]*?\{%\s*endschema\s*%\}/g, '<!-- schema -->')
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
// --- Liquid tag balance -------------------------------------------------------
|
|
84
|
+
|
|
85
|
+
const PAIRED_TAGS = [
|
|
86
|
+
'if', 'unless', 'for', 'case', 'capture', 'comment', 'form', 'paginate',
|
|
87
|
+
'tablerow', 'style', 'javascript', 'stylesheet', 'schema', 'raw',
|
|
88
|
+
]
|
|
89
|
+
|
|
90
|
+
function checkLiquidBalance(liquid) {
|
|
91
|
+
const errors = []
|
|
92
|
+
const stack = []
|
|
93
|
+
// Match every {% tag ... %}. Within each, capture the leading word.
|
|
94
|
+
const tagRe = /\{%-?\s*(\w+)[\s\S]*?-?%\}/g
|
|
95
|
+
for (let m = tagRe.exec(liquid); m !== null; m = tagRe.exec(liquid)) {
|
|
96
|
+
const tag = m[1]
|
|
97
|
+
if (PAIRED_TAGS.includes(tag)) {
|
|
98
|
+
stack.push({ tag, index: m.index })
|
|
99
|
+
} else if (tag.startsWith('end')) {
|
|
100
|
+
const expected = tag.slice(3)
|
|
101
|
+
if (PAIRED_TAGS.includes(expected)) {
|
|
102
|
+
if (stack.length === 0) {
|
|
103
|
+
errors.push({
|
|
104
|
+
rule: 'liquid-unmatched-end',
|
|
105
|
+
message: `{% ${tag} %} found with no matching opening tag.`,
|
|
106
|
+
})
|
|
107
|
+
} else {
|
|
108
|
+
const top = stack.pop()
|
|
109
|
+
if (top.tag !== expected) {
|
|
110
|
+
errors.push({
|
|
111
|
+
rule: 'liquid-mismatched-end',
|
|
112
|
+
message: `{% ${tag} %} does not match {% ${top.tag} %} (opened at index ${top.index}).`,
|
|
113
|
+
})
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
for (const open of stack) {
|
|
120
|
+
errors.push({
|
|
121
|
+
rule: 'liquid-unclosed-tag',
|
|
122
|
+
message: `{% ${open.tag} %} opened at index ${open.index} was never closed.`,
|
|
123
|
+
})
|
|
124
|
+
}
|
|
125
|
+
return errors
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
// --- Schema extraction & validation ------------------------------------------
|
|
129
|
+
|
|
130
|
+
function extractSchema(liquid) {
|
|
131
|
+
const m = /\{%\s*schema\s*%\}([\s\S]*?)\{%\s*endschema\s*%\}/.exec(liquid)
|
|
132
|
+
return m ? m[1].trim() : null
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
function validateSchema(liquid) {
|
|
136
|
+
const errors = []
|
|
137
|
+
const warnings = []
|
|
138
|
+
const raw = extractSchema(liquid)
|
|
139
|
+
if (raw === null) {
|
|
140
|
+
errors.push({
|
|
141
|
+
rule: 'schema-missing',
|
|
142
|
+
message: 'No {% schema %}...{% endschema %} block found. Shopify will refuse to register the section.',
|
|
143
|
+
})
|
|
144
|
+
return { errors, warnings, schema: null }
|
|
145
|
+
}
|
|
146
|
+
let schema
|
|
147
|
+
try {
|
|
148
|
+
schema = JSON.parse(raw)
|
|
149
|
+
} catch (err) {
|
|
150
|
+
errors.push({
|
|
151
|
+
rule: 'schema-invalid-json',
|
|
152
|
+
message: `Schema is not valid JSON: ${err.message}`,
|
|
153
|
+
})
|
|
154
|
+
return { errors, warnings, schema: null }
|
|
155
|
+
}
|
|
156
|
+
if (typeof schema !== 'object' || schema === null || Array.isArray(schema)) {
|
|
157
|
+
errors.push({
|
|
158
|
+
rule: 'schema-not-object',
|
|
159
|
+
message: 'Schema root must be a JSON object.',
|
|
160
|
+
})
|
|
161
|
+
return { errors, warnings, schema: null }
|
|
162
|
+
}
|
|
163
|
+
if (typeof schema.name !== 'string' || schema.name.length === 0) {
|
|
164
|
+
errors.push({
|
|
165
|
+
rule: 'schema-missing-name',
|
|
166
|
+
message: 'Schema must have a non-empty "name" field.',
|
|
167
|
+
})
|
|
168
|
+
}
|
|
169
|
+
if (!Array.isArray(schema.presets) || schema.presets.length === 0) {
|
|
170
|
+
errors.push({
|
|
171
|
+
rule: 'schema-missing-presets',
|
|
172
|
+
message: 'Schema must have at least one preset (anti-pattern #12). Without presets, the section is uninstallable from the editor.',
|
|
173
|
+
})
|
|
174
|
+
} else {
|
|
175
|
+
schema.presets.forEach((p, i) => {
|
|
176
|
+
if (typeof p !== 'object' || p === null || typeof p.name !== 'string') {
|
|
177
|
+
errors.push({
|
|
178
|
+
rule: 'schema-preset-invalid',
|
|
179
|
+
message: `presets[${i}] must be an object with a "name" string.`,
|
|
180
|
+
})
|
|
181
|
+
}
|
|
182
|
+
})
|
|
183
|
+
}
|
|
184
|
+
const settings = Array.isArray(schema.settings) ? schema.settings : []
|
|
185
|
+
const seenIds = new Map()
|
|
186
|
+
settings.forEach((s, i) => {
|
|
187
|
+
if (typeof s !== 'object' || s === null) {
|
|
188
|
+
errors.push({
|
|
189
|
+
rule: 'schema-setting-invalid',
|
|
190
|
+
message: `settings[${i}] must be an object.`,
|
|
191
|
+
})
|
|
192
|
+
return
|
|
193
|
+
}
|
|
194
|
+
if (typeof s.type !== 'string') {
|
|
195
|
+
errors.push({
|
|
196
|
+
rule: 'schema-setting-missing-type',
|
|
197
|
+
message: `settings[${i}] missing "type" string.`,
|
|
198
|
+
})
|
|
199
|
+
return
|
|
200
|
+
}
|
|
201
|
+
if (s.type === 'header' || s.type === 'paragraph') return // these don't need an id
|
|
202
|
+
if (typeof s.id !== 'string' || s.id.length === 0) {
|
|
203
|
+
errors.push({
|
|
204
|
+
rule: 'schema-setting-missing-id',
|
|
205
|
+
message: `settings[${i}] (type=${s.type}) is missing "id".`,
|
|
206
|
+
})
|
|
207
|
+
return
|
|
208
|
+
}
|
|
209
|
+
if (seenIds.has(s.id)) {
|
|
210
|
+
errors.push({
|
|
211
|
+
rule: 'schema-setting-duplicate-id',
|
|
212
|
+
message: `settings[${i}] has duplicate id "${s.id}" (also at index ${seenIds.get(s.id)}).`,
|
|
213
|
+
})
|
|
214
|
+
} else {
|
|
215
|
+
seenIds.set(s.id, i)
|
|
216
|
+
}
|
|
217
|
+
if (s.type === 'image_picker' && Object.hasOwn(s, 'default')) {
|
|
218
|
+
errors.push({
|
|
219
|
+
rule: 'schema-image-picker-default',
|
|
220
|
+
message: `Anti-pattern #11: settings[${i}] (id="${s.id}") is image_picker with a "default". image_picker does not accept default — use a Liquid {% if %}{% else %}{% endif %} fallback.`,
|
|
221
|
+
})
|
|
222
|
+
}
|
|
223
|
+
})
|
|
224
|
+
// Block iterated → block setting type sanity.
|
|
225
|
+
if (Array.isArray(schema.blocks)) {
|
|
226
|
+
schema.blocks.forEach((b, i) => {
|
|
227
|
+
if (typeof b !== 'object' || b === null) {
|
|
228
|
+
errors.push({
|
|
229
|
+
rule: 'schema-block-invalid',
|
|
230
|
+
message: `blocks[${i}] must be an object.`,
|
|
231
|
+
})
|
|
232
|
+
return
|
|
233
|
+
}
|
|
234
|
+
if (typeof b.type !== 'string' || b.type.length === 0) {
|
|
235
|
+
errors.push({
|
|
236
|
+
rule: 'schema-block-missing-type',
|
|
237
|
+
message: `blocks[${i}] missing "type".`,
|
|
238
|
+
})
|
|
239
|
+
}
|
|
240
|
+
if (typeof b.name !== 'string' || b.name.length === 0) {
|
|
241
|
+
warnings.push({
|
|
242
|
+
rule: 'schema-block-missing-name',
|
|
243
|
+
message: `blocks[${i}] is missing "name" — editor sidebar will show the type instead.`,
|
|
244
|
+
})
|
|
245
|
+
}
|
|
246
|
+
})
|
|
247
|
+
}
|
|
248
|
+
return { errors, warnings, schema }
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
// --- HTML/Liquid surface validation (anti-patterns + a11y/perf) ---------------
|
|
252
|
+
|
|
253
|
+
function validateSurface(liquid) {
|
|
254
|
+
const errors = []
|
|
255
|
+
const warnings = []
|
|
256
|
+
const info = []
|
|
257
|
+
|
|
258
|
+
// Strip schema before regexing — JSON inside has braces/percents that confuse other passes.
|
|
259
|
+
const body = stripSchemaBlock(liquid)
|
|
260
|
+
|
|
261
|
+
// 1. Every <img> must have alt attribute (decorative use alt="").
|
|
262
|
+
const imgs = findAll(/<img\b[^>]*\/?>/gi, body)
|
|
263
|
+
imgs.forEach((m, i) => {
|
|
264
|
+
if (!/\balt\s*=/i.test(m[0])) {
|
|
265
|
+
errors.push({
|
|
266
|
+
rule: 'img-missing-alt',
|
|
267
|
+
message: `<img> #${i + 1} is missing alt attribute. Decorative images use alt="".`,
|
|
268
|
+
snippet: m[0].slice(0, 120),
|
|
269
|
+
})
|
|
270
|
+
}
|
|
271
|
+
})
|
|
272
|
+
|
|
273
|
+
// 2. Every <button> must have type=.
|
|
274
|
+
const buttons = findAll(/<button\b[^>]*>/gi, body)
|
|
275
|
+
buttons.forEach((m, i) => {
|
|
276
|
+
if (!/\btype\s*=/i.test(m[0])) {
|
|
277
|
+
errors.push({
|
|
278
|
+
rule: 'button-missing-type',
|
|
279
|
+
message: `<button> #${i + 1} is missing type attribute. Use type="button" unless intentionally a submit.`,
|
|
280
|
+
snippet: m[0].slice(0, 120),
|
|
281
|
+
})
|
|
282
|
+
}
|
|
283
|
+
})
|
|
284
|
+
|
|
285
|
+
// 3. Anti-pattern #8 — fabricated raster-as-SVG via inline data URI in <img src>.
|
|
286
|
+
imgs.forEach((m) => {
|
|
287
|
+
const srcMatch = /\bsrc\s*=\s*["']([^"']+)["']/i.exec(m[0])
|
|
288
|
+
if (srcMatch && /^data:image\/svg\+xml/i.test(srcMatch[1])) {
|
|
289
|
+
errors.push({
|
|
290
|
+
rule: 'fabricated-svg-data-uri',
|
|
291
|
+
message: 'Anti-pattern #8: <img src="data:image/svg+xml,..."> indicates a fabricated SVG. Use the real asset from assetsMap.',
|
|
292
|
+
snippet: `${srcMatch[1].slice(0, 80)}...`,
|
|
293
|
+
})
|
|
294
|
+
}
|
|
295
|
+
})
|
|
296
|
+
|
|
297
|
+
// 4. Anti-pattern #1 — 100vh on a height-related property in CSS (inside {% style %} or <style>).
|
|
298
|
+
const styleBlocks = [
|
|
299
|
+
...findAll(/\{%\s*style\s*%\}([\s\S]*?)\{%\s*endstyle\s*%\}/gi, body),
|
|
300
|
+
...findAll(/<style\b[^>]*>([\s\S]*?)<\/style>/gi, body),
|
|
301
|
+
]
|
|
302
|
+
const css = styleBlocks.map((m) => m[1]).join('\n')
|
|
303
|
+
const vhUses = findAll(/(min-height|height)\s*:\s*100vh\b/gi, css)
|
|
304
|
+
if (vhUses.length > 0) {
|
|
305
|
+
errors.push({
|
|
306
|
+
rule: 'hero-100vh',
|
|
307
|
+
message: `Anti-pattern #1: 100vh used on ${vhUses.length} height declaration(s). Use aspect-ratio + max-height instead.`,
|
|
308
|
+
})
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
// 5. Anti-pattern #14 — Google Fonts <link rel="stylesheet"> inside the section. Shopify owns <head>.
|
|
312
|
+
const fontsStylesheet = findAll(/<link\b[^>]*>/gi, body).filter((m) =>
|
|
313
|
+
/\bhref\s*=\s*["'][^"']*fonts\.googleapis\.com\/css/i.test(m[0]) &&
|
|
314
|
+
/\brel\s*=\s*["']stylesheet["']/i.test(m[0])
|
|
315
|
+
)
|
|
316
|
+
if (fontsStylesheet.length > 0) {
|
|
317
|
+
errors.push({
|
|
318
|
+
rule: 'fonts-link-in-section',
|
|
319
|
+
message: `Anti-pattern #14: Google Fonts <link rel="stylesheet"> found inside the section. Shopify owns <head>; use the {{ font_picker | font_face }} filter instead.`,
|
|
320
|
+
})
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
// 6. Anti-pattern #14 (companion) — preconnect to font hosts inside the section.
|
|
324
|
+
const fontPreconnect = findAll(/<link\b[^>]*>/gi, body).filter((m) =>
|
|
325
|
+
/\brel\s*=\s*["']preconnect["']/i.test(m[0]) &&
|
|
326
|
+
/\bhref\s*=\s*["'][^"']*fonts\.(googleapis|gstatic)\.com/i.test(m[0])
|
|
327
|
+
)
|
|
328
|
+
if (fontPreconnect.length > 0) {
|
|
329
|
+
warnings.push({
|
|
330
|
+
rule: 'fonts-preconnect-in-section',
|
|
331
|
+
message: `${fontPreconnect.length} <link rel="preconnect"> to fonts.* found. Sections should not preconnect — Shopify's theme handles font loading.`,
|
|
332
|
+
})
|
|
333
|
+
}
|
|
334
|
+
|
|
335
|
+
// 7. block iteration without {{ block.shopify_attributes }} — anti-pattern #13.
|
|
336
|
+
const blockLoops = findAll(/\{%\s*for\s+block\s+in\s+section\.blocks\s*%\}([\s\S]*?)\{%\s*endfor\s*%\}/gi, body)
|
|
337
|
+
blockLoops.forEach((m, i) => {
|
|
338
|
+
if (!/\{\{\s*block\.shopify_attributes\s*\}\}/.test(m[1])) {
|
|
339
|
+
errors.push({
|
|
340
|
+
rule: 'block-missing-shopify-attributes',
|
|
341
|
+
message: `Anti-pattern #13: {% for block in section.blocks %} loop #${i + 1} does not emit {{ block.shopify_attributes }}. The editor cannot link the block UI to the rendered element.`,
|
|
342
|
+
})
|
|
343
|
+
}
|
|
344
|
+
})
|
|
345
|
+
|
|
346
|
+
// 8. Hardcoded CDN URLs in markup — anti-pattern #15.
|
|
347
|
+
const cdnHardcoded = findAll(
|
|
348
|
+
/\bsrc\s*=\s*["'](https?:\/\/(?:[a-z0-9-]+\.)*(?:shopify\.com|cdn\.shopify|myshopify\.com|cloudinary|imgix|akamaized|cloudfront)[^"']*)["']/gi,
|
|
349
|
+
body,
|
|
350
|
+
)
|
|
351
|
+
if (cdnHardcoded.length > 0) {
|
|
352
|
+
warnings.push({
|
|
353
|
+
rule: 'hardcoded-cdn-url',
|
|
354
|
+
message: `Anti-pattern #15: ${cdnHardcoded.length} hardcoded CDN URL(s) in src. Use {{ image | image_url: width: N }} for setting-driven, localPath for fallback.`,
|
|
355
|
+
})
|
|
356
|
+
}
|
|
357
|
+
|
|
358
|
+
// 9. Defensive specificity heuristic.
|
|
359
|
+
if (css.length > 0) {
|
|
360
|
+
const chainedSelectors = findAll(/\.([a-z][a-z0-9_-]*)\s+\.\1__/gi, css).length
|
|
361
|
+
info.push({
|
|
362
|
+
rule: 'defensive-specificity-count',
|
|
363
|
+
message: `Found ${chainedSelectors} selector(s) using the chained-scope pattern (.scope .scope__X). More is better.`,
|
|
364
|
+
})
|
|
365
|
+
if (chainedSelectors === 0 && css.length > 200) {
|
|
366
|
+
warnings.push({
|
|
367
|
+
rule: 'no-defensive-specificity',
|
|
368
|
+
message: 'No selectors found using the chained-scope pattern (.scope .scope__X). Anti-pattern #5b: Dawn / OS 2.0 overrides will win.',
|
|
369
|
+
})
|
|
370
|
+
}
|
|
371
|
+
}
|
|
372
|
+
|
|
373
|
+
// 10. Reset rule heuristic.
|
|
374
|
+
if (css.length > 0 && !/box-sizing\s*:\s*border-box/i.test(css)) {
|
|
375
|
+
warnings.push({
|
|
376
|
+
rule: 'no-reset-box-sizing',
|
|
377
|
+
message: 'No box-sizing: border-box reset found. Add the universal reset to the top of the {% style %} block.',
|
|
378
|
+
})
|
|
379
|
+
}
|
|
380
|
+
|
|
381
|
+
// 11. {% style %} preferred over <style> for setting-driven CSS.
|
|
382
|
+
if (
|
|
383
|
+
/<style\b/i.test(body) &&
|
|
384
|
+
!/\{%\s*style\s*%\}/i.test(body) &&
|
|
385
|
+
/\{\{[\s\S]*?\}\}/.test(css)
|
|
386
|
+
) {
|
|
387
|
+
warnings.push({
|
|
388
|
+
rule: 'style-tag-with-liquid',
|
|
389
|
+
message: '<style> block contains Liquid expressions but is not wrapped in {% style %}. Use {% style %}{% endstyle %} so Shopify processes the Liquid.',
|
|
390
|
+
})
|
|
391
|
+
}
|
|
392
|
+
|
|
393
|
+
// 12. If multiple images, most should have loading attribute.
|
|
394
|
+
if (imgs.length >= 2) {
|
|
395
|
+
const withoutLoading = imgs.filter((m) => !/\bloading\s*=/i.test(m[0])).length
|
|
396
|
+
if (withoutLoading >= imgs.length - 1) {
|
|
397
|
+
warnings.push({
|
|
398
|
+
rule: 'imgs-missing-loading',
|
|
399
|
+
message: `${withoutLoading}/${imgs.length} <img> tags lack a loading attribute. Hero/LCP can be eager; the rest should be lazy.`,
|
|
400
|
+
})
|
|
401
|
+
}
|
|
402
|
+
}
|
|
403
|
+
|
|
404
|
+
return { errors, warnings, info }
|
|
405
|
+
}
|
|
406
|
+
|
|
407
|
+
// --- liquidjs (lazy, optional) ------------------------------------------------
|
|
408
|
+
|
|
409
|
+
// Strip Shopify-only paired tag wrappers ({% style %}, {% javascript %},
|
|
410
|
+
// {% stylesheet %}, {% schema %}, {% form %}, {% paginate %}) so liquidjs
|
|
411
|
+
// (which only knows core Liquid tags) doesn't choke. The wrappers' balance is
|
|
412
|
+
// already verified by checkLiquidBalance — here we only care about parsing the
|
|
413
|
+
// expressions inside.
|
|
414
|
+
//
|
|
415
|
+
// {% schema %} contents are JSON, not Liquid, so we replace the whole block
|
|
416
|
+
// with a comment. The other wrappers may contain Liquid expressions, so we
|
|
417
|
+
// preserve their inner content.
|
|
418
|
+
function stripShopifyOnlyTags(liquid) {
|
|
419
|
+
let out = stripSchemaBlock(liquid)
|
|
420
|
+
const innerPreservingPairs = ['style', 'javascript', 'stylesheet', 'form', 'paginate']
|
|
421
|
+
for (const tag of innerPreservingPairs) {
|
|
422
|
+
const open = new RegExp(`\\{%-?\\s*${tag}\\b[^%]*%\\}`, 'gi')
|
|
423
|
+
const close = new RegExp(`\\{%-?\\s*end${tag}\\s*-?%\\}`, 'gi')
|
|
424
|
+
out = out.replace(open, '').replace(close, '')
|
|
425
|
+
}
|
|
426
|
+
return out
|
|
427
|
+
}
|
|
428
|
+
|
|
429
|
+
async function tryLiquidParse(liquid, verbose) {
|
|
430
|
+
let liquidjs
|
|
431
|
+
try {
|
|
432
|
+
liquidjs = await import('liquidjs')
|
|
433
|
+
} catch {
|
|
434
|
+
log(verbose, 'liquidjs not installed — relying on regex tag-balance check only')
|
|
435
|
+
return { parsed: false, reason: 'liquidjs-not-installed', errors: [] }
|
|
436
|
+
}
|
|
437
|
+
try {
|
|
438
|
+
const Liquid = liquidjs.Liquid
|
|
439
|
+
const engine = new Liquid({ strictFilters: false, strictVariables: false })
|
|
440
|
+
const stripped = stripShopifyOnlyTags(liquid)
|
|
441
|
+
engine.parse(stripped)
|
|
442
|
+
return { parsed: true, reason: null, errors: [] }
|
|
443
|
+
} catch (err) {
|
|
444
|
+
return {
|
|
445
|
+
parsed: false,
|
|
446
|
+
reason: `liquidjs-parse-error: ${err.message}`,
|
|
447
|
+
errors: [{ rule: 'liquid-parse-error', message: err.message }],
|
|
448
|
+
}
|
|
449
|
+
}
|
|
450
|
+
}
|
|
451
|
+
|
|
452
|
+
// --- Top-level validate -------------------------------------------------------
|
|
453
|
+
|
|
454
|
+
async function validateLiquid(liquid, verbose) {
|
|
455
|
+
const allErrors = []
|
|
456
|
+
const allWarnings = []
|
|
457
|
+
const allInfo = []
|
|
458
|
+
|
|
459
|
+
// Tag balance (regex-based, always available).
|
|
460
|
+
const balanceErrors = checkLiquidBalance(liquid)
|
|
461
|
+
allErrors.push(...balanceErrors)
|
|
462
|
+
|
|
463
|
+
// Optional: full parse via liquidjs.
|
|
464
|
+
const liquidParse = await tryLiquidParse(liquid, verbose)
|
|
465
|
+
allErrors.push(...liquidParse.errors)
|
|
466
|
+
|
|
467
|
+
// Schema extraction + validation.
|
|
468
|
+
const schemaResult = validateSchema(liquid)
|
|
469
|
+
allErrors.push(...schemaResult.errors)
|
|
470
|
+
allWarnings.push(...schemaResult.warnings)
|
|
471
|
+
|
|
472
|
+
// Surface (anti-patterns, a11y, perf).
|
|
473
|
+
const surface = validateSurface(liquid)
|
|
474
|
+
allErrors.push(...surface.errors)
|
|
475
|
+
allWarnings.push(...surface.warnings)
|
|
476
|
+
allInfo.push(...surface.info)
|
|
477
|
+
|
|
478
|
+
return {
|
|
479
|
+
passed: allErrors.length === 0,
|
|
480
|
+
errorCount: allErrors.length,
|
|
481
|
+
warningCount: allWarnings.length,
|
|
482
|
+
errors: allErrors,
|
|
483
|
+
warnings: allWarnings,
|
|
484
|
+
info: allInfo,
|
|
485
|
+
schemaValidated: schemaResult.schema !== null,
|
|
486
|
+
liquidParsed: liquidParse.parsed,
|
|
487
|
+
liquidSkipReason: liquidParse.reason,
|
|
488
|
+
}
|
|
489
|
+
}
|
|
490
|
+
|
|
491
|
+
// --- prettier (lazy, optional) ------------------------------------------------
|
|
492
|
+
|
|
493
|
+
async function tryPrettierFormat(content, verbose) {
|
|
494
|
+
let prettier
|
|
495
|
+
try {
|
|
496
|
+
prettier = await import('prettier')
|
|
497
|
+
} catch {
|
|
498
|
+
log(verbose, 'prettier not installed — writing unformatted')
|
|
499
|
+
return { content, formatted: false, reason: 'prettier-not-installed' }
|
|
500
|
+
}
|
|
501
|
+
// Prettier's HTML parser word-wraps long string literals inside `{% schema %}`
|
|
502
|
+
// (it doesn't know the block is JSON), inserting `\n` *inside* JSON strings
|
|
503
|
+
// and producing invalid JSON. We extract the schema block, format its JSON
|
|
504
|
+
// ourselves with JSON.stringify, swap a placeholder comment in for prettier,
|
|
505
|
+
// then re-inject the formatted JSON afterwards. Outside that block, prettier
|
|
506
|
+
// formats HTML + Liquid tags-as-text the same as before.
|
|
507
|
+
const SCHEMA_PLACEHOLDER = '<!--sb-shopify-schema-placeholder-->'
|
|
508
|
+
const schemaRe = /\{%-?\s*schema\s*-?%\}([\s\S]*?)\{%-?\s*endschema\s*-?%\}/
|
|
509
|
+
const schemaMatch = schemaRe.exec(content)
|
|
510
|
+
let formattedSchemaBlock = null
|
|
511
|
+
let prettierInput = content
|
|
512
|
+
if (schemaMatch) {
|
|
513
|
+
const rawJson = schemaMatch[1].trim()
|
|
514
|
+
let formattedJson = rawJson
|
|
515
|
+
try {
|
|
516
|
+
formattedJson = JSON.stringify(JSON.parse(rawJson), null, 2)
|
|
517
|
+
} catch {
|
|
518
|
+
// Schema is not valid JSON — leave it raw; validateSchema reports it.
|
|
519
|
+
}
|
|
520
|
+
formattedSchemaBlock = `{% schema %}\n${formattedJson}\n{% endschema %}`
|
|
521
|
+
prettierInput = content.replace(schemaMatch[0], SCHEMA_PLACEHOLDER)
|
|
522
|
+
}
|
|
523
|
+
try {
|
|
524
|
+
const out = await prettier.format(prettierInput, {
|
|
525
|
+
parser: 'html',
|
|
526
|
+
printWidth: 100,
|
|
527
|
+
tabWidth: 2,
|
|
528
|
+
htmlWhitespaceSensitivity: 'css',
|
|
529
|
+
})
|
|
530
|
+
if (formattedSchemaBlock) {
|
|
531
|
+
if (!out.includes(SCHEMA_PLACEHOLDER)) {
|
|
532
|
+
// Prettier dropped or mangled the placeholder — bail out unformatted
|
|
533
|
+
// rather than silently lose the schema block.
|
|
534
|
+
log(verbose, 'prettier dropped schema placeholder — writing unformatted')
|
|
535
|
+
return { content, formatted: false, reason: 'prettier-placeholder-lost' }
|
|
536
|
+
}
|
|
537
|
+
return {
|
|
538
|
+
content: out.replace(SCHEMA_PLACEHOLDER, formattedSchemaBlock),
|
|
539
|
+
formatted: true,
|
|
540
|
+
reason: null,
|
|
541
|
+
}
|
|
542
|
+
}
|
|
543
|
+
return { content: out, formatted: true, reason: null }
|
|
544
|
+
} catch (err) {
|
|
545
|
+
log(verbose, `prettier failed: ${err.message} — writing unformatted`)
|
|
546
|
+
return { content, formatted: false, reason: `prettier-error: ${err.message}` }
|
|
547
|
+
}
|
|
548
|
+
}
|
|
549
|
+
|
|
550
|
+
// --- entry --------------------------------------------------------------------
|
|
551
|
+
|
|
552
|
+
const argv = process.argv.slice(2)
|
|
553
|
+
|
|
554
|
+
if (argv.length === 0 || argv[0] === '--help' || argv[0] === '-h') {
|
|
555
|
+
process.stdout.write(HELP)
|
|
556
|
+
process.exit(0)
|
|
557
|
+
}
|
|
558
|
+
|
|
559
|
+
const subcommand = argv[0]
|
|
560
|
+
const rest = argv.slice(1)
|
|
561
|
+
|
|
562
|
+
if (subcommand !== 'validate' && subcommand !== 'write') {
|
|
563
|
+
fail(`unknown subcommand '${subcommand}' — use 'validate' or 'write'`)
|
|
564
|
+
}
|
|
565
|
+
|
|
566
|
+
const { values } = parseArgs({
|
|
567
|
+
args: rest,
|
|
568
|
+
options: {
|
|
569
|
+
'liquid-file': { type: 'string' },
|
|
570
|
+
'output-path': { type: 'string' },
|
|
571
|
+
verbose: { type: 'boolean', default: false },
|
|
572
|
+
help: { type: 'boolean', default: false },
|
|
573
|
+
},
|
|
574
|
+
strict: false,
|
|
575
|
+
})
|
|
576
|
+
|
|
577
|
+
if (values.help) {
|
|
578
|
+
process.stdout.write(HELP)
|
|
579
|
+
process.exit(0)
|
|
580
|
+
}
|
|
581
|
+
|
|
582
|
+
async function main() {
|
|
583
|
+
if (subcommand === 'validate') {
|
|
584
|
+
if (!values['liquid-file']) fail('validate: missing --liquid-file')
|
|
585
|
+
const path = resolve(values['liquid-file'])
|
|
586
|
+
let liquid
|
|
587
|
+
try {
|
|
588
|
+
liquid = await readFile(path, 'utf8')
|
|
589
|
+
} catch (err) {
|
|
590
|
+
fail(`validate: cannot read ${path}: ${err.message}`, 1)
|
|
591
|
+
}
|
|
592
|
+
log(values.verbose, `validating ${path} (${liquid.length} bytes)`)
|
|
593
|
+
const report = await validateLiquid(liquid, values.verbose)
|
|
594
|
+
process.stdout.write(`${JSON.stringify(report, null, 2)}\n`)
|
|
595
|
+
process.exit(report.passed ? 0 : 3)
|
|
596
|
+
}
|
|
597
|
+
|
|
598
|
+
if (subcommand === 'write') {
|
|
599
|
+
if (!values['output-path']) fail('write: missing --output-path')
|
|
600
|
+
const out = resolve(values['output-path'])
|
|
601
|
+
let liquid
|
|
602
|
+
try {
|
|
603
|
+
liquid = await readStdin()
|
|
604
|
+
} catch (err) {
|
|
605
|
+
fail(`write: failed to read stdin: ${err.message}`, 1)
|
|
606
|
+
}
|
|
607
|
+
if (!liquid.trim()) fail('write: stdin was empty', 2)
|
|
608
|
+
log(values.verbose, `read ${liquid.length} bytes from stdin`)
|
|
609
|
+
// Schema validation runs in write too — surface as part of metadata so
|
|
610
|
+
// callers know whether the persisted file has a parseable schema.
|
|
611
|
+
const schemaResult = validateSchema(liquid)
|
|
612
|
+
const validatedSchema = schemaResult.schema !== null && schemaResult.errors.length === 0
|
|
613
|
+
const fmt = await tryPrettierFormat(liquid, values.verbose)
|
|
614
|
+
try {
|
|
615
|
+
await mkdir(dirname(out), { recursive: true })
|
|
616
|
+
await writeFile(out, fmt.content, 'utf8')
|
|
617
|
+
} catch (err) {
|
|
618
|
+
fail(`write: failed to write ${out}: ${err.message}`, 1)
|
|
619
|
+
}
|
|
620
|
+
const payload = JSON.stringify(
|
|
621
|
+
{
|
|
622
|
+
path: out,
|
|
623
|
+
bytes: Buffer.byteLength(fmt.content, 'utf8'),
|
|
624
|
+
formatted: fmt.formatted,
|
|
625
|
+
formatterSkippedReason: fmt.reason,
|
|
626
|
+
validatedSchema,
|
|
627
|
+
schemaErrors: schemaResult.errors,
|
|
628
|
+
},
|
|
629
|
+
null,
|
|
630
|
+
2,
|
|
631
|
+
)
|
|
632
|
+
process.stdout.write(`${payload}\n`)
|
|
633
|
+
process.exit(0)
|
|
634
|
+
}
|
|
635
|
+
}
|
|
636
|
+
|
|
637
|
+
main().catch((err) => fail(`unhandled: ${err.stack || err.message}`, 1))
|