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,541 @@
|
|
|
1
|
+
// design-quality.mjs — 12 pure-function checks for accessibility, performance,
|
|
2
|
+
// and web-standards baselines. These are not opinions — they are baselines
|
|
3
|
+
// every production fragment must clear. Inputs are the same `ctx` shape as
|
|
4
|
+
// anti-patterns.mjs: { html, css, $, preset, styleBlocks }.
|
|
5
|
+
//
|
|
6
|
+
// Mechanical vs semantic split (pattern #11): every check here detects a
|
|
7
|
+
// *structural* problem that regex/AST can find unambiguously. Things that
|
|
8
|
+
// require semantic judgment ("is this alt text meaningful?", "is this the
|
|
9
|
+
// right ARIA pattern?") are explicitly OUT — those are the LLM's job once
|
|
10
|
+
// the violations land in the orchestrator. The script flags presence/absence,
|
|
11
|
+
// not quality.
|
|
12
|
+
|
|
13
|
+
const SEVERITY_ORDER = { high: 0, medium: 1, low: 2 }
|
|
14
|
+
export function sortBySeverity(violations) {
|
|
15
|
+
return [...violations].sort((a, b) => {
|
|
16
|
+
const sa = SEVERITY_ORDER[a.severity] ?? 99
|
|
17
|
+
const sb = SEVERITY_ORDER[b.severity] ?? 99
|
|
18
|
+
return sa - sb
|
|
19
|
+
})
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
// Helpers ────────────────────────────────────────────────────────────────────
|
|
23
|
+
|
|
24
|
+
// Best-effort: a button is "icon-only" when its text content is empty (after
|
|
25
|
+
// trimming and stripping a single character / pictograph) and it has at most
|
|
26
|
+
// one child element (an svg or img). This errs on the side of false positives
|
|
27
|
+
// for the LLM to filter — better to flag a labeled button than miss a bare one.
|
|
28
|
+
function isIconOnlyButton($, $btn) {
|
|
29
|
+
const text = $btn.text().replace(/\s+/g, '').trim()
|
|
30
|
+
if (text.length > 1) return false
|
|
31
|
+
// If the only "text" is a single emoji/pictograph (e.g. ☰, ×) treat as icon
|
|
32
|
+
const children = $btn.children()
|
|
33
|
+
const hasGlyph = children.length === 0 && text.length === 1
|
|
34
|
+
if (hasGlyph) return true
|
|
35
|
+
if (children.length === 1) {
|
|
36
|
+
const tag = (children[0].tagName || '').toLowerCase()
|
|
37
|
+
if (tag === 'svg' || tag === 'img' || tag === 'i' || tag === 'span') return true
|
|
38
|
+
}
|
|
39
|
+
if (children.length === 0 && text.length === 0) return true
|
|
40
|
+
return false
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
// Identifies "the hero image" for preload/lazy/fetchpriority checks.
|
|
44
|
+
// Heuristic, in order: explicit fetchpriority="high"; ancestor class containing
|
|
45
|
+
// "hero"; first <img> in document order. Returns the cheerio element or null.
|
|
46
|
+
function findHeroImg($) {
|
|
47
|
+
const explicit = $('img[fetchpriority="high"]').first()
|
|
48
|
+
if (explicit.length) return explicit
|
|
49
|
+
const inHero = $('img').filter((_, el) => {
|
|
50
|
+
const $el = $(el)
|
|
51
|
+
let cur = $el
|
|
52
|
+
for (let i = 0; i < 8 && cur.length; i++) {
|
|
53
|
+
const cls = String(cur.attr('class') || '').toLowerCase()
|
|
54
|
+
if (/(^|[\s_-])hero([\s_-]|$)/.test(cls)) return true
|
|
55
|
+
cur = cur.parent()
|
|
56
|
+
}
|
|
57
|
+
return false
|
|
58
|
+
}).first()
|
|
59
|
+
if (inHero.length) return inHero
|
|
60
|
+
const first = $('img').first()
|
|
61
|
+
return first.length ? first : null
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
// Pattern #25 of the plan. The plain `findHeroImg` returns the first <img> as a
|
|
65
|
+
// last-resort guess — but in builds where the hero is a CSS background-image,
|
|
66
|
+
// the first <img> is the LOGO, not the hero. Flagging it as the hero produces
|
|
67
|
+
// false positives (the logo doesn't need preload — the real hero does, and the
|
|
68
|
+
// real hero is not an <img>). This variant returns a tiered confidence so
|
|
69
|
+
// callers can choose to skip the lowest tier ("fallback") when a CSS-bg path
|
|
70
|
+
// has already been considered.
|
|
71
|
+
const HERO_IMG_CONFIDENCE = ['explicit', 'hero-ancestor', 'name-match', 'wide', 'fallback']
|
|
72
|
+
export function findHeroImgWithConfidence($) {
|
|
73
|
+
const explicit = $('img[fetchpriority="high"]').first()
|
|
74
|
+
if (explicit.length) return { el: explicit, confidence: 'explicit' }
|
|
75
|
+
let inHero = null
|
|
76
|
+
$('img').each((_, el) => {
|
|
77
|
+
if (inHero) return
|
|
78
|
+
const $el = $(el)
|
|
79
|
+
let cur = $el
|
|
80
|
+
for (let i = 0; i < 8 && cur.length; i++) {
|
|
81
|
+
const cls = String(cur.attr('class') || '').toLowerCase()
|
|
82
|
+
if (/(^|[\s_-])hero([\s_-]|$)/.test(cls)) {
|
|
83
|
+
inHero = $el
|
|
84
|
+
return
|
|
85
|
+
}
|
|
86
|
+
cur = cur.parent()
|
|
87
|
+
}
|
|
88
|
+
})
|
|
89
|
+
if (inHero) return { el: inHero, confidence: 'hero-ancestor' }
|
|
90
|
+
let nameMatch = null
|
|
91
|
+
$('img').each((_, el) => {
|
|
92
|
+
if (nameMatch) return
|
|
93
|
+
const $el = $(el)
|
|
94
|
+
const src = String($el.attr('src') || $el.attr('data-src') || '').toLowerCase()
|
|
95
|
+
const alt = String($el.attr('alt') || '').toLowerCase()
|
|
96
|
+
if (/\b(hero|banner|cover)\b/.test(src) || /\b(hero|banner|cover)\b/.test(alt)) {
|
|
97
|
+
nameMatch = $el
|
|
98
|
+
}
|
|
99
|
+
})
|
|
100
|
+
if (nameMatch) return { el: nameMatch, confidence: 'name-match' }
|
|
101
|
+
let wideImg = null
|
|
102
|
+
$('img').each((_, el) => {
|
|
103
|
+
if (wideImg) return
|
|
104
|
+
const w = getImgWidth($(el))
|
|
105
|
+
if (Number.isFinite(w) && w >= 800) wideImg = $(el)
|
|
106
|
+
})
|
|
107
|
+
if (wideImg) return { el: wideImg, confidence: 'wide' }
|
|
108
|
+
const first = $('img').first()
|
|
109
|
+
if (first.length) return { el: first, confidence: 'fallback' }
|
|
110
|
+
return null
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
// Find the first CSS rule whose selector is a single root-scope class (no `__`
|
|
114
|
+
// BEM-modifier, no descendant combinator) and whose body declares a
|
|
115
|
+
// `background-image: url(...)`. The build skills (sb-build-wp,
|
|
116
|
+
// sb-build-shopify) emit the section's root scope as a single class — so a
|
|
117
|
+
// `background-image` declaration on that class is canonical hero-CSS-bg
|
|
118
|
+
// territory. Returns `{ selector, url }` or null.
|
|
119
|
+
export function findHeroCssBackgroundImage(css) {
|
|
120
|
+
if (!css || typeof css !== 'string') return null
|
|
121
|
+
// Strip CSS comments to keep the regex below simple.
|
|
122
|
+
const stripped = css.replace(/\/\*[\s\S]*?\*\//g, '')
|
|
123
|
+
// Walk top-level rule blocks. This keeps things readable; nested at-rules
|
|
124
|
+
// like @media{} won't match the simple `selector { ... }` form because the
|
|
125
|
+
// outer rule has nested braces — handled by skipping any "selector" that
|
|
126
|
+
// contains a `{`.
|
|
127
|
+
const RULE_RE = /([^{}]+?)\{([^{}]*?)\}/g
|
|
128
|
+
let m
|
|
129
|
+
while ((m = RULE_RE.exec(stripped)) !== null) {
|
|
130
|
+
const selectorRaw = m[1].trim()
|
|
131
|
+
const body = m[2]
|
|
132
|
+
if (!selectorRaw) continue
|
|
133
|
+
// Skip at-rules and comma-joined selectors with nested @media.
|
|
134
|
+
if (selectorRaw.startsWith('@')) continue
|
|
135
|
+
// For comma-separated selectors, accept if the FIRST selector is a root
|
|
136
|
+
// scope class — same heuristic as the build skills emit.
|
|
137
|
+
const firstSelector = selectorRaw.split(',')[0].trim()
|
|
138
|
+
// Single-class root scope: `.foo` or `.foo:pseudo`. Reject:
|
|
139
|
+
// - descendant/child combinators (`.foo .bar`, `.foo > .bar`)
|
|
140
|
+
// - BEM modifier (`__`)
|
|
141
|
+
// - id selectors, type selectors, attribute selectors
|
|
142
|
+
if (!/^\.[A-Za-z][\w-]*(?::[A-Za-z-]+(?:\([^)]*\))?)?$/.test(firstSelector)) continue
|
|
143
|
+
if (firstSelector.includes('__')) continue
|
|
144
|
+
const bgMatch = body.match(/background(?:-image)?\s*:\s*[^;]*?url\(\s*['"]?([^'")]+?)['"]?\s*\)/i)
|
|
145
|
+
if (!bgMatch) continue
|
|
146
|
+
return { selector: firstSelector, url: bgMatch[1] }
|
|
147
|
+
}
|
|
148
|
+
return null
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
function getImgWidth($img) {
|
|
152
|
+
const explicit = parseInt(String($img.attr('width') || ''), 10)
|
|
153
|
+
if (Number.isFinite(explicit) && explicit > 0) return explicit
|
|
154
|
+
const style = String($img.attr('style') || '')
|
|
155
|
+
const m = style.match(/(?:^|;|\s)width\s*:\s*(\d+)\s*px/i)
|
|
156
|
+
if (m) return parseInt(m[1], 10)
|
|
157
|
+
return null
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
function collectPreloadHrefs($) {
|
|
161
|
+
return $('link[rel="preload"][as="image"]')
|
|
162
|
+
.map((_, el) => String($(el).attr('href') || ''))
|
|
163
|
+
.get()
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
// Checks ─────────────────────────────────────────────────────────────────────
|
|
167
|
+
|
|
168
|
+
// a11y-button-aria — icon-only buttons need an aria-label
|
|
169
|
+
export function checkButtonAria(ctx) {
|
|
170
|
+
const { $ } = ctx
|
|
171
|
+
const violations = []
|
|
172
|
+
$('button').each((_, el) => {
|
|
173
|
+
const $el = $(el)
|
|
174
|
+
if (!isIconOnlyButton($, $el)) return
|
|
175
|
+
if ($el.attr('aria-label')) return
|
|
176
|
+
if ($el.attr('aria-labelledby')) return
|
|
177
|
+
// <img alt="..."> child satisfies a11y too
|
|
178
|
+
const innerImgAlt = $el.find('img[alt]').first().attr('alt')
|
|
179
|
+
if (innerImgAlt && innerImgAlt.trim().length > 0) return
|
|
180
|
+
const cls = $el.attr('class') || ''
|
|
181
|
+
const hint = inferActionHint($el)
|
|
182
|
+
violations.push({
|
|
183
|
+
group: 'a11y',
|
|
184
|
+
id: 'a11y-button-aria',
|
|
185
|
+
location: cls ? `<button class="${cls}">` : '<button> (icon-only)',
|
|
186
|
+
issue: 'icon-only <button> without aria-label — screen readers announce nothing',
|
|
187
|
+
candidateFix: `add \`aria-label="${hint}"\` to the <button>`,
|
|
188
|
+
severity: 'high',
|
|
189
|
+
})
|
|
190
|
+
})
|
|
191
|
+
return violations
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
function inferActionHint($btn) {
|
|
195
|
+
const cls = String($btn.attr('class') || '').toLowerCase()
|
|
196
|
+
if (/(close|dismiss|×)/.test(cls)) return 'Close'
|
|
197
|
+
if (/(open|menu|hamburger|drawer)/.test(cls)) return 'Open menu'
|
|
198
|
+
if (/(prev|previous|back)/.test(cls)) return 'Previous'
|
|
199
|
+
if (/(next|forward)/.test(cls)) return 'Next'
|
|
200
|
+
if (/(search|find)/.test(cls)) return 'Search'
|
|
201
|
+
if (/(cart|bag)/.test(cls)) return 'Cart'
|
|
202
|
+
return 'Button action'
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
// a11y-img-alt — every <img> must have an alt attribute (empty allowed)
|
|
206
|
+
export function checkImgAlt(ctx) {
|
|
207
|
+
const { $ } = ctx
|
|
208
|
+
const violations = []
|
|
209
|
+
$('img').each((_, el) => {
|
|
210
|
+
const $el = $(el)
|
|
211
|
+
if ($el.attr('alt') !== undefined) return
|
|
212
|
+
const src = $el.attr('src') || $el.attr('data-src') || ''
|
|
213
|
+
violations.push({
|
|
214
|
+
group: 'a11y',
|
|
215
|
+
id: 'a11y-img-alt',
|
|
216
|
+
location: src ? `<img src="${truncate(src, 60)}">` : '<img>',
|
|
217
|
+
issue: '<img> missing `alt` attribute — assistive tech reads the filename or skips the image',
|
|
218
|
+
candidateFix: `add \`alt=""\` (decorative) or \`alt="<short description>"\` to the <img src="${truncate(src, 60)}">`,
|
|
219
|
+
severity: 'high',
|
|
220
|
+
})
|
|
221
|
+
})
|
|
222
|
+
return violations
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
function truncate(s, n) {
|
|
226
|
+
return s.length > n ? s.slice(0, n - 1) + '…' : s
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
// a11y-heading-hierarchy — H1 absent OR multiple OR skips a level
|
|
230
|
+
export function checkHeadingHierarchy(ctx) {
|
|
231
|
+
const { $ } = ctx
|
|
232
|
+
const violations = []
|
|
233
|
+
const levels = []
|
|
234
|
+
$('h1, h2, h3, h4, h5, h6').each((_, el) => {
|
|
235
|
+
const tag = (el.tagName || '').toLowerCase()
|
|
236
|
+
levels.push({ tag, n: parseInt(tag.slice(1), 10) })
|
|
237
|
+
})
|
|
238
|
+
if (levels.length === 0) return violations
|
|
239
|
+
const h1Count = levels.filter((l) => l.n === 1).length
|
|
240
|
+
if (h1Count === 0) {
|
|
241
|
+
// Sections without an H1 can be valid IF this is a sub-fragment — but the
|
|
242
|
+
// first heading shouldn't deeper than H2 (otherwise the doc starts at H3+).
|
|
243
|
+
if (levels[0].n > 2) {
|
|
244
|
+
violations.push({
|
|
245
|
+
group: 'a11y',
|
|
246
|
+
id: 'a11y-heading-hierarchy',
|
|
247
|
+
location: `first heading is <${levels[0].tag}>`,
|
|
248
|
+
issue: `document starts at <${levels[0].tag}> with no H1/H2 above — heading outline is broken`,
|
|
249
|
+
candidateFix: `change the first <${levels[0].tag}> to <h2> (or add an <h1> ancestor in the page chrome — the orchestrator will know which)`,
|
|
250
|
+
severity: 'medium',
|
|
251
|
+
})
|
|
252
|
+
}
|
|
253
|
+
} else if (h1Count > 1) {
|
|
254
|
+
violations.push({
|
|
255
|
+
group: 'a11y',
|
|
256
|
+
id: 'a11y-heading-hierarchy',
|
|
257
|
+
location: `${h1Count} <h1> elements`,
|
|
258
|
+
issue: `multiple <h1> elements (${h1Count}) — page should have exactly one`,
|
|
259
|
+
candidateFix: `keep the most prominent <h1> and demote the others to <h2>`,
|
|
260
|
+
severity: 'medium',
|
|
261
|
+
})
|
|
262
|
+
}
|
|
263
|
+
// Detect skips (e.g. H2 → H4)
|
|
264
|
+
for (let i = 1; i < levels.length; i++) {
|
|
265
|
+
const prev = levels[i - 1]
|
|
266
|
+
const cur = levels[i]
|
|
267
|
+
if (cur.n > prev.n + 1) {
|
|
268
|
+
violations.push({
|
|
269
|
+
group: 'a11y',
|
|
270
|
+
id: 'a11y-heading-hierarchy',
|
|
271
|
+
location: `<${prev.tag}> → <${cur.tag}> (heading ${i + 1})`,
|
|
272
|
+
issue: `heading level skip from <${prev.tag}> to <${cur.tag}> — outline is jagged`,
|
|
273
|
+
candidateFix: `change the <${cur.tag}> after the <${prev.tag}> to <h${prev.n + 1}>`,
|
|
274
|
+
severity: 'low',
|
|
275
|
+
})
|
|
276
|
+
}
|
|
277
|
+
}
|
|
278
|
+
return violations
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
// a11y-button-type — <button> without type="button" submits parent forms
|
|
282
|
+
export function checkButtonType(ctx) {
|
|
283
|
+
const { $ } = ctx
|
|
284
|
+
const violations = []
|
|
285
|
+
$('button').each((_, el) => {
|
|
286
|
+
const $el = $(el)
|
|
287
|
+
const type = ($el.attr('type') || '').toLowerCase()
|
|
288
|
+
if (type === 'button' || type === 'submit' || type === 'reset') return
|
|
289
|
+
const cls = $el.attr('class') || ''
|
|
290
|
+
violations.push({
|
|
291
|
+
group: 'a11y',
|
|
292
|
+
id: 'a11y-button-type',
|
|
293
|
+
location: cls ? `<button class="${cls}">` : '<button>',
|
|
294
|
+
issue: '<button> without `type="button"` — defaults to type="submit" inside a form, can submit accidentally',
|
|
295
|
+
candidateFix: `add \`type="button"\` to the <button${cls ? ` class="${cls}"` : ''}>`,
|
|
296
|
+
severity: 'medium',
|
|
297
|
+
})
|
|
298
|
+
})
|
|
299
|
+
return violations
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
// a11y-nav-semantic — main navigation should be <nav>, not <div>
|
|
303
|
+
// Heuristic: an element with a class containing "nav" / "menu" / "header" that
|
|
304
|
+
// has an <ul> or multiple <a> children, and is itself a <div> (not <nav>),
|
|
305
|
+
// AND there is no <nav> ancestor wrapping it.
|
|
306
|
+
export function checkNavSemantic(ctx) {
|
|
307
|
+
const { $ } = ctx
|
|
308
|
+
const violations = []
|
|
309
|
+
$('div').each((_, el) => {
|
|
310
|
+
const $el = $(el)
|
|
311
|
+
const cls = String($el.attr('class') || '').toLowerCase()
|
|
312
|
+
if (!/(nav|menu|navigation)/.test(cls)) return
|
|
313
|
+
if (/(footer|sub|breadcrumb)/.test(cls)) return // skip secondary nav
|
|
314
|
+
if ($el.closest('nav').length) return
|
|
315
|
+
const links = $el.find('a').length
|
|
316
|
+
if (links < 2) return
|
|
317
|
+
violations.push({
|
|
318
|
+
group: 'a11y',
|
|
319
|
+
id: 'a11y-nav-semantic',
|
|
320
|
+
location: `<div class="${cls}"> with ${links} links`,
|
|
321
|
+
issue: 'navigation rendered as <div> — landmark roles missed by assistive tech',
|
|
322
|
+
candidateFix: `change \`<div class="${cls}">\` to \`<nav class="${cls}" aria-label="Main">\` (close the matching tag too)`,
|
|
323
|
+
severity: 'medium',
|
|
324
|
+
})
|
|
325
|
+
})
|
|
326
|
+
return violations
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
// perf-img-lazy — every non-hero <img> needs loading="lazy"
|
|
330
|
+
export function checkImgLazy(ctx) {
|
|
331
|
+
const { $ } = ctx
|
|
332
|
+
const violations = []
|
|
333
|
+
const hero = findHeroImg($)
|
|
334
|
+
$('img').each((_, el) => {
|
|
335
|
+
const $el = $(el)
|
|
336
|
+
if (hero && $el.is(hero)) return
|
|
337
|
+
const loading = String($el.attr('loading') || '').toLowerCase()
|
|
338
|
+
if (loading === 'lazy') return
|
|
339
|
+
if (loading === 'eager') return // explicit eager is intentional (LCP fallback)
|
|
340
|
+
const src = $el.attr('src') || $el.attr('data-src') || ''
|
|
341
|
+
violations.push({
|
|
342
|
+
group: 'performance',
|
|
343
|
+
id: 'perf-img-lazy',
|
|
344
|
+
location: src ? `<img src="${truncate(src, 60)}">` : '<img>',
|
|
345
|
+
issue: 'non-hero <img> without `loading="lazy"` — eager load wastes bandwidth on below-fold images',
|
|
346
|
+
candidateFix: `add \`loading="lazy"\` to the <img src="${truncate(src, 60)}">`,
|
|
347
|
+
severity: 'medium',
|
|
348
|
+
})
|
|
349
|
+
})
|
|
350
|
+
return violations
|
|
351
|
+
}
|
|
352
|
+
|
|
353
|
+
// perf-hero-preload — hero needs `<link rel="preload" as="image">`.
|
|
354
|
+
// Two-step detection (Pattern #25 of the plan):
|
|
355
|
+
// Step 1: search the scope CSS for a root-class rule with
|
|
356
|
+
// `background-image: url(...)`. If found, that IS the hero — verify
|
|
357
|
+
// a matching preload exists; flag against the bg URL otherwise.
|
|
358
|
+
// Step 2: only when no CSS-bg hero was found, fall back to <img> heroes.
|
|
359
|
+
// Skip the "first <img>" fallback tier — that's almost always the
|
|
360
|
+
// logo on builds where the hero uses CSS background-image.
|
|
361
|
+
// Step 3: if neither tier finds a hero, skip the check (no false positive).
|
|
362
|
+
export function checkHeroPreload(ctx) {
|
|
363
|
+
const { $ } = ctx
|
|
364
|
+
const violations = []
|
|
365
|
+
const preloads = collectPreloadHrefs($)
|
|
366
|
+
|
|
367
|
+
// Step 1: CSS background-image hero.
|
|
368
|
+
const cssHero = findHeroCssBackgroundImage(ctx.css || '')
|
|
369
|
+
if (cssHero) {
|
|
370
|
+
if (preloads.some((href) => href === cssHero.url)) return violations
|
|
371
|
+
violations.push({
|
|
372
|
+
group: 'performance',
|
|
373
|
+
id: 'perf-hero-preload',
|
|
374
|
+
location: `CSS \`background-image\` on \`${cssHero.selector}\``,
|
|
375
|
+
issue: 'hero CSS background-image without `<link rel="preload">` — LCP starts the network round-trip late',
|
|
376
|
+
candidateFix: `add \`<link rel="preload" as="image" href="${cssHero.url}">\` at the top of the fragment`,
|
|
377
|
+
severity: 'medium',
|
|
378
|
+
})
|
|
379
|
+
return violations
|
|
380
|
+
}
|
|
381
|
+
|
|
382
|
+
// Step 2: <img> hero. Skip the lowest "first <img>" tier — the logo case.
|
|
383
|
+
const heroPick = findHeroImgWithConfidence($)
|
|
384
|
+
if (!heroPick || heroPick.confidence === 'fallback') return violations
|
|
385
|
+
const heroSrc = heroPick.el.attr('src') || heroPick.el.attr('data-src') || ''
|
|
386
|
+
if (!heroSrc) return violations
|
|
387
|
+
if (preloads.some((href) => href === heroSrc)) return violations
|
|
388
|
+
violations.push({
|
|
389
|
+
group: 'performance',
|
|
390
|
+
id: 'perf-hero-preload',
|
|
391
|
+
location: `hero <img src="${truncate(heroSrc, 60)}">`,
|
|
392
|
+
issue: 'hero image without `<link rel="preload">` — LCP starts the network round-trip late',
|
|
393
|
+
candidateFix: `add \`<link rel="preload" as="image" href="${heroSrc}">\` at the top of the fragment`,
|
|
394
|
+
severity: 'medium',
|
|
395
|
+
})
|
|
396
|
+
return violations
|
|
397
|
+
}
|
|
398
|
+
|
|
399
|
+
// perf-font-display — Google Fonts URL must include &display=swap
|
|
400
|
+
export function checkFontDisplay(ctx) {
|
|
401
|
+
const { $ } = ctx
|
|
402
|
+
const violations = []
|
|
403
|
+
$('link[rel="stylesheet"][href*="fonts.googleapis.com"]').each((_, el) => {
|
|
404
|
+
const href = String($(el).attr('href') || '')
|
|
405
|
+
if (/[?&]display=swap\b/i.test(href)) return
|
|
406
|
+
violations.push({
|
|
407
|
+
group: 'performance',
|
|
408
|
+
id: 'perf-font-display',
|
|
409
|
+
location: `<link href="${truncate(href, 60)}">`,
|
|
410
|
+
issue: 'Google Fonts stylesheet without `&display=swap` — invisible text during font load (FOIT)',
|
|
411
|
+
candidateFix: `append \`&display=swap\` to the href of <link href="${truncate(href, 60)}">`,
|
|
412
|
+
severity: 'medium',
|
|
413
|
+
})
|
|
414
|
+
})
|
|
415
|
+
return violations
|
|
416
|
+
}
|
|
417
|
+
|
|
418
|
+
// perf-fetchpriority-hero — Shopify-only: hero needs fetchpriority="high"
|
|
419
|
+
export function checkFetchpriorityHero(ctx) {
|
|
420
|
+
if (ctx.preset !== 'shopify-section') return []
|
|
421
|
+
const { $ } = ctx
|
|
422
|
+
const hero = findHeroImg($)
|
|
423
|
+
if (!hero) return []
|
|
424
|
+
const fp = String(hero.attr('fetchpriority') || '').toLowerCase()
|
|
425
|
+
if (fp === 'high') return []
|
|
426
|
+
const heroSrc = hero.attr('src') || hero.attr('data-src') || ''
|
|
427
|
+
return [{
|
|
428
|
+
group: 'performance',
|
|
429
|
+
id: 'perf-fetchpriority-hero',
|
|
430
|
+
location: `hero <img src="${truncate(heroSrc, 60)}">`,
|
|
431
|
+
issue: 'hero <img> without `fetchpriority="high"` — Shopify themes benefit from the hint to prioritize LCP',
|
|
432
|
+
candidateFix: `add \`fetchpriority="high"\` to the hero <img src="${truncate(heroSrc, 60)}">`,
|
|
433
|
+
severity: 'low',
|
|
434
|
+
}]
|
|
435
|
+
}
|
|
436
|
+
|
|
437
|
+
// web-srcset-responsive — large <img> without srcset wastes bandwidth on small viewports
|
|
438
|
+
export function checkSrcsetResponsive(ctx) {
|
|
439
|
+
const { $ } = ctx
|
|
440
|
+
const violations = []
|
|
441
|
+
$('img').each((_, el) => {
|
|
442
|
+
const $el = $(el)
|
|
443
|
+
if ($el.attr('srcset')) return
|
|
444
|
+
const w = getImgWidth($el)
|
|
445
|
+
// Only flag images we know to be wide. Without a width attribute the file
|
|
446
|
+
// *might* still be huge, but flagging every <img> would be noise — let the
|
|
447
|
+
// LLM make a judgment call when explicit width is missing.
|
|
448
|
+
if (w == null || w <= 800) return
|
|
449
|
+
const src = $el.attr('src') || $el.attr('data-src') || ''
|
|
450
|
+
violations.push({
|
|
451
|
+
group: 'web-standards',
|
|
452
|
+
id: 'web-srcset-responsive',
|
|
453
|
+
location: src ? `<img src="${truncate(src, 60)}" width="${w}">` : `<img width="${w}">`,
|
|
454
|
+
issue: `<img> width=${w} without srcset — mobile viewports download the full-size file`,
|
|
455
|
+
candidateFix: `add \`srcset="${src} 1x, ${src} 2x"\` and \`sizes="(min-width: 1000px) 50vw, 100vw"\` to the <img> (the orchestrator can fill the actual srcset values from the inspection)`,
|
|
456
|
+
severity: 'low',
|
|
457
|
+
})
|
|
458
|
+
})
|
|
459
|
+
return violations
|
|
460
|
+
}
|
|
461
|
+
|
|
462
|
+
// web-modal-dialog — modals should be <dialog>, not <div>
|
|
463
|
+
export function checkModalDialog(ctx) {
|
|
464
|
+
const { $ } = ctx
|
|
465
|
+
const violations = []
|
|
466
|
+
$('div').each((_, el) => {
|
|
467
|
+
const $el = $(el)
|
|
468
|
+
const cls = String($el.attr('class') || '').toLowerCase()
|
|
469
|
+
const role = String($el.attr('role') || '').toLowerCase()
|
|
470
|
+
const isModalCandidate = role === 'dialog' || /(^|[\s_-])modal([\s_-]|$)/.test(cls)
|
|
471
|
+
if (!isModalCandidate) return
|
|
472
|
+
if ($el.closest('dialog').length) return
|
|
473
|
+
violations.push({
|
|
474
|
+
group: 'web-standards',
|
|
475
|
+
id: 'web-modal-dialog',
|
|
476
|
+
location: `<div class="${cls}"${role ? ` role="${role}"` : ''}>`,
|
|
477
|
+
issue: 'modal as <div> instead of <dialog> — loses native focus trap, ESC handling, backdrop',
|
|
478
|
+
candidateFix: `change \`<div class="${cls}"${role ? ` role="${role}"` : ''}>\` to \`<dialog class="${cls}">\` and open with \`dialog.showModal()\`, close with \`dialog.close()\``,
|
|
479
|
+
severity: 'medium',
|
|
480
|
+
})
|
|
481
|
+
})
|
|
482
|
+
return violations
|
|
483
|
+
}
|
|
484
|
+
|
|
485
|
+
// web-preconnect-fonts — Google Fonts stylesheet without a preceding preconnect
|
|
486
|
+
export function checkPreconnectFonts(ctx) {
|
|
487
|
+
const { $ } = ctx
|
|
488
|
+
const violations = []
|
|
489
|
+
const stylesheets = $('link[rel="stylesheet"][href*="fonts.googleapis.com"]')
|
|
490
|
+
if (stylesheets.length === 0) return violations
|
|
491
|
+
const preconnects = $('link[rel="preconnect"]').map((_, el) => String($(el).attr('href') || '')).get()
|
|
492
|
+
const hasGoogleApis = preconnects.some((h) => /fonts\.googleapis\.com/.test(h))
|
|
493
|
+
const hasGstatic = preconnects.some((h) => /fonts\.gstatic\.com/.test(h))
|
|
494
|
+
if (hasGoogleApis && hasGstatic) return violations
|
|
495
|
+
const missing = []
|
|
496
|
+
if (!hasGoogleApis) missing.push('https://fonts.googleapis.com')
|
|
497
|
+
if (!hasGstatic) missing.push('https://fonts.gstatic.com')
|
|
498
|
+
violations.push({
|
|
499
|
+
group: 'performance',
|
|
500
|
+
id: 'web-preconnect-fonts',
|
|
501
|
+
location: 'fragment <head>-position links',
|
|
502
|
+
issue: `Google Fonts loaded without <link rel="preconnect"> — DNS+TLS handshake delays font request${missing.length > 1 ? 's for ' + missing.join(' and ') : ' for ' + missing[0]}`,
|
|
503
|
+
candidateFix: `add ${missing.map((u) => `\`<link rel="preconnect" href="${u}"${u.includes('gstatic') ? ' crossorigin' : ''}>\``).join(' and ')} before the Google Fonts <link rel="stylesheet">`,
|
|
504
|
+
severity: 'low',
|
|
505
|
+
})
|
|
506
|
+
return violations
|
|
507
|
+
}
|
|
508
|
+
|
|
509
|
+
export const DESIGN_QUALITY_CHECKS = [
|
|
510
|
+
checkButtonAria,
|
|
511
|
+
checkImgAlt,
|
|
512
|
+
checkHeadingHierarchy,
|
|
513
|
+
checkButtonType,
|
|
514
|
+
checkNavSemantic,
|
|
515
|
+
checkImgLazy,
|
|
516
|
+
checkHeroPreload,
|
|
517
|
+
checkFontDisplay,
|
|
518
|
+
checkFetchpriorityHero,
|
|
519
|
+
checkSrcsetResponsive,
|
|
520
|
+
checkModalDialog,
|
|
521
|
+
checkPreconnectFonts,
|
|
522
|
+
]
|
|
523
|
+
|
|
524
|
+
export function runDesignQuality(ctx) {
|
|
525
|
+
const out = []
|
|
526
|
+
for (const fn of DESIGN_QUALITY_CHECKS) {
|
|
527
|
+
try {
|
|
528
|
+
out.push(...fn(ctx))
|
|
529
|
+
} catch (err) {
|
|
530
|
+
out.push({
|
|
531
|
+
group: 'design-quality',
|
|
532
|
+
id: fn.name || 'unknown',
|
|
533
|
+
location: 'check',
|
|
534
|
+
issue: `check threw: ${err.message}`,
|
|
535
|
+
candidateFix: 'inspect the input file — it may be malformed HTML',
|
|
536
|
+
severity: 'low',
|
|
537
|
+
})
|
|
538
|
+
}
|
|
539
|
+
}
|
|
540
|
+
return sortBySeverity(out)
|
|
541
|
+
}
|