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.
Files changed (76) hide show
  1. package/CHANGELOG.md +110 -0
  2. package/LICENSE +21 -0
  3. package/README.md +301 -0
  4. package/bin/install.js +256 -0
  5. package/lib/copy-templates.mjs +52 -0
  6. package/lib/install-deps.mjs +62 -0
  7. package/lib/prompt-config.mjs +83 -0
  8. package/lib/verify-env.mjs +19 -0
  9. package/package.json +63 -0
  10. package/scripts/sync-templates.mjs +71 -0
  11. package/templates/commands/build-page.md +490 -0
  12. package/templates/commands/build-site.md +548 -0
  13. package/templates/commands/clip-section.md +519 -0
  14. package/templates/memory/anti-patterns.md +212 -0
  15. package/templates/memory/design-knowledge.md +225 -0
  16. package/templates/memory/fixes.md +163 -0
  17. package/templates/memory/patterns.md +681 -0
  18. package/templates/presets/shopify-section.yaml +51 -0
  19. package/templates/presets/wp-elementor.yaml +49 -0
  20. package/templates/reports/fixtures/mock-run-1.json +115 -0
  21. package/templates/reports/fixtures/mock-run-2.json +72 -0
  22. package/templates/reports/report-renderer.mjs +218 -0
  23. package/templates/reports/report-template.html +571 -0
  24. package/templates/skills/sb-build-shopify/SKILL.md +104 -0
  25. package/templates/skills/sb-build-shopify/references/shopify-build-rules.md +563 -0
  26. package/templates/skills/sb-build-shopify/scripts/build-shopify.mjs +637 -0
  27. package/templates/skills/sb-build-shopify/scripts/tests/test-build-shopify.mjs +424 -0
  28. package/templates/skills/sb-build-wp/SKILL.md +83 -0
  29. package/templates/skills/sb-build-wp/references/wp-build-rules.md +376 -0
  30. package/templates/skills/sb-build-wp/scripts/build-wp.mjs +327 -0
  31. package/templates/skills/sb-build-wp/scripts/tests/test-build-wp.mjs +224 -0
  32. package/templates/skills/sb-compare-visual/SKILL.md +121 -0
  33. package/templates/skills/sb-compare-visual/scripts/compare-visual.mjs +387 -0
  34. package/templates/skills/sb-compare-visual/scripts/lib/compare-tokens.mjs +273 -0
  35. package/templates/skills/sb-compare-visual/scripts/tests/test-compare-tokens.mjs +350 -0
  36. package/templates/skills/sb-compare-visual/scripts/tests/test-compare-visual.mjs +626 -0
  37. package/templates/skills/sb-crawl-and-list/SKILL.md +99 -0
  38. package/templates/skills/sb-crawl-and-list/scripts/crawl-and-list.mjs +437 -0
  39. package/templates/skills/sb-crawl-and-list/scripts/lib/blocklist-filter.mjs +176 -0
  40. package/templates/skills/sb-crawl-and-list/scripts/lib/fallback-crawler.mjs +107 -0
  41. package/templates/skills/sb-crawl-and-list/scripts/lib/page-classifier.mjs +89 -0
  42. package/templates/skills/sb-crawl-and-list/scripts/lib/sitemap-parser.mjs +118 -0
  43. package/templates/skills/sb-crawl-and-list/scripts/tests/test-blocklist-filter.mjs +204 -0
  44. package/templates/skills/sb-crawl-and-list/scripts/tests/test-crawl-and-list.mjs +276 -0
  45. package/templates/skills/sb-crawl-and-list/scripts/tests/test-fallback-crawler.mjs +243 -0
  46. package/templates/skills/sb-crawl-and-list/scripts/tests/test-page-classifier.mjs +120 -0
  47. package/templates/skills/sb-crawl-and-list/scripts/tests/test-sitemap-parser.mjs +157 -0
  48. package/templates/skills/sb-extract-assets/SKILL.md +112 -0
  49. package/templates/skills/sb-extract-assets/scripts/extract-assets.mjs +484 -0
  50. package/templates/skills/sb-extract-assets/scripts/tests/test-extract-assets.mjs +112 -0
  51. package/templates/skills/sb-inspect-live/SKILL.md +105 -0
  52. package/templates/skills/sb-inspect-live/scripts/inspect-live.mjs +693 -0
  53. package/templates/skills/sb-inspect-live/scripts/tests/test-inspect-live.mjs +181 -0
  54. package/templates/skills/sb-review-checks/SKILL.md +113 -0
  55. package/templates/skills/sb-review-checks/references/review-rules.md +195 -0
  56. package/templates/skills/sb-review-checks/scripts/lib/anti-patterns.mjs +379 -0
  57. package/templates/skills/sb-review-checks/scripts/lib/cross-reference.mjs +115 -0
  58. package/templates/skills/sb-review-checks/scripts/lib/design-quality.mjs +541 -0
  59. package/templates/skills/sb-review-checks/scripts/review-checks.mjs +250 -0
  60. package/templates/skills/sb-review-checks/scripts/tests/test-anti-patterns.mjs +343 -0
  61. package/templates/skills/sb-review-checks/scripts/tests/test-cross-reference.mjs +170 -0
  62. package/templates/skills/sb-review-checks/scripts/tests/test-design-quality.mjs +493 -0
  63. package/templates/skills/sb-review-checks/scripts/tests/test-review-checks.mjs +267 -0
  64. package/templates/skills/sb-tweak/SKILL.md +130 -0
  65. package/templates/skills/sb-tweak/references/tweak-patterns.md +157 -0
  66. package/templates/skills/sb-tweak/scripts/lib/diff-summarizer.mjs +140 -0
  67. package/templates/skills/sb-tweak/scripts/lib/element-locator.mjs +507 -0
  68. package/templates/skills/sb-tweak/scripts/lib/intent-parser.mjs +324 -0
  69. package/templates/skills/sb-tweak/scripts/tests/test-diff-summarizer.mjs +248 -0
  70. package/templates/skills/sb-tweak/scripts/tests/test-element-locator.mjs +418 -0
  71. package/templates/skills/sb-tweak/scripts/tests/test-intent-parser.mjs +496 -0
  72. package/templates/skills/sb-tweak/scripts/tests/test-tweak.mjs +407 -0
  73. package/templates/skills/sb-tweak/scripts/tweak.mjs +656 -0
  74. package/templates/skills/sb-validate-render/SKILL.md +120 -0
  75. package/templates/skills/sb-validate-render/scripts/tests/test-validate-render.mjs +304 -0
  76. package/templates/skills/sb-validate-render/scripts/validate-render.mjs +645 -0
@@ -0,0 +1,418 @@
1
+ #!/usr/bin/env node
2
+ // test-element-locator.mjs — Unit tests for lib/element-locator.mjs.
3
+ // Covers: candidate collection by type, qualifier scoring, ordinal selection,
4
+ // CSS-rule discovery, ambiguity detection, selector matching.
5
+ //
6
+ // Loads cheerio lazily — gracefully skips integration tests when not installed.
7
+
8
+ import { strict as assert } from 'node:assert'
9
+ import {
10
+ locateCandidates,
11
+ isAmbiguous,
12
+ findCssRule,
13
+ findDeclaration,
14
+ walkTopLevelRules,
15
+ selectorMatches,
16
+ splitStyleBlocks,
17
+ findCssRuleAcrossBlocks,
18
+ } from '../lib/element-locator.mjs'
19
+
20
+ let passed = 0
21
+ let failed = 0
22
+ let cheerio = null
23
+
24
+ function test(name, fn) {
25
+ try {
26
+ const result = fn()
27
+ if (result && typeof result.then === 'function') {
28
+ return result.then(
29
+ () => { process.stdout.write(`ok - ${name}\n`); passed++ },
30
+ (err) => { process.stdout.write(`not ok - ${name}\n ${err.message}\n`); failed++ },
31
+ )
32
+ }
33
+ process.stdout.write(`ok - ${name}\n`)
34
+ passed++
35
+ } catch (err) {
36
+ process.stdout.write(`not ok - ${name}\n ${err.message}\n`)
37
+ failed++
38
+ }
39
+ }
40
+
41
+ function skip(name, reason) {
42
+ process.stdout.write(`ok - ${name} # SKIP ${reason}\n`)
43
+ passed++
44
+ }
45
+
46
+ async function loadCheerio() {
47
+ if (cheerio) return cheerio
48
+ try {
49
+ cheerio = await import('cheerio')
50
+ } catch {
51
+ cheerio = null
52
+ }
53
+ return cheerio
54
+ }
55
+
56
+ // ─── Pure CSS helpers (no cheerio needed) ─────────────────────────────────────
57
+
58
+ test('walkTopLevelRules: simple rule', () => {
59
+ const css = '.foo { color: red; }'
60
+ const rules = walkTopLevelRules(css)
61
+ assert.equal(rules.length, 1)
62
+ assert.equal(rules[0].selector, '.foo')
63
+ assert.match(rules[0].body, /color:\s*red/)
64
+ })
65
+
66
+ test('walkTopLevelRules: multiple rules', () => {
67
+ const css = '.foo { color: red; } .bar { color: blue; }'
68
+ const rules = walkTopLevelRules(css)
69
+ assert.equal(rules.length, 2)
70
+ assert.equal(rules[0].selector, '.foo')
71
+ assert.equal(rules[1].selector, '.bar')
72
+ })
73
+
74
+ test('walkTopLevelRules: handles comments', () => {
75
+ const css = '/* hi */ .foo { color: red; /* nested */ }'
76
+ const rules = walkTopLevelRules(css)
77
+ assert.equal(rules.length, 1)
78
+ })
79
+
80
+ test('walkTopLevelRules: descends into @media', () => {
81
+ const css = '.a { color: red; } @media (min-width: 600px) { .b { color: blue; } }'
82
+ const rules = walkTopLevelRules(css)
83
+ // both .a and the inner .b should be picked up
84
+ assert.equal(rules.length, 2)
85
+ assert.equal(rules[0].selector, '.a')
86
+ assert.equal(rules[1].selector, '.b')
87
+ })
88
+
89
+ test('findDeclaration: finds property', () => {
90
+ const body = ' color: red; font-size: 14px; '
91
+ const d = findDeclaration(body, 0, 'font-size')
92
+ assert.equal(d.value, '14px')
93
+ assert.equal(d.important, false)
94
+ })
95
+
96
+ test('findDeclaration: marks !important', () => {
97
+ const body = ' color: red !important; '
98
+ const d = findDeclaration(body, 0, 'color')
99
+ assert.equal(d.value, 'red')
100
+ assert.equal(d.important, true)
101
+ })
102
+
103
+ test('findDeclaration: missing property → null', () => {
104
+ const body = ' color: red; '
105
+ const d = findDeclaration(body, 0, 'font-size')
106
+ assert.equal(d, null)
107
+ })
108
+
109
+ test('findCssRule: matches by class', () => {
110
+ const css = '.hero__title { font-size: 27px; color: red; }'
111
+ const r = findCssRule(css, ['hero__title'], 'h1', 'font-size')
112
+ assert.ok(r, 'expected match')
113
+ assert.equal(r.currentValue, '27px')
114
+ })
115
+
116
+ test('findCssRule: matches by tag', () => {
117
+ const css = 'h1 { font-size: 32px; }'
118
+ const r = findCssRule(css, [], 'h1', 'font-size')
119
+ assert.ok(r)
120
+ assert.equal(r.currentValue, '32px')
121
+ })
122
+
123
+ test('findCssRule: no match returns null', () => {
124
+ const css = '.other { font-size: 14px; }'
125
+ const r = findCssRule(css, ['hero__title'], 'h1', 'font-size')
126
+ assert.equal(r, null)
127
+ })
128
+
129
+ test('findCssRule: prefers cascade winner (last match)', () => {
130
+ const css = '.title { font-size: 14px; } .title { font-size: 18px; }'
131
+ const r = findCssRule(css, ['title'], 'h1', 'font-size')
132
+ assert.equal(r.currentValue, '18px')
133
+ })
134
+
135
+ test('findCssRule: with blockOffset offsets are absolute', () => {
136
+ const css = '.foo { color: red; }'
137
+ const r = findCssRule(css, ['foo'], 'div', 'color', 1000)
138
+ assert.ok(r.declStart >= 1000)
139
+ assert.equal(r.blockOffset, 1000)
140
+ })
141
+
142
+ test('selectorMatches: class hit', () => {
143
+ assert.equal(selectorMatches('.hero__title', ['hero__title'], 'h1'), true)
144
+ })
145
+
146
+ test('selectorMatches: tag hit when no class', () => {
147
+ assert.equal(selectorMatches('h1', [], 'h1'), true)
148
+ })
149
+
150
+ test('selectorMatches: chained selector matches via class', () => {
151
+ assert.equal(selectorMatches('.hero .hero__title', ['hero__title'], 'h1'), true)
152
+ })
153
+
154
+ test('selectorMatches: comma-separated list', () => {
155
+ assert.equal(selectorMatches('.a, .b, .c', ['b'], 'div'), true)
156
+ })
157
+
158
+ test('selectorMatches: no class hit and no tag hit → false', () => {
159
+ assert.equal(selectorMatches('.other', ['foo'], 'div'), false)
160
+ })
161
+
162
+ test('selectorMatches: tag-only selector matches when class only present', () => {
163
+ assert.equal(selectorMatches('h1', ['hero__title'], 'h1'), true)
164
+ })
165
+
166
+ test('splitStyleBlocks: html with one style block', () => {
167
+ const html = '<style>.a { color: red; }</style>'
168
+ const blocks = splitStyleBlocks(html)
169
+ assert.equal(blocks.length, 1)
170
+ assert.match(blocks[0].css, /color: red/)
171
+ })
172
+
173
+ test('splitStyleBlocks: liquid {% style %}', () => {
174
+ const liquid = '{% style %}.a { color: red; }{% endstyle %}'
175
+ const blocks = splitStyleBlocks(liquid)
176
+ assert.equal(blocks.length, 1)
177
+ })
178
+
179
+ test('splitStyleBlocks: multiple blocks ordered by offset', () => {
180
+ const html = '<style>.a { x: 1; }</style><div></div><style>.b { y: 2; }</style>'
181
+ const blocks = splitStyleBlocks(html)
182
+ assert.equal(blocks.length, 2)
183
+ assert.ok(blocks[0].offset < blocks[1].offset)
184
+ })
185
+
186
+ test('splitStyleBlocks: empty input', () => {
187
+ assert.equal(splitStyleBlocks('').length, 0)
188
+ })
189
+
190
+ test('findCssRuleAcrossBlocks: finds in the right block', () => {
191
+ const blocks = [
192
+ { css: '.a { color: red; }', offset: 0 },
193
+ { css: '.b { color: blue; }', offset: 100 },
194
+ ]
195
+ const r = findCssRuleAcrossBlocks(blocks, ['b'], 'div', 'color')
196
+ assert.ok(r)
197
+ assert.equal(r.currentValue, 'blue')
198
+ assert.ok(r.declStart >= 100)
199
+ })
200
+
201
+ test('findCssRuleAcrossBlocks: returns null when not found', () => {
202
+ const blocks = [{ css: '.a { color: red; }', offset: 0 }]
203
+ const r = findCssRuleAcrossBlocks(blocks, ['x'], 'span', 'color')
204
+ assert.equal(r, null)
205
+ })
206
+
207
+ // ─── isAmbiguous ──────────────────────────────────────────────────────────────
208
+
209
+ test('isAmbiguous: empty list → false', () => {
210
+ assert.equal(isAmbiguous([]), false)
211
+ })
212
+
213
+ test('isAmbiguous: single candidate → false', () => {
214
+ assert.equal(isAmbiguous([{ score: 0.8 }]), false)
215
+ })
216
+
217
+ test('isAmbiguous: top two close → true', () => {
218
+ assert.equal(isAmbiguous([{ score: 0.7 }, { score: 0.65 }]), true)
219
+ })
220
+
221
+ test('isAmbiguous: top two far apart → false', () => {
222
+ assert.equal(isAmbiguous([{ score: 0.9 }, { score: 0.4 }]), false)
223
+ })
224
+
225
+ test('isAmbiguous: top score below 0.3 → false (no high-confidence pick)', () => {
226
+ assert.equal(isAmbiguous([{ score: 0.25 }, { score: 0.2 }]), false)
227
+ })
228
+
229
+ test('isAmbiguous: custom threshold', () => {
230
+ assert.equal(isAmbiguous([{ score: 0.7 }, { score: 0.5 }], 0.05), false)
231
+ assert.equal(isAmbiguous([{ score: 0.7 }, { score: 0.5 }], 0.3), true)
232
+ })
233
+
234
+ // ─── locateCandidates (cheerio-required integration tests) ────────────────────
235
+
236
+ await (async () => {
237
+ const c = await loadCheerio()
238
+ if (!c) {
239
+ skip('locateCandidates: by heading + hero qualifier', 'cheerio not installed')
240
+ skip('locateCandidates: ordinal first', 'cheerio not installed')
241
+ skip('locateCandidates: ordinal last', 'cheerio not installed')
242
+ skip('locateCandidates: image qualifier hero (ancestor match)', 'cheerio not installed')
243
+ skip('locateCandidates: button without qualifier returns multiple', 'cheerio not installed')
244
+ skip('locateCandidates: applyTo=css-rule when CSS exists', 'cheerio not installed')
245
+ skip('locateCandidates: applyTo=attr for alt property', 'cheerio not installed')
246
+ skip('locateCandidates: applyTo=text when property=text', 'cheerio not installed')
247
+ skip('locateCandidates: applyTo=remove-element when action=remove', 'cheerio not installed')
248
+ skip('locateCandidates: empty when target type absent', 'cheerio not installed')
249
+ skip('locateCandidates: no candidates for missing target.type', 'cheerio not installed')
250
+ return
251
+ }
252
+
253
+ const HTML_HERO = `
254
+ <html><head><style>
255
+ .hero__title { font-size: 27px; color: black; }
256
+ .pricing__title { font-size: 22px; }
257
+ </style></head><body>
258
+ <section class="hero">
259
+ <h1 class="hero__title">Welcome</h1>
260
+ <img src="/hero.png" alt="hero" />
261
+ <button type="button">Buy now</button>
262
+ </section>
263
+ <section class="pricing">
264
+ <h2 class="pricing__title">Plans</h2>
265
+ <button type="button">Choose</button>
266
+ </section>
267
+ <footer>
268
+ <button type="button">Contact</button>
269
+ </footer>
270
+ </body></html>
271
+ `
272
+
273
+ await test('locateCandidates: by heading + hero qualifier', () => {
274
+ const $ = c.load(HTML_HERO)
275
+ const intent = {
276
+ action: 'increase',
277
+ target: { type: 'heading', qualifier: 'hero' },
278
+ property: 'font-size',
279
+ value: { kind: 'pixels', parsed: 32 },
280
+ }
281
+ const cands = locateCandidates(intent, $, HTML_HERO)
282
+ assert.ok(cands.length >= 1, 'expected at least one candidate')
283
+ assert.equal(cands[0].tag, 'h1')
284
+ assert.ok(cands[0].classes.includes('hero__title'))
285
+ assert.ok(cands[0].score >= 0.5, `top score was ${cands[0].score}`)
286
+ })
287
+
288
+ await test('locateCandidates: ordinal first', () => {
289
+ const $ = c.load(HTML_HERO)
290
+ const intent = {
291
+ action: 'remove',
292
+ target: { type: 'button', qualifier: 'first' },
293
+ property: null,
294
+ value: null,
295
+ }
296
+ const cands = locateCandidates(intent, $, HTML_HERO)
297
+ assert.ok(cands.length >= 1)
298
+ // First button text is "Buy now"
299
+ assert.match(cands[0].snippet, /Buy now/)
300
+ })
301
+
302
+ await test('locateCandidates: ordinal last', () => {
303
+ const $ = c.load(HTML_HERO)
304
+ const intent = {
305
+ action: 'remove',
306
+ target: { type: 'button', qualifier: 'last' },
307
+ property: null,
308
+ value: null,
309
+ }
310
+ const cands = locateCandidates(intent, $, HTML_HERO)
311
+ assert.ok(cands.length >= 1)
312
+ // Last button text is "Contact"
313
+ assert.match(cands[0].snippet, /Contact/)
314
+ })
315
+
316
+ await test('locateCandidates: image qualifier hero (ancestor match)', () => {
317
+ const $ = c.load(HTML_HERO)
318
+ const intent = {
319
+ action: 'set',
320
+ target: { type: 'image', qualifier: 'hero' },
321
+ property: 'alt',
322
+ value: { kind: 'text', parsed: 'AlphaInfuse hero' },
323
+ }
324
+ const cands = locateCandidates(intent, $, HTML_HERO)
325
+ assert.ok(cands.length >= 1)
326
+ assert.equal(cands[0].tag, 'img')
327
+ // Ancestor match scoring: should land >= 0.5 (0.2 base + 0.3 ancestor)
328
+ assert.ok(cands[0].score >= 0.5, `score was ${cands[0].score}`)
329
+ })
330
+
331
+ await test('locateCandidates: button without qualifier returns multiple', () => {
332
+ const $ = c.load(HTML_HERO)
333
+ const intent = {
334
+ action: 'set',
335
+ target: { type: 'button' },
336
+ property: 'background-color',
337
+ value: { kind: 'color', parsed: '#fff' },
338
+ }
339
+ const cands = locateCandidates(intent, $, HTML_HERO)
340
+ assert.ok(cands.length >= 2)
341
+ // Top candidates should be similar in score → ambiguous
342
+ assert.equal(isAmbiguous(cands), false) // base score is 0.2 — under threshold to trip ambiguity
343
+ })
344
+
345
+ await test('locateCandidates: applyTo=css-rule when CSS exists', () => {
346
+ const $ = c.load(HTML_HERO)
347
+ const intent = {
348
+ action: 'increase',
349
+ target: { type: 'heading', qualifier: 'hero' },
350
+ property: 'font-size',
351
+ value: { kind: 'pixels', parsed: 32 },
352
+ }
353
+ const cands = locateCandidates(intent, $, HTML_HERO)
354
+ assert.equal(cands[0].applyTo, 'css-rule')
355
+ assert.ok(cands[0].css)
356
+ assert.equal(cands[0].css.currentValue, '27px')
357
+ })
358
+
359
+ await test('locateCandidates: applyTo=attr for alt property', () => {
360
+ const $ = c.load(HTML_HERO)
361
+ const intent = {
362
+ action: 'set',
363
+ target: { type: 'image', qualifier: 'hero' },
364
+ property: 'alt',
365
+ value: { kind: 'text', parsed: 'New alt' },
366
+ }
367
+ const cands = locateCandidates(intent, $, HTML_HERO)
368
+ assert.equal(cands[0].applyTo, 'attr')
369
+ assert.equal(cands[0].attrName, 'alt')
370
+ })
371
+
372
+ await test('locateCandidates: applyTo=text when property=text', () => {
373
+ const $ = c.load(HTML_HERO)
374
+ const intent = {
375
+ action: 'set',
376
+ target: { type: 'heading', qualifier: 'hero' },
377
+ property: 'text',
378
+ value: { kind: 'text', parsed: 'New title' },
379
+ }
380
+ const cands = locateCandidates(intent, $, HTML_HERO)
381
+ assert.equal(cands[0].applyTo, 'text')
382
+ })
383
+
384
+ await test('locateCandidates: applyTo=remove-element when action=remove', () => {
385
+ const $ = c.load(HTML_HERO)
386
+ const intent = {
387
+ action: 'remove',
388
+ target: { type: 'button', qualifier: 'last' },
389
+ property: null,
390
+ value: null,
391
+ }
392
+ const cands = locateCandidates(intent, $, HTML_HERO)
393
+ assert.equal(cands[0].applyTo, 'remove-element')
394
+ })
395
+
396
+ await test('locateCandidates: empty when target type absent', () => {
397
+ const $ = c.load('<div>no buttons here</div>')
398
+ const intent = {
399
+ action: 'remove',
400
+ target: { type: 'button' },
401
+ property: null,
402
+ value: null,
403
+ }
404
+ const cands = locateCandidates(intent, $, '<div>no buttons here</div>')
405
+ assert.equal(cands.length, 0)
406
+ })
407
+
408
+ await test('locateCandidates: no candidates for missing target.type', () => {
409
+ const $ = c.load('<div>x</div>')
410
+ const cands = locateCandidates({ action: 'set', target: null }, $, '')
411
+ assert.equal(cands.length, 0)
412
+ })
413
+ })()
414
+
415
+ // ─── Done ─────────────────────────────────────────────────────────────────────
416
+
417
+ process.stdout.write(`\n1..${passed + failed}\n# passed ${passed}, failed ${failed}\n`)
418
+ process.exit(failed === 0 ? 0 : 1)