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,140 @@
1
+ // diff-summarizer.mjs — Turn a list of structured `changes` into a short,
2
+ // human-readable diff suitable for surfacing to the user. Also supports
3
+ // fallback line-level diffs when no structured change is provided (e.g. raw
4
+ // text replace via cheerio that doesn't lend itself to a property summary).
5
+ //
6
+ // Pure module, no I/O. Tested in isolation.
7
+
8
+ // ─── summarizeChanges ────────────────────────────────────────────────────────
9
+ //
10
+ // `changes` shape (each entry):
11
+ // {
12
+ // selector: string, // CSS selector / element identifier
13
+ // property: string, // 'font-size' | 'text' | 'alt' | ...
14
+ // before: string | null, // current value (null for additive ops)
15
+ // after: string | null, // new value (null for removals)
16
+ // scope: 'css-rule' | 'attr' | 'text' | 'inline-style' | 'remove-element',
17
+ // line?: number, // 1-based line where the change applies
18
+ // }
19
+ //
20
+ // Returns a single string with one line per change, formatted for terminal
21
+ // output. Produces stable, idempotent text — running twice on the same input
22
+ // yields the same output. When `changes` is empty, returns the canonical
23
+ // "no changes (already applied)" sentinel.
24
+
25
+ export function summarizeChanges(changes) {
26
+ if (!Array.isArray(changes) || changes.length === 0) {
27
+ return 'no changes (already applied)'
28
+ }
29
+ return changes.map(formatLine).join('\n')
30
+ }
31
+
32
+ // ─── formatLine ──────────────────────────────────────────────────────────────
33
+
34
+ export function formatLine(change) {
35
+ if (!change) return ''
36
+ const { selector, property, before, after, scope, line } = change
37
+ const where = line ? ` (line ${line})` : ''
38
+ const sel = quote(selector)
39
+
40
+ switch (scope) {
41
+ case 'css-rule':
42
+ return `Changed ${sel} ${property}: ${quote(before)} → ${quote(after)}${where}`
43
+ case 'inline-style':
44
+ if (before == null) {
45
+ return `Added inline ${property}: ${quote(after)} on ${sel}${where}`
46
+ }
47
+ return `Changed inline ${property}: ${quote(before)} → ${quote(after)} on ${sel}${where}`
48
+ case 'attr':
49
+ if (before == null) {
50
+ return `Added ${property}=${quote(after)} on ${sel}${where}`
51
+ }
52
+ if (after == null) {
53
+ return `Removed ${property} from ${sel}${where}`
54
+ }
55
+ return `Changed ${property} on ${sel}: ${quote(before)} → ${quote(after)}${where}`
56
+ case 'text':
57
+ return `Changed text of ${sel}: ${quote(before)} → ${quote(after)}${where}`
58
+ case 'remove-element':
59
+ return `Removed element ${sel}${where}`
60
+ default:
61
+ // Generic fallback — best-effort.
62
+ if (before == null && after != null) {
63
+ return `Set ${property} on ${sel} to ${quote(after)}${where}`
64
+ }
65
+ if (before != null && after != null) {
66
+ return `Changed ${property} on ${sel}: ${quote(before)} → ${quote(after)}${where}`
67
+ }
68
+ return `Updated ${sel}${where}`
69
+ }
70
+ }
71
+
72
+ function quote(s) {
73
+ if (s == null) return '∅'
74
+ const str = String(s)
75
+ // If already wrapped in backticks/quotes, leave it. Otherwise wrap in
76
+ // backticks so values with spaces / colons read well.
77
+ if (/^[`'"].*[`'"]$/.test(str)) return str
78
+ return '`' + str + '`'
79
+ }
80
+
81
+ // ─── diffLines ───────────────────────────────────────────────────────────────
82
+ //
83
+ // Last-resort diff for ad-hoc changes that don't carry structured metadata.
84
+ // Returns a small unified-diff-like string with `-` / `+` markers. Truncates
85
+ // long results so the JSON output stays human-readable. Used by the CLI when
86
+ // the change is best described by raw text rather than a property summary.
87
+
88
+ export function diffLines(beforeText, afterText, opts = {}) {
89
+ const maxLines = opts.maxLines ?? 20
90
+ const aLines = String(beforeText ?? '').split('\n')
91
+ const bLines = String(afterText ?? '').split('\n')
92
+
93
+ // Find the first and last differing lines so we don't print 10k unchanged
94
+ // lines around a 1-line edit.
95
+ let head = 0
96
+ while (head < aLines.length && head < bLines.length && aLines[head] === bLines[head]) head++
97
+ let tailA = aLines.length - 1
98
+ let tailB = bLines.length - 1
99
+ while (tailA > head && tailB > head && aLines[tailA] === bLines[tailB]) {
100
+ tailA--
101
+ tailB--
102
+ }
103
+ if (head > aLines.length - 1 && head > bLines.length - 1) {
104
+ return '' // identical
105
+ }
106
+
107
+ const out = []
108
+ out.push(`@@ around line ${head + 1} @@`)
109
+ for (let i = head; i <= tailA; i++) {
110
+ if (out.length >= maxLines + 1) {
111
+ out.push('… (truncated)')
112
+ return out.join('\n')
113
+ }
114
+ out.push(`- ${aLines[i]}`)
115
+ }
116
+ for (let i = head; i <= tailB; i++) {
117
+ if (out.length >= maxLines + 1) {
118
+ out.push('… (truncated)')
119
+ return out.join('\n')
120
+ }
121
+ out.push(`+ ${bLines[i]}`)
122
+ }
123
+ return out.join('\n')
124
+ }
125
+
126
+ // ─── computeLineFromOffset ───────────────────────────────────────────────────
127
+ //
128
+ // Tiny helper exported so the CLI can resolve byte-offset → 1-based line for
129
+ // CSS-rule edits without duplicating logic. (The element locator already
130
+ // computes lines for DOM elements; this handles the CSS-rule case.)
131
+
132
+ export function computeLineFromOffset(source, offset) {
133
+ if (!source || offset == null || offset < 0) return 0
134
+ let line = 1
135
+ const limit = Math.min(offset, source.length)
136
+ for (let i = 0; i < limit; i++) {
137
+ if (source.charCodeAt(i) === 10) line++
138
+ }
139
+ return line
140
+ }
@@ -0,0 +1,507 @@
1
+ // element-locator.mjs — Given a parsed intent and a cheerio-loaded HTML
2
+ // document, return scored candidate elements ordered best-first. Each candidate
3
+ // names a CSS selector, a snippet for human review, and the *application
4
+ // scope* — where the change should land (text content, attribute, CSS rule, or
5
+ // inline style).
6
+ //
7
+ // Pure module: cheerio is passed in by the caller, not imported here. No I/O.
8
+ // All public functions are testable in isolation.
9
+
10
+ const TYPE_TO_SELECTOR = {
11
+ heading: 'h1, h2, h3, h4, h5, h6',
12
+ subheading: 'h2, h3, h4, h5, h6',
13
+ image: 'img',
14
+ button: 'button, [role=button], a.btn, .btn, .cta, .button',
15
+ text: 'p, .description, .copy',
16
+ link: 'a[href]',
17
+ icon: 'svg, i.icon, .icon',
18
+ background: 'section, .section, [class*="hero"], [class*="bg"]',
19
+ section: 'section, .section, [class*="hero"], [class*="footer"], [class*="header"]',
20
+ }
21
+
22
+ const QUALIFIER_HINTS = {
23
+ hero: ['hero'],
24
+ footer: ['footer'],
25
+ header: ['header'],
26
+ // Ordinal qualifiers don't have class hints — they pick by position.
27
+ }
28
+
29
+ const ORDINAL_QUALIFIERS = new Set(['first', 'second', 'third', 'last'])
30
+
31
+ // ─── locateCandidates ────────────────────────────────────────────────────────
32
+ //
33
+ // `intent` shape (from intent-parser.mjs):
34
+ // { action, target: {type, qualifier?}, property, value, ... }
35
+ // `$` is a cheerio API.
36
+ // `source` is the raw file source (used for line numbers and CSS extraction).
37
+ //
38
+ // Returns an array of candidates, sorted by score (descending):
39
+ // [{
40
+ // selector: string, // CSS selector that uniquely picks the element
41
+ // snippet: string, // first ~80 chars of the element's outer HTML
42
+ // score: number, // 0..1
43
+ // line: number, // 1-based line in the source file
44
+ // applyTo: 'text' | 'attr' | 'css-rule' | 'inline-style' | 'remove-element',
45
+ // attrName?: string, // when applyTo === 'attr'
46
+ // css?: { selector, ruleStart, ruleEnd, declStart, declEnd, currentValue, blockOffset } // when applyTo === 'css-rule'
47
+ // }]
48
+
49
+ export function locateCandidates(intent, $, source) {
50
+ if (!intent || !intent.target?.type) return []
51
+ const { target, action, property, value } = intent
52
+
53
+ // 1. Find DOM candidates by target.type.
54
+ const domCands = collectDomCandidates($, target, source)
55
+ if (domCands.length === 0) return []
56
+
57
+ // 2. Score each candidate by:
58
+ // - exact qualifier-class hit (e.g. element class contains 'hero'): +0.5
59
+ // - ancestor qualifier hit: +0.3
60
+ // - ordinal position match: +0.4
61
+ // - default (no qualifier needed): +0.2
62
+ // - text relevance (if intent.target has a text snippet): +0.1
63
+ let scored = scoreCandidates(domCands, target, $)
64
+
65
+ // 3. Determine application scope per candidate. Properties like font-size
66
+ // prefer CSS-rule edits; alt/src prefer attributes; text changes touch
67
+ // the text content; remove drops the element.
68
+ scored = scored.map((c) =>
69
+ annotateApplyTo(c, { action, property, value, source }),
70
+ )
71
+
72
+ // 4. For ordinal qualifiers, keep only the matching position(s) and zero
73
+ // out the rest.
74
+ if (target.qualifier && ORDINAL_QUALIFIERS.has(target.qualifier)) {
75
+ scored = pickByOrdinal(scored, target.qualifier)
76
+ }
77
+
78
+ // 5. Sort: highest score first; stable beyond that.
79
+ scored.sort((a, b) => b.score - a.score)
80
+ return scored
81
+ }
82
+
83
+ // ─── isAmbiguous ──────────────────────────────────────────────────────────────
84
+ //
85
+ // Returns true when the top two candidates are within `threshold` of each
86
+ // other in score AND both are above 0.3 — meaning we have two equally good
87
+ // guesses. This is the signal for "ask the user to pick" (exit code 4).
88
+
89
+ export function isAmbiguous(candidates, threshold = 0.15) {
90
+ if (!Array.isArray(candidates) || candidates.length < 2) return false
91
+ const [a, b] = candidates
92
+ if (a.score < 0.3) return false
93
+ return Math.abs(a.score - b.score) <= threshold
94
+ }
95
+
96
+ // ─── findCssRule ─────────────────────────────────────────────────────────────
97
+ //
98
+ // Scan a CSS source string for a rule whose selector targets the given DOM
99
+ // element (by class chain) and contains a declaration for `property`. Returns
100
+ // the byte offsets of the rule, the declaration, and the current value — so
101
+ // the caller can splice in a new value while preserving the rest of the file.
102
+ //
103
+ // Strategy: walk top-level rules; for each, check if the selector "matches"
104
+ // the element's class set / tag; if so, look for the property declaration.
105
+ // Match is deliberately loose: any class in the selector must be in the
106
+ // element's class set, OR the selector must include the element's tag name.
107
+
108
+ export function findCssRule(css, classList, tag, property, blockOffset = 0) {
109
+ if (!css || !property) return null
110
+ const rules = walkTopLevelRules(css)
111
+ // Iterate in reverse — later rules have the same or higher specificity
112
+ // (cascade-wise the last one wins on ties). We want the one whose value
113
+ // would actually be applied.
114
+ for (let i = rules.length - 1; i >= 0; i--) {
115
+ const rule = rules[i]
116
+ if (!selectorMatches(rule.selector, classList, tag)) continue
117
+ const decl = findDeclaration(rule.body, rule.bodyStart, property)
118
+ if (decl) {
119
+ return {
120
+ selector: rule.selector,
121
+ ruleStart: blockOffset + rule.start,
122
+ ruleEnd: blockOffset + rule.end,
123
+ declStart: blockOffset + decl.start,
124
+ declEnd: blockOffset + decl.end,
125
+ currentValue: decl.value,
126
+ blockOffset,
127
+ }
128
+ }
129
+ }
130
+ return null
131
+ }
132
+
133
+ // Find a declaration of `property` inside a rule body. Returns null when not
134
+ // present. Tolerates `!important`. Returned offsets are absolute (i.e. include
135
+ // `bodyStart`) and bracket EXACTLY the value text — no leading whitespace,
136
+ // no trailing terminator.
137
+ export function findDeclaration(body, bodyStart, property) {
138
+ const re = new RegExp(
139
+ `(^|[\\s;{])${escapeRegex(property)}\\s*:\\s*([^;}]+?)(\\s*!important)?\\s*([;}])`,
140
+ 'gi',
141
+ )
142
+ let m
143
+ while ((m = re.exec(body)) !== null) {
144
+ // Compute the offset of m[2] (the value capture) within m[0]. We can't
145
+ // rely on indexOf because m[2] may equal a substring that appears earlier
146
+ // in m[0] — instead, walk past the colon and any whitespace.
147
+ const colonIdx = m[0].indexOf(':')
148
+ let off = colonIdx + 1
149
+ while (off < m[0].length && /\s/.test(m[0][off])) off++
150
+ const valueStart = m.index + off
151
+ const valueEnd = valueStart + m[2].length
152
+ return {
153
+ value: m[2].trim(),
154
+ important: !!m[3],
155
+ start: bodyStart + valueStart,
156
+ end: bodyStart + valueEnd,
157
+ }
158
+ }
159
+ return null
160
+ }
161
+
162
+ // Walk CSS to collect top-level (non-at-rule) rules. Recurses into at-rules so
163
+ // rules inside `@media` are picked up too. Returns objects with absolute
164
+ // `start` / `end` offsets into the source string and an absolute `bodyStart`
165
+ // that points to the byte AFTER the opening brace.
166
+ export function walkTopLevelRules(css) {
167
+ const rules = []
168
+ walk(css, 0, css.length, rules)
169
+ return rules
170
+
171
+ function walk(src, start, end, out) {
172
+ let i = start
173
+ while (i < end) {
174
+ // Skip whitespace and comments
175
+ if (src[i] === '/' && src[i + 1] === '*') {
176
+ const close = src.indexOf('*/', i + 2)
177
+ if (close === -1) return
178
+ i = close + 2
179
+ continue
180
+ }
181
+ if (/\s/.test(src[i])) { i++; continue }
182
+
183
+ // At-rule: recurse into its body if it has one (e.g. @media)
184
+ if (src[i] === '@') {
185
+ const stmt = findStmtEnd(src, i, end)
186
+ if (stmt.kind === 'block') {
187
+ walk(src, stmt.bodyStart + 1, stmt.bodyEnd, out)
188
+ i = stmt.bodyEnd + 1
189
+ } else {
190
+ i = stmt.semiIndex + 1
191
+ }
192
+ continue
193
+ }
194
+
195
+ // Plain rule
196
+ const open = findOpenBrace(src, i, end)
197
+ if (open === -1) return
198
+ const close = findMatchingBrace(src, open)
199
+ if (close === -1) return
200
+ const selector = src.slice(i, open).trim()
201
+ const body = src.slice(open + 1, close)
202
+ out.push({
203
+ selector,
204
+ body,
205
+ start: i,
206
+ end: close + 1,
207
+ bodyStart: open + 1,
208
+ })
209
+ i = close + 1
210
+ }
211
+ }
212
+ }
213
+
214
+ function findOpenBrace(src, start, end) {
215
+ for (let i = start; i < end; i++) {
216
+ if (src[i] === '{') return i
217
+ if (src[i] === ';') return -1
218
+ }
219
+ return -1
220
+ }
221
+
222
+ function findMatchingBrace(src, openIndex) {
223
+ let depth = 1
224
+ for (let i = openIndex + 1; i < src.length; i++) {
225
+ if (src[i] === '/' && src[i + 1] === '*') {
226
+ const close = src.indexOf('*/', i + 2)
227
+ if (close === -1) return -1
228
+ i = close + 1
229
+ continue
230
+ }
231
+ if (src[i] === '{') depth++
232
+ else if (src[i] === '}') {
233
+ depth--
234
+ if (depth === 0) return i
235
+ }
236
+ }
237
+ return -1
238
+ }
239
+
240
+ function findStmtEnd(src, start, end) {
241
+ for (let i = start; i < end; i++) {
242
+ if (src[i] === '{') {
243
+ const close = findMatchingBrace(src, i)
244
+ return { kind: 'block', bodyStart: i, bodyEnd: close }
245
+ }
246
+ if (src[i] === ';') return { kind: 'stmt', semiIndex: i }
247
+ }
248
+ return { kind: 'stmt', semiIndex: end - 1 }
249
+ }
250
+
251
+ function escapeRegex(s) {
252
+ return String(s).replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
253
+ }
254
+
255
+ // ─── selectorMatches ─────────────────────────────────────────────────────────
256
+ //
257
+ // Loose match: a selector matches an element when at least one of the
258
+ // selector's class tokens is in the element's class list, OR the selector
259
+ // contains the element's tag name. Multi-selector lists (separated by `,`)
260
+ // are handled by splitting and OR-ing.
261
+
262
+ export function selectorMatches(selector, classList = [], tag = null) {
263
+ const parts = String(selector || '').split(',').map((s) => s.trim()).filter(Boolean)
264
+ for (const part of parts) {
265
+ if (singleSelectorMatches(part, classList, tag)) return true
266
+ }
267
+ return false
268
+ }
269
+
270
+ function singleSelectorMatches(sel, classList, tag) {
271
+ // Pull class tokens from the selector (.foo, .foo.bar, .foo .bar)
272
+ const classTokens = []
273
+ const classRe = /\.([\w-]+)/g
274
+ let m
275
+ while ((m = classRe.exec(sel)) !== null) classTokens.push(m[1])
276
+
277
+ // If selector has any class tokens, require at least one to be in classList.
278
+ if (classTokens.length > 0) {
279
+ const set = new Set(classList)
280
+ if (classTokens.some((c) => set.has(c))) return true
281
+ // No class hit — fall through to tag check (covers `h1.title` where the
282
+ // element has the tag but not the class).
283
+ }
284
+
285
+ // Pull tag name(s) — tokens at start that are bare alpha.
286
+ const tagRe = /(?:^|\s|>)([a-z][a-z0-9]*)/gi
287
+ const tags = []
288
+ let t
289
+ while ((t = tagRe.exec(sel)) !== null) tags.push(t[1].toLowerCase())
290
+ if (tag && tags.includes(String(tag).toLowerCase())) return true
291
+
292
+ return false
293
+ }
294
+
295
+ // ─── DOM scanning helpers ─────────────────────────────────────────────────────
296
+
297
+ function collectDomCandidates($, target, source) {
298
+ const sel = TYPE_TO_SELECTOR[target.type]
299
+ if (!sel) return []
300
+ const out = []
301
+ $(sel).each((idx, el) => {
302
+ const $el = $(el)
303
+ const tag = (el.tagName || el.name || '').toLowerCase()
304
+ const classes = String($el.attr('class') || '').split(/\s+/).filter(Boolean)
305
+ const id = $el.attr('id') || null
306
+ const outerHtml = $.html($el)
307
+ const snippet = outerHtml.length > 120 ? outerHtml.slice(0, 117) + '...' : outerHtml
308
+
309
+ const elementSelector = buildElementSelector(tag, id, classes)
310
+ const line = lineOfSnippet(source, outerHtml)
311
+
312
+ out.push({
313
+ tag,
314
+ classes,
315
+ id,
316
+ idx,
317
+ $el,
318
+ snippet,
319
+ selector: elementSelector,
320
+ line,
321
+ _raw: outerHtml,
322
+ })
323
+ })
324
+ return out
325
+ }
326
+
327
+ function buildElementSelector(tag, id, classes) {
328
+ if (id) return `#${id}`
329
+ if (classes.length > 0) {
330
+ // Pick a characteristic class — prefer ones that look BEM-y or are longer
331
+ // (specificity heuristic).
332
+ const sorted = [...classes].sort((a, b) => b.length - a.length)
333
+ return `${tag}.${sorted[0]}`
334
+ }
335
+ return tag
336
+ }
337
+
338
+ function lineOfSnippet(source, snippet) {
339
+ if (!source || !snippet) return 0
340
+ const head = snippet.slice(0, Math.min(80, snippet.length))
341
+ const i = source.indexOf(head)
342
+ if (i < 0) return 0
343
+ let line = 1
344
+ for (let j = 0; j < i; j++) {
345
+ if (source.charCodeAt(j) === 10) line++
346
+ }
347
+ return line
348
+ }
349
+
350
+ // ─── Scoring ─────────────────────────────────────────────────────────────────
351
+
352
+ function scoreCandidates(candidates, target, $) {
353
+ const qualifier = target.qualifier
354
+ const isOrdinal = qualifier && ORDINAL_QUALIFIERS.has(qualifier)
355
+ const hints = (qualifier && QUALIFIER_HINTS[qualifier]) || null
356
+
357
+ return candidates.map((c) => {
358
+ let score = 0.2 // base — type match alone
359
+
360
+ if (hints) {
361
+ const direct = c.classes.some((cls) =>
362
+ hints.some((h) => cls.toLowerCase().includes(h)),
363
+ )
364
+ if (direct) {
365
+ score += 0.5
366
+ } else if (hasAncestorMatchingHint($, c.$el, hints)) {
367
+ score += 0.3
368
+ }
369
+ } else if (isOrdinal) {
370
+ // Ordinal scoring is handled in pickByOrdinal; here we just make sure
371
+ // every candidate is in play by bumping score slightly so `isAmbiguous`
372
+ // isn't tripped before pickByOrdinal narrows.
373
+ score += 0.4
374
+ } else {
375
+ // No qualifier — small bonus when there's only a single match (handled
376
+ // outside, but we precompute by tagging a uniqueness bump later).
377
+ score += 0.0
378
+ }
379
+
380
+ // Heading hierarchy bonus: when target.type is 'heading', prefer h1 over
381
+ // h2/h3 unless qualifier overrides.
382
+ if (target.type === 'heading' && !qualifier) {
383
+ if (c.tag === 'h1') score += 0.1
384
+ }
385
+
386
+ return { ...c, score }
387
+ })
388
+ }
389
+
390
+ function hasAncestorMatchingHint($, $el, hints) {
391
+ let cur = $el.parent()
392
+ for (let i = 0; i < 8 && cur && cur.length; i++) {
393
+ const cls = String(cur.attr('class') || '').toLowerCase()
394
+ const tag = String((cur[0] && (cur[0].tagName || cur[0].name)) || '').toLowerCase()
395
+ for (const h of hints) {
396
+ if (cls.includes(h) || tag === h) return true
397
+ }
398
+ cur = cur.parent()
399
+ }
400
+ return false
401
+ }
402
+
403
+ function pickByOrdinal(scored, qualifier) {
404
+ if (scored.length === 0) return scored
405
+ const positions = {
406
+ first: 0,
407
+ second: 1,
408
+ third: 2,
409
+ last: scored.length - 1,
410
+ }
411
+ const idx = positions[qualifier]
412
+ if (idx === undefined) return scored
413
+ if (idx < 0 || idx >= scored.length) return scored
414
+ return scored.map((c, i) => ({
415
+ ...c,
416
+ score: i === idx ? Math.min(c.score + 0.4, 1) : 0.05,
417
+ }))
418
+ }
419
+
420
+ // ─── annotateApplyTo ─────────────────────────────────────────────────────────
421
+ //
422
+ // Decides where the edit should land for this candidate, given the requested
423
+ // property + action. Adds an `applyTo` field and (when relevant) per-scope
424
+ // metadata. Pure inspection — no edits applied here.
425
+
426
+ function annotateApplyTo(candidate, ctx) {
427
+ const { action, property, value, source } = ctx
428
+
429
+ // Whole-element removal
430
+ if (action === 'remove') {
431
+ return { ...candidate, applyTo: 'remove-element' }
432
+ }
433
+
434
+ // Attribute edits (alt, src, href, id, class, etc.)
435
+ if (property === 'alt' || property === 'src' || property === 'href' || property === 'id' || property === 'class') {
436
+ return { ...candidate, applyTo: 'attr', attrName: property }
437
+ }
438
+
439
+ // Text-content edits
440
+ if (property === 'text') {
441
+ return { ...candidate, applyTo: 'text' }
442
+ }
443
+
444
+ // CSS property edits (font-size, color, background-color, ...)
445
+ if (property) {
446
+ // Prefer modifying an existing CSS rule. The CLI passes the merged CSS
447
+ // (joined from all <style> blocks) along with per-block offsets so we can
448
+ // splice the right block.
449
+ const css = ctx.cssBlocks ? joinBlocks(ctx.cssBlocks) : extractAllCss(source || '')
450
+ const cssBlocks = ctx.cssBlocks || splitStyleBlocks(source || '')
451
+ const cssMatch = findCssRuleAcrossBlocks(cssBlocks, candidate.classes, candidate.tag, property)
452
+ if (cssMatch) {
453
+ return { ...candidate, applyTo: 'css-rule', css: cssMatch }
454
+ }
455
+ return { ...candidate, applyTo: 'inline-style', cssProperty: property }
456
+ }
457
+
458
+ // Fallback: no property — assume text edit when value is text-ish.
459
+ if (value && value.kind === 'text') {
460
+ return { ...candidate, applyTo: 'text' }
461
+ }
462
+ return { ...candidate, applyTo: 'inline-style', cssProperty: property || 'font-size' }
463
+ }
464
+
465
+ // joinBlocks / splitStyleBlocks / extractAllCss are helpers to unify the
466
+ // "multiple <style> blocks" reality with the rule-walker. Each block has its
467
+ // own offset relative to the source file.
468
+
469
+ export function splitStyleBlocks(source) {
470
+ const blocks = []
471
+ if (!source) return blocks
472
+ const patterns = [
473
+ /<style\b[^>]*>([\s\S]*?)<\/style\s*>/gi,
474
+ /\{%\s*style\s*%\}([\s\S]*?)\{%\s*endstyle\s*%\}/gi,
475
+ /\{%\s*stylesheet\s*%\}([\s\S]*?)\{%\s*endstylesheet\s*%\}/gi,
476
+ ]
477
+ for (const re of patterns) {
478
+ let m
479
+ while ((m = re.exec(source)) !== null) {
480
+ // Offset of the inner CSS within the source file
481
+ const innerStart = m.index + m[0].indexOf(m[1])
482
+ blocks.push({ css: m[1], offset: innerStart })
483
+ }
484
+ }
485
+ return blocks.sort((a, b) => a.offset - b.offset)
486
+ }
487
+
488
+ function extractAllCss(source) {
489
+ return splitStyleBlocks(source).map((b) => b.css).join('\n')
490
+ }
491
+
492
+ function joinBlocks(blocks) {
493
+ return blocks.map((b) => b.css).join('\n')
494
+ }
495
+
496
+ // Try each style block in order; first match wins. The blocks are passed to
497
+ // findCssRule with their absolute offset so the returned rule offsets are
498
+ // relative to the source file (not the block).
499
+ export function findCssRuleAcrossBlocks(blocks, classList, tag, property) {
500
+ // Prefer the LAST matching rule across all blocks (cascade winner).
501
+ let best = null
502
+ for (const block of blocks) {
503
+ const match = findCssRule(block.css, classList, tag, property, block.offset)
504
+ if (match) best = match // keep updating to find the latest match
505
+ }
506
+ return best
507
+ }