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,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
|
+
}
|