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,250 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// review-checks.mjs — Audit a built fragment (HTML or Liquid) against three
|
|
3
|
+
// independent groups of checks and emit a prioritized list of violations whose
|
|
4
|
+
// `candidateFix` strings are specific enough for sb-build-{wp,shopify} to
|
|
5
|
+
// apply programmatically as `fixHints`. This script is the engine of the
|
|
6
|
+
// auto-correct loop.
|
|
7
|
+
//
|
|
8
|
+
// Determinism only — no chromium, no network. cheerio is lazy-imported so
|
|
9
|
+
// --help and arg validation work without it installed.
|
|
10
|
+
//
|
|
11
|
+
// Outputs JSON to stdout AND writes report.json into --output-dir.
|
|
12
|
+
// Exit codes: 0=ok, 1=script error, 2=invalid args, 3=violations (med/high).
|
|
13
|
+
|
|
14
|
+
import { parseArgs } from 'node:util'
|
|
15
|
+
import { mkdir, writeFile, readFile, access } from 'node:fs/promises'
|
|
16
|
+
import { join, resolve, dirname } from 'node:path'
|
|
17
|
+
import { fileURLToPath } from 'node:url'
|
|
18
|
+
import { runAntiPatterns } from './lib/anti-patterns.mjs'
|
|
19
|
+
import { runDesignQuality } from './lib/design-quality.mjs'
|
|
20
|
+
import { applyCrossReference, sortByPriority } from './lib/cross-reference.mjs'
|
|
21
|
+
|
|
22
|
+
const HERE = dirname(fileURLToPath(import.meta.url))
|
|
23
|
+
const SKILL_ROOT = resolve(HERE, '..')
|
|
24
|
+
|
|
25
|
+
const HELP = `
|
|
26
|
+
review-checks.mjs — Audit an HTML/Liquid fragment for anti-patterns, design
|
|
27
|
+
quality, and visual-diff correlations. Emits a prioritized fix list.
|
|
28
|
+
|
|
29
|
+
Required:
|
|
30
|
+
--file <path> HTML or Liquid file to audit.
|
|
31
|
+
--preset <name> wp-elementor | shopify-section
|
|
32
|
+
--output-dir <dir> Directory for report.json.
|
|
33
|
+
|
|
34
|
+
Optional:
|
|
35
|
+
--compare-diffs <path> JSON from sb-compare-visual (cross-reference layer).
|
|
36
|
+
--memory-dir <dir> Override <plugin>/memory location. Auto-detects
|
|
37
|
+
\`~/.claude/similarbuild-memory\` and the bundled
|
|
38
|
+
references/ fallback otherwise.
|
|
39
|
+
--help Show this message.
|
|
40
|
+
|
|
41
|
+
Exit codes: 0=ok (zero or only-low violations), 1=script error,
|
|
42
|
+
2=invalid args, 3=violations found (medium/high severity).
|
|
43
|
+
`
|
|
44
|
+
|
|
45
|
+
function fail(msg, code = 2) {
|
|
46
|
+
process.stderr.write(`[sb-review-checks] ${msg}\n`)
|
|
47
|
+
process.exit(code)
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
function log(msg) {
|
|
51
|
+
process.stderr.write(`[sb-review-checks] ${msg}\n`)
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
const { values } = parseArgs({
|
|
55
|
+
options: {
|
|
56
|
+
file: { type: 'string' },
|
|
57
|
+
preset: { type: 'string' },
|
|
58
|
+
'output-dir': { type: 'string' },
|
|
59
|
+
'compare-diffs': { type: 'string' },
|
|
60
|
+
'memory-dir': { type: 'string' },
|
|
61
|
+
help: { type: 'boolean', default: false },
|
|
62
|
+
},
|
|
63
|
+
strict: false,
|
|
64
|
+
})
|
|
65
|
+
|
|
66
|
+
if (values.help) {
|
|
67
|
+
process.stdout.write(HELP)
|
|
68
|
+
process.exit(0)
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
if (!values.file) fail('missing --file')
|
|
72
|
+
if (!values.preset) fail('missing --preset')
|
|
73
|
+
if (!values['output-dir']) fail('missing --output-dir')
|
|
74
|
+
|
|
75
|
+
const VALID_PRESETS = new Set(['wp-elementor', 'shopify-section'])
|
|
76
|
+
if (!VALID_PRESETS.has(values.preset)) {
|
|
77
|
+
fail(`--preset must be one of: ${[...VALID_PRESETS].join(', ')} (got "${values.preset}")`)
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
const FILE = resolve(values.file)
|
|
81
|
+
const PRESET = values.preset
|
|
82
|
+
const OUTPUT_DIR = resolve(values['output-dir'])
|
|
83
|
+
const COMPARE_DIFFS = values['compare-diffs'] ? resolve(values['compare-diffs']) : null
|
|
84
|
+
const MEMORY_DIR = values['memory-dir'] ? resolve(values['memory-dir']) : null
|
|
85
|
+
|
|
86
|
+
async function exists(path) {
|
|
87
|
+
try {
|
|
88
|
+
await access(path)
|
|
89
|
+
return true
|
|
90
|
+
} catch {
|
|
91
|
+
return false
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
// Extract every <style>…</style> block. Liquid files often embed them inside
|
|
96
|
+
// `{% style %}` or `{% stylesheet %}` tags too — capture both. Returned objects
|
|
97
|
+
// keep the original substring so line numbers reported by checks point at the
|
|
98
|
+
// CSS-block-relative line, which is more actionable than file-relative.
|
|
99
|
+
function extractStyleBlocks(source) {
|
|
100
|
+
const blocks = []
|
|
101
|
+
const patterns = [
|
|
102
|
+
/<style\b[^>]*>([\s\S]*?)<\/style\s*>/gi,
|
|
103
|
+
/\{%\s*style\s*%\}([\s\S]*?)\{%\s*endstyle\s*%\}/gi,
|
|
104
|
+
/\{%\s*stylesheet\s*%\}([\s\S]*?)\{%\s*endstylesheet\s*%\}/gi,
|
|
105
|
+
]
|
|
106
|
+
for (const re of patterns) {
|
|
107
|
+
let m
|
|
108
|
+
while ((m = re.exec(source)) !== null) {
|
|
109
|
+
blocks.push({ css: m[1], htmlOffset: m.index })
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
return blocks
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
// Liquid → HTML-ish: cheerio parses HTML; Liquid expressions like {{ x }} and
|
|
116
|
+
// {% if %}…{% endif %} are foreign syntax that confuses no-op-tolerant parsers.
|
|
117
|
+
// We strip Liquid expressions to whitespace before parsing so cheerio sees
|
|
118
|
+
// well-formed HTML structure. The CSS path uses the raw source so {% style %}
|
|
119
|
+
// blocks are still picked up by extractStyleBlocks above.
|
|
120
|
+
function liquidToHtmlish(source) {
|
|
121
|
+
return source
|
|
122
|
+
.replace(/\{\{[\s\S]*?\}\}/g, ' ')
|
|
123
|
+
.replace(/\{%[\s\S]*?%\}/g, ' ')
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
async function loadComparisonDiffs(path) {
|
|
127
|
+
if (!path) return null
|
|
128
|
+
if (!(await exists(path))) {
|
|
129
|
+
log(`--compare-diffs not found: ${path} — skipping cross-reference`)
|
|
130
|
+
return null
|
|
131
|
+
}
|
|
132
|
+
try {
|
|
133
|
+
const raw = await readFile(path, 'utf8')
|
|
134
|
+
const json = JSON.parse(raw)
|
|
135
|
+
if (Array.isArray(json)) return json
|
|
136
|
+
if (Array.isArray(json.structuredDiffs)) return json.structuredDiffs
|
|
137
|
+
log(`--compare-diffs JSON has no structuredDiffs[] — skipping cross-reference`)
|
|
138
|
+
return null
|
|
139
|
+
} catch (err) {
|
|
140
|
+
log(`could not parse --compare-diffs: ${err.message} — skipping cross-reference`)
|
|
141
|
+
return null
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
// Resolve which memory directory to consult. Order:
|
|
146
|
+
// 1. --memory-dir flag
|
|
147
|
+
// 2. ~/.claude/similarbuild-memory
|
|
148
|
+
// 3. <skill-root>/references (always exists; bundled fallback)
|
|
149
|
+
async function resolveMemoryDir() {
|
|
150
|
+
if (MEMORY_DIR) return { dir: MEMORY_DIR, source: 'flag' }
|
|
151
|
+
const home = process.env.HOME || process.env.USERPROFILE
|
|
152
|
+
if (home) {
|
|
153
|
+
const candidate = join(home, '.claude', 'similarbuild-memory')
|
|
154
|
+
if (await exists(candidate)) return { dir: candidate, source: 'home' }
|
|
155
|
+
}
|
|
156
|
+
return { dir: join(SKILL_ROOT, 'references'), source: 'bundled' }
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
function counts(violations) {
|
|
160
|
+
const c = { high: 0, medium: 0, low: 0 }
|
|
161
|
+
for (const v of violations) c[v.severity] = (c[v.severity] || 0) + 1
|
|
162
|
+
return c
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
async function main() {
|
|
166
|
+
if (!(await exists(FILE))) fail(`--file not found: ${FILE}`)
|
|
167
|
+
await mkdir(OUTPUT_DIR, { recursive: true })
|
|
168
|
+
|
|
169
|
+
const reportPath = join(OUTPUT_DIR, 'report.json')
|
|
170
|
+
|
|
171
|
+
let cheerio
|
|
172
|
+
try {
|
|
173
|
+
cheerio = await import('cheerio')
|
|
174
|
+
} catch (err) {
|
|
175
|
+
process.stderr.write(
|
|
176
|
+
`[sb-review-checks] missing dependency 'cheerio': ${err?.message || err}\n` +
|
|
177
|
+
`Install with: npm i cheerio\n`,
|
|
178
|
+
)
|
|
179
|
+
process.exit(1)
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
log(`reading ${FILE} (preset=${PRESET})`)
|
|
183
|
+
const source = await readFile(FILE, 'utf8')
|
|
184
|
+
const looksLikeLiquid = /(\{%|\{\{)/.test(source)
|
|
185
|
+
const htmlForParse = looksLikeLiquid ? liquidToHtmlish(source) : source
|
|
186
|
+
|
|
187
|
+
const $ = cheerio.load(htmlForParse, { decodeEntities: false })
|
|
188
|
+
const styleBlocks = extractStyleBlocks(source)
|
|
189
|
+
const css = styleBlocks.map((b) => b.css).join('\n')
|
|
190
|
+
|
|
191
|
+
log(`parsed: ${$('*').length} elements, ${styleBlocks.length} style blocks, ${css.length} chars CSS`)
|
|
192
|
+
|
|
193
|
+
const memoryInfo = await resolveMemoryDir()
|
|
194
|
+
log(`memory dir: ${memoryInfo.dir} (${memoryInfo.source})`)
|
|
195
|
+
|
|
196
|
+
const ctx = {
|
|
197
|
+
html: source,
|
|
198
|
+
css,
|
|
199
|
+
$,
|
|
200
|
+
preset: PRESET,
|
|
201
|
+
styleBlocks,
|
|
202
|
+
memoryDir: memoryInfo.dir,
|
|
203
|
+
memorySource: memoryInfo.source,
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
const antiPatternViolations = runAntiPatterns(ctx)
|
|
207
|
+
const designQualityViolations = runDesignQuality(ctx)
|
|
208
|
+
log(`group 1 (anti-patterns): ${antiPatternViolations.length}`)
|
|
209
|
+
log(`group 2 (design quality): ${designQualityViolations.length}`)
|
|
210
|
+
|
|
211
|
+
const compareDiffs = await loadComparisonDiffs(COMPARE_DIFFS)
|
|
212
|
+
let allViolations = [...antiPatternViolations, ...designQualityViolations]
|
|
213
|
+
if (compareDiffs) {
|
|
214
|
+
allViolations = applyCrossReference(allViolations, compareDiffs)
|
|
215
|
+
log(`group 3 (cross-reference): ${compareDiffs.length} diff(s) considered`)
|
|
216
|
+
}
|
|
217
|
+
allViolations = sortByPriority(allViolations)
|
|
218
|
+
|
|
219
|
+
const c = counts(allViolations)
|
|
220
|
+
const passed = c.high === 0 && c.medium === 0
|
|
221
|
+
const result = {
|
|
222
|
+
passed,
|
|
223
|
+
file: FILE,
|
|
224
|
+
preset: PRESET,
|
|
225
|
+
violationCount: c,
|
|
226
|
+
violations: allViolations,
|
|
227
|
+
inputs: {
|
|
228
|
+
file: FILE,
|
|
229
|
+
preset: PRESET,
|
|
230
|
+
compareDiffs: COMPARE_DIFFS,
|
|
231
|
+
memoryDir: memoryInfo.dir,
|
|
232
|
+
memorySource: memoryInfo.source,
|
|
233
|
+
},
|
|
234
|
+
report: reportPath,
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
await writeFile(reportPath, JSON.stringify(result, null, 2), 'utf8')
|
|
238
|
+
process.stdout.write(JSON.stringify(result))
|
|
239
|
+
process.stdout.write('\n')
|
|
240
|
+
|
|
241
|
+
// Exit 3 when there's anything medium or high — that's what the orchestrator
|
|
242
|
+
// branches on to decide "re-roll the build" vs "ship it." Low-only is OK
|
|
243
|
+
// because low-severity is "nice to fix later," not a blocker.
|
|
244
|
+
process.exit(passed ? 0 : 3)
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
main().catch((err) => {
|
|
248
|
+
process.stderr.write(`[sb-review-checks] fatal: ${err?.stack || err}\n`)
|
|
249
|
+
process.exit(1)
|
|
250
|
+
})
|
|
@@ -0,0 +1,343 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// test-anti-patterns.mjs — Pure-function tests for the 8 anti-pattern checks.
|
|
3
|
+
// CSS-only checks need no deps; HTML-shape checks need cheerio (skipped when
|
|
4
|
+
// missing). No chromium gating, so coverage can go deep.
|
|
5
|
+
|
|
6
|
+
import { fileURLToPath } from 'node:url'
|
|
7
|
+
import { dirname, resolve } from 'node:path'
|
|
8
|
+
import { strict as assert } from 'node:assert'
|
|
9
|
+
|
|
10
|
+
const here = dirname(fileURLToPath(import.meta.url))
|
|
11
|
+
const LIB = resolve(here, '..', 'lib', 'anti-patterns.mjs')
|
|
12
|
+
const {
|
|
13
|
+
check100vh,
|
|
14
|
+
checkImgHeight100Hero,
|
|
15
|
+
checkRoundOpacity,
|
|
16
|
+
checkAnchorColorInherit,
|
|
17
|
+
checkButtonNoChain,
|
|
18
|
+
checkImportantNoAncestor,
|
|
19
|
+
checkOverlapBoxInWrapper,
|
|
20
|
+
checkSvgRasterImpostor,
|
|
21
|
+
runAntiPatterns,
|
|
22
|
+
walkRules,
|
|
23
|
+
lineOf,
|
|
24
|
+
sortBySeverity,
|
|
25
|
+
} = await import(LIB)
|
|
26
|
+
|
|
27
|
+
let cheerio = null
|
|
28
|
+
try { cheerio = await import('cheerio') } catch {}
|
|
29
|
+
|
|
30
|
+
let passed = 0
|
|
31
|
+
let failed = 0
|
|
32
|
+
|
|
33
|
+
function test(name, fn) {
|
|
34
|
+
try {
|
|
35
|
+
fn()
|
|
36
|
+
process.stdout.write(`ok - ${name}\n`)
|
|
37
|
+
passed++
|
|
38
|
+
} catch (err) {
|
|
39
|
+
process.stdout.write(`not ok - ${name}\n ${err.message}\n`)
|
|
40
|
+
failed++
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
function skip(name, reason) {
|
|
45
|
+
process.stdout.write(`ok - ${name} # SKIP ${reason}\n`)
|
|
46
|
+
passed++
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
// Build a ctx without cheerio (CSS-only checks).
|
|
50
|
+
function cssCtx({ css = '', preset = 'wp-elementor' } = {}) {
|
|
51
|
+
return {
|
|
52
|
+
html: `<style>${css}</style>`,
|
|
53
|
+
css,
|
|
54
|
+
$: cheerio ? cheerio.load('<div></div>') : null,
|
|
55
|
+
preset,
|
|
56
|
+
styleBlocks: [{ css, htmlOffset: 0 }],
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
// Build a ctx with cheerio for HTML-shape checks.
|
|
61
|
+
function htmlCtx({ html = '', css = '', preset = 'wp-elementor' } = {}) {
|
|
62
|
+
if (!cheerio) return null
|
|
63
|
+
return {
|
|
64
|
+
html: `${html}<style>${css}</style>`,
|
|
65
|
+
css,
|
|
66
|
+
$: cheerio.load(html),
|
|
67
|
+
preset,
|
|
68
|
+
styleBlocks: [{ css, htmlOffset: 0 }],
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
// ─── helpers ────────────────────────────────────────────────────────────────
|
|
73
|
+
|
|
74
|
+
test('walkRules: parses a simple rule', () => {
|
|
75
|
+
const rules = walkRules('.x { color: red; }')
|
|
76
|
+
assert.equal(rules.length, 1)
|
|
77
|
+
assert.equal(rules[0].selector, '.x')
|
|
78
|
+
assert.match(rules[0].body, /color\s*:\s*red/)
|
|
79
|
+
})
|
|
80
|
+
|
|
81
|
+
test('walkRules: parses multiple rules', () => {
|
|
82
|
+
const rules = walkRules('.a { color: red; } .b { color: blue; }')
|
|
83
|
+
assert.equal(rules.length, 2)
|
|
84
|
+
assert.equal(rules[0].selector, '.a')
|
|
85
|
+
assert.equal(rules[1].selector, '.b')
|
|
86
|
+
})
|
|
87
|
+
|
|
88
|
+
test('walkRules: skips comments', () => {
|
|
89
|
+
const rules = walkRules('/* comment */ .x { color: red; }')
|
|
90
|
+
assert.equal(rules.length, 1)
|
|
91
|
+
})
|
|
92
|
+
|
|
93
|
+
test('walkRules: handles @media at-rule', () => {
|
|
94
|
+
const rules = walkRules('@media (min-width: 1000px) { .x { color: red; } }')
|
|
95
|
+
assert.equal(rules.length, 1)
|
|
96
|
+
assert.equal(rules[0].selector, '.x')
|
|
97
|
+
})
|
|
98
|
+
|
|
99
|
+
test('walkRules: handles @import statement', () => {
|
|
100
|
+
const rules = walkRules('@import url("x.css"); .x { color: red; }')
|
|
101
|
+
assert.equal(rules.length, 1)
|
|
102
|
+
})
|
|
103
|
+
|
|
104
|
+
test('lineOf: 1-based line numbers', () => {
|
|
105
|
+
assert.equal(lineOf('a\nb\nc', 0), 1)
|
|
106
|
+
assert.equal(lineOf('a\nb\nc', 2), 2)
|
|
107
|
+
assert.equal(lineOf('a\nb\nc', 4), 3)
|
|
108
|
+
})
|
|
109
|
+
|
|
110
|
+
test('sortBySeverity: high → medium → low', () => {
|
|
111
|
+
const sorted = sortBySeverity([
|
|
112
|
+
{ severity: 'low' }, { severity: 'high' }, { severity: 'medium' }, { severity: 'high' },
|
|
113
|
+
])
|
|
114
|
+
assert.deepEqual(sorted.map((v) => v.severity), ['high', 'high', 'medium', 'low'])
|
|
115
|
+
})
|
|
116
|
+
|
|
117
|
+
// ─── #1 — 100vh ─────────────────────────────────────────────────────────────
|
|
118
|
+
|
|
119
|
+
test('#1: detects 100vh in wp-elementor preset', () => {
|
|
120
|
+
const ctx = cssCtx({ css: '.hero { height: 100vh; }', preset: 'wp-elementor' })
|
|
121
|
+
const v = check100vh(ctx)
|
|
122
|
+
assert.equal(v.length, 1)
|
|
123
|
+
assert.equal(v[0].id, '#1')
|
|
124
|
+
assert.equal(v[0].severity, 'high')
|
|
125
|
+
assert.match(v[0].candidateFix, /aspect-ratio/)
|
|
126
|
+
})
|
|
127
|
+
|
|
128
|
+
test('#1: ignores 100vh in shopify preset', () => {
|
|
129
|
+
const ctx = cssCtx({ css: '.hero { height: 100vh; }', preset: 'shopify-section' })
|
|
130
|
+
assert.equal(check100vh(ctx).length, 0)
|
|
131
|
+
})
|
|
132
|
+
|
|
133
|
+
test('#1: zero violations on clean CSS', () => {
|
|
134
|
+
const ctx = cssCtx({ css: '.hero { aspect-ratio: 9 / 16; max-height: 700px; }' })
|
|
135
|
+
assert.equal(check100vh(ctx).length, 0)
|
|
136
|
+
})
|
|
137
|
+
|
|
138
|
+
// ─── #2 — img height 100% in hero (cheerio-gated) ──────────────────────────
|
|
139
|
+
|
|
140
|
+
if (!cheerio) {
|
|
141
|
+
skip('#2: detects <img> with height:100% in hero', 'cheerio not installed')
|
|
142
|
+
skip('#2: ignores <img> outside hero', 'cheerio not installed')
|
|
143
|
+
} else {
|
|
144
|
+
test('#2: detects <img> with height:100% in hero', () => {
|
|
145
|
+
const ctx = htmlCtx({
|
|
146
|
+
html: '<section class="hero"><img src="x.jpg" style="height: 100%; width: 100%"></section>',
|
|
147
|
+
})
|
|
148
|
+
const v = checkImgHeight100Hero(ctx)
|
|
149
|
+
assert.equal(v.length, 1)
|
|
150
|
+
assert.equal(v[0].id, '#2')
|
|
151
|
+
assert.equal(v[0].severity, 'high')
|
|
152
|
+
assert.match(v[0].candidateFix, /background-image/)
|
|
153
|
+
})
|
|
154
|
+
|
|
155
|
+
test('#2: ignores <img> outside hero', () => {
|
|
156
|
+
const ctx = htmlCtx({
|
|
157
|
+
html: '<section class="other"><img src="x.jpg" style="height: 100%"></section>',
|
|
158
|
+
})
|
|
159
|
+
assert.equal(checkImgHeight100Hero(ctx).length, 0)
|
|
160
|
+
})
|
|
161
|
+
|
|
162
|
+
test('#2: ignores hero img without height:100%', () => {
|
|
163
|
+
const ctx = htmlCtx({
|
|
164
|
+
html: '<section class="hero"><img src="x.jpg" style="width: 100%"></section>',
|
|
165
|
+
})
|
|
166
|
+
assert.equal(checkImgHeight100Hero(ctx).length, 0)
|
|
167
|
+
})
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
// ─── #3 — Round opacity ─────────────────────────────────────────────────────
|
|
171
|
+
|
|
172
|
+
test('#3: flags opacity 0.5', () => {
|
|
173
|
+
const ctx = cssCtx({ css: '.x { opacity: 0.5; }' })
|
|
174
|
+
const v = checkRoundOpacity(ctx)
|
|
175
|
+
assert.equal(v.length, 1)
|
|
176
|
+
assert.equal(v[0].id, '#3')
|
|
177
|
+
assert.equal(v[0].severity, 'low')
|
|
178
|
+
})
|
|
179
|
+
|
|
180
|
+
test('#3: flags 0.3 and 0.7', () => {
|
|
181
|
+
const ctx = cssCtx({ css: '.a { opacity: 0.3; } .b { opacity: 0.7; }' })
|
|
182
|
+
const v = checkRoundOpacity(ctx)
|
|
183
|
+
assert.equal(v.length, 2)
|
|
184
|
+
})
|
|
185
|
+
|
|
186
|
+
test('#3: ignores measured 0.42', () => {
|
|
187
|
+
const ctx = cssCtx({ css: '.x { opacity: 0.42; }' })
|
|
188
|
+
assert.equal(checkRoundOpacity(ctx).length, 0)
|
|
189
|
+
})
|
|
190
|
+
|
|
191
|
+
test('#3: ignores fully opaque/transparent', () => {
|
|
192
|
+
const ctx = cssCtx({ css: '.a { opacity: 1; } .b { opacity: 0; }' })
|
|
193
|
+
assert.equal(checkRoundOpacity(ctx).length, 0)
|
|
194
|
+
})
|
|
195
|
+
|
|
196
|
+
// ─── #4 — color: inherit on <a> ─────────────────────────────────────────────
|
|
197
|
+
|
|
198
|
+
test('#4: flags `a { color: inherit }`', () => {
|
|
199
|
+
const ctx = cssCtx({ css: '.scope a { color: inherit; }' })
|
|
200
|
+
const v = checkAnchorColorInherit(ctx)
|
|
201
|
+
assert.equal(v.length, 1)
|
|
202
|
+
assert.equal(v[0].id, '#4')
|
|
203
|
+
assert.equal(v[0].severity, 'medium')
|
|
204
|
+
})
|
|
205
|
+
|
|
206
|
+
test('#4: ignores explicit color', () => {
|
|
207
|
+
const ctx = cssCtx({ css: '.scope a { color: #d8112a; }' })
|
|
208
|
+
assert.equal(checkAnchorColorInherit(ctx).length, 0)
|
|
209
|
+
})
|
|
210
|
+
|
|
211
|
+
test('#4: ignores div { color: inherit }', () => {
|
|
212
|
+
const ctx = cssCtx({ css: '.scope div { color: inherit; }' })
|
|
213
|
+
assert.equal(checkAnchorColorInherit(ctx).length, 0)
|
|
214
|
+
})
|
|
215
|
+
|
|
216
|
+
// ─── #5 — button without chain ──────────────────────────────────────────────
|
|
217
|
+
|
|
218
|
+
test('#5: flags single-class button selector with bg/color', () => {
|
|
219
|
+
const ctx = cssCtx({ css: '.cta { background: red; color: white; }' })
|
|
220
|
+
const v = checkButtonNoChain(ctx)
|
|
221
|
+
assert.equal(v.length, 1)
|
|
222
|
+
assert.equal(v[0].id, '#5')
|
|
223
|
+
assert.equal(v[0].severity, 'high')
|
|
224
|
+
})
|
|
225
|
+
|
|
226
|
+
test('#5: passes when scope is ancestor', () => {
|
|
227
|
+
const ctx = cssCtx({ css: '.scope .cta { background: red; }' })
|
|
228
|
+
assert.equal(checkButtonNoChain(ctx).length, 0)
|
|
229
|
+
})
|
|
230
|
+
|
|
231
|
+
test('#5: passes with doubled class chain', () => {
|
|
232
|
+
const ctx = cssCtx({ css: '.cta.cta { background: red; }' })
|
|
233
|
+
assert.equal(checkButtonNoChain(ctx).length, 0)
|
|
234
|
+
})
|
|
235
|
+
|
|
236
|
+
test('#5: ignores non-button selectors', () => {
|
|
237
|
+
const ctx = cssCtx({ css: '.title { background: red; }' })
|
|
238
|
+
assert.equal(checkButtonNoChain(ctx).length, 0)
|
|
239
|
+
})
|
|
240
|
+
|
|
241
|
+
// ─── #5b — !important without ancestor ──────────────────────────────────────
|
|
242
|
+
|
|
243
|
+
test('#5b: flags single-class !important', () => {
|
|
244
|
+
const ctx = cssCtx({ css: '.title { font-size: 24px !important; }' })
|
|
245
|
+
const v = checkImportantNoAncestor(ctx)
|
|
246
|
+
assert.equal(v.length, 1)
|
|
247
|
+
assert.equal(v[0].id, '#5b')
|
|
248
|
+
assert.equal(v[0].severity, 'high')
|
|
249
|
+
assert.match(v[0].candidateFix, /\.\{scope\}/)
|
|
250
|
+
})
|
|
251
|
+
|
|
252
|
+
test('#5b: passes with ancestor descendant combinator', () => {
|
|
253
|
+
const ctx = cssCtx({ css: '.scope .title { font-size: 24px !important; }' })
|
|
254
|
+
assert.equal(checkImportantNoAncestor(ctx).length, 0)
|
|
255
|
+
})
|
|
256
|
+
|
|
257
|
+
test('#5b: ignores rules without !important', () => {
|
|
258
|
+
const ctx = cssCtx({ css: '.title { font-size: 24px; }' })
|
|
259
|
+
assert.equal(checkImportantNoAncestor(ctx).length, 0)
|
|
260
|
+
})
|
|
261
|
+
|
|
262
|
+
// ─── #6 — overlap box on wrapper ────────────────────────────────────────────
|
|
263
|
+
|
|
264
|
+
test('#6: flags ::before on wrapper class with absolute', () => {
|
|
265
|
+
const ctx = cssCtx({
|
|
266
|
+
css: '.story__wrapper::before { content: ""; position: absolute; inset: 30% 0 0 0; background: #f4eee8; }',
|
|
267
|
+
})
|
|
268
|
+
const v = checkOverlapBoxInWrapper(ctx)
|
|
269
|
+
assert.equal(v.length, 1)
|
|
270
|
+
assert.equal(v[0].id, '#6')
|
|
271
|
+
assert.match(v[0].candidateFix, /move the/)
|
|
272
|
+
})
|
|
273
|
+
|
|
274
|
+
test('#6: passes when ::before is on the section', () => {
|
|
275
|
+
const ctx = cssCtx({
|
|
276
|
+
css: '.story::before { content: ""; position: absolute; inset: 30% 0 0 0; }',
|
|
277
|
+
})
|
|
278
|
+
assert.equal(checkOverlapBoxInWrapper(ctx).length, 0)
|
|
279
|
+
})
|
|
280
|
+
|
|
281
|
+
test('#6: ignores ::before without position absolute', () => {
|
|
282
|
+
const ctx = cssCtx({
|
|
283
|
+
css: '.story__wrapper::before { content: ""; display: block; }',
|
|
284
|
+
})
|
|
285
|
+
assert.equal(checkOverlapBoxInWrapper(ctx).length, 0)
|
|
286
|
+
})
|
|
287
|
+
|
|
288
|
+
// ─── #8 — SVG raster impostor (cheerio-gated) ───────────────────────────────
|
|
289
|
+
|
|
290
|
+
if (!cheerio) {
|
|
291
|
+
skip('#8: flags large SVG in product context', 'cheerio not installed')
|
|
292
|
+
skip('#8: ignores small icon SVG', 'cheerio not installed')
|
|
293
|
+
} else {
|
|
294
|
+
test('#8: flags large SVG in product context', () => {
|
|
295
|
+
const ctx = htmlCtx({
|
|
296
|
+
html: `<div class="product"><svg viewBox="0 0 600 600"><path d="M0 0h600"/><path d="M0 600h600"/></svg></div>`,
|
|
297
|
+
})
|
|
298
|
+
const v = checkSvgRasterImpostor(ctx)
|
|
299
|
+
assert.equal(v.length, 1)
|
|
300
|
+
assert.equal(v[0].id, '#8')
|
|
301
|
+
assert.match(v[0].candidateFix, /<img src=/)
|
|
302
|
+
})
|
|
303
|
+
|
|
304
|
+
test('#8: ignores small icon SVG', () => {
|
|
305
|
+
const ctx = htmlCtx({
|
|
306
|
+
html: `<div class="icon"><svg viewBox="0 0 24 24"><path d="M0 0h24"/></svg></div>`,
|
|
307
|
+
})
|
|
308
|
+
assert.equal(checkSvgRasterImpostor(ctx).length, 0)
|
|
309
|
+
})
|
|
310
|
+
|
|
311
|
+
test('#8: ignores large SVG outside product context', () => {
|
|
312
|
+
const ctx = htmlCtx({
|
|
313
|
+
html: `<div class="logo"><svg viewBox="0 0 600 600"><path d="M0 0h600"/><path d="M0 600h600"/></svg></div>`,
|
|
314
|
+
})
|
|
315
|
+
assert.equal(checkSvgRasterImpostor(ctx).length, 0)
|
|
316
|
+
})
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
// ─── runAntiPatterns: aggregator ────────────────────────────────────────────
|
|
320
|
+
|
|
321
|
+
if (!cheerio) {
|
|
322
|
+
skip('runAntiPatterns: aggregates across checks and sorts', 'cheerio not installed')
|
|
323
|
+
} else {
|
|
324
|
+
test('runAntiPatterns: aggregates across checks and sorts', () => {
|
|
325
|
+
const ctx = htmlCtx({
|
|
326
|
+
preset: 'wp-elementor',
|
|
327
|
+
html: '<section class="hero"><img src="x" style="height: 100%"></section>',
|
|
328
|
+
css: '.title { font-size: 24px !important; } .hero { height: 100vh; } .x { opacity: 0.5; }',
|
|
329
|
+
})
|
|
330
|
+
const v = runAntiPatterns(ctx)
|
|
331
|
+
// 100vh + img-height + !important + opacity = at least 4
|
|
332
|
+
assert.ok(v.length >= 4, `expected >=4 violations, got ${v.length}`)
|
|
333
|
+
// First entries should be high severity
|
|
334
|
+
assert.equal(v[0].severity, 'high')
|
|
335
|
+
})
|
|
336
|
+
}
|
|
337
|
+
|
|
338
|
+
if (failed > 0) {
|
|
339
|
+
process.stdout.write(`\n${failed} failed, ${passed} passed\n`)
|
|
340
|
+
process.exit(1)
|
|
341
|
+
}
|
|
342
|
+
process.stdout.write(`\n${passed} passed\n`)
|
|
343
|
+
process.exit(0)
|