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,656 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// tweak.mjs — sb-tweak CLI. Reads a built HTML/Liquid file, parses a natural-
|
|
3
|
+
// language tweak request, locates the targeted element, applies a single
|
|
4
|
+
// surgical edit, optionally invokes sb-validate-render, and emits a summarized
|
|
5
|
+
// diff as JSON to stdout.
|
|
6
|
+
//
|
|
7
|
+
// Outputs JSON to stdout AND writes tweak.json into --output-dir.
|
|
8
|
+
// Exit codes: 0=ok, 1=script error, 2=invalid args, 3=validation failed,
|
|
9
|
+
// 4=disambiguation needed (low confidence OR multiple candidates).
|
|
10
|
+
|
|
11
|
+
import { parseArgs } from 'node:util'
|
|
12
|
+
import { mkdir, writeFile, readFile, access } from 'node:fs/promises'
|
|
13
|
+
import { join, resolve, dirname } from 'node:path'
|
|
14
|
+
import { fileURLToPath } from 'node:url'
|
|
15
|
+
import { spawnSync } from 'node:child_process'
|
|
16
|
+
|
|
17
|
+
import { parseIntent } from './lib/intent-parser.mjs'
|
|
18
|
+
import {
|
|
19
|
+
locateCandidates,
|
|
20
|
+
isAmbiguous,
|
|
21
|
+
splitStyleBlocks,
|
|
22
|
+
findCssRuleAcrossBlocks,
|
|
23
|
+
} from './lib/element-locator.mjs'
|
|
24
|
+
import {
|
|
25
|
+
summarizeChanges,
|
|
26
|
+
computeLineFromOffset,
|
|
27
|
+
diffLines,
|
|
28
|
+
} from './lib/diff-summarizer.mjs'
|
|
29
|
+
|
|
30
|
+
const HERE = dirname(fileURLToPath(import.meta.url))
|
|
31
|
+
const SKILL_ROOT = resolve(HERE, '..')
|
|
32
|
+
|
|
33
|
+
const HELP = `
|
|
34
|
+
tweak.mjs — Edit a built HTML/Liquid file from a natural-language request.
|
|
35
|
+
|
|
36
|
+
Required:
|
|
37
|
+
--file <path> HTML or Liquid file to tweak (edited in place by default).
|
|
38
|
+
--request "<pedido>" Natural-language request in PT or EN.
|
|
39
|
+
--output-dir <dir> Directory for tweak.json (and validation artifacts).
|
|
40
|
+
|
|
41
|
+
Optional:
|
|
42
|
+
--target-selector <sel> Bypass the locator and apply the edit to this exact
|
|
43
|
+
CSS selector. Used after a disambiguation round.
|
|
44
|
+
--output-path <path> Write the edited file to this path instead of in-place.
|
|
45
|
+
--no-validate Skip the post-edit sb-validate-render call.
|
|
46
|
+
--preset <name> wp-elementor | shopify-section. Required when --validate.
|
|
47
|
+
--dry-run Show what would change without writing.
|
|
48
|
+
--help Show this message.
|
|
49
|
+
|
|
50
|
+
Exit codes: 0=ok, 1=script error, 2=invalid args,
|
|
51
|
+
3=validation failed, 4=disambiguation needed.
|
|
52
|
+
`
|
|
53
|
+
|
|
54
|
+
function fail(msg, code = 2) {
|
|
55
|
+
process.stderr.write(`[sb-tweak] ${msg}\n`)
|
|
56
|
+
process.exit(code)
|
|
57
|
+
}
|
|
58
|
+
function log(msg) {
|
|
59
|
+
process.stderr.write(`[sb-tweak] ${msg}\n`)
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
const { values } = parseArgs({
|
|
63
|
+
options: {
|
|
64
|
+
file: { type: 'string' },
|
|
65
|
+
request: { type: 'string' },
|
|
66
|
+
'target-selector': { type: 'string' },
|
|
67
|
+
'output-path': { type: 'string' },
|
|
68
|
+
'output-dir': { type: 'string' },
|
|
69
|
+
validate: { type: 'boolean', default: true },
|
|
70
|
+
'no-validate': { type: 'boolean', default: false },
|
|
71
|
+
preset: { type: 'string' },
|
|
72
|
+
'dry-run': { type: 'boolean', default: false },
|
|
73
|
+
help: { type: 'boolean', default: false },
|
|
74
|
+
},
|
|
75
|
+
strict: false,
|
|
76
|
+
})
|
|
77
|
+
|
|
78
|
+
if (values.help) {
|
|
79
|
+
process.stdout.write(HELP)
|
|
80
|
+
process.exit(0)
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
if (!values.file) fail('missing --file')
|
|
84
|
+
if (!values.request) fail('missing --request')
|
|
85
|
+
if (!values['output-dir']) fail('missing --output-dir')
|
|
86
|
+
|
|
87
|
+
const FILE = resolve(values.file)
|
|
88
|
+
const REQUEST = String(values.request)
|
|
89
|
+
const OUTPUT_DIR = resolve(values['output-dir'])
|
|
90
|
+
const OUTPUT_PATH = values['output-path'] ? resolve(values['output-path']) : FILE
|
|
91
|
+
const TARGET_SELECTOR = values['target-selector'] || null
|
|
92
|
+
const DRY_RUN = !!values['dry-run']
|
|
93
|
+
const SHOULD_VALIDATE = values['no-validate'] ? false : values.validate !== false
|
|
94
|
+
const PRESET = values.preset || null
|
|
95
|
+
|
|
96
|
+
if (SHOULD_VALIDATE && !PRESET) {
|
|
97
|
+
fail('--preset is required when validation is enabled (use --no-validate to skip)')
|
|
98
|
+
}
|
|
99
|
+
const VALID_PRESETS = new Set(['wp-elementor', 'shopify-section'])
|
|
100
|
+
if (PRESET && !VALID_PRESETS.has(PRESET)) {
|
|
101
|
+
fail(`--preset must be one of: ${[...VALID_PRESETS].join(', ')} (got "${PRESET}")`)
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
async function exists(path) {
|
|
105
|
+
try {
|
|
106
|
+
await access(path)
|
|
107
|
+
return true
|
|
108
|
+
} catch {
|
|
109
|
+
return false
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
// Liquid → HTML-ish so cheerio can parse markup that contains {{ ... }} / {% ... %}.
|
|
114
|
+
function liquidToHtmlish(source) {
|
|
115
|
+
return source
|
|
116
|
+
.replace(/\{\{[\s\S]*?\}\}/g, ' ')
|
|
117
|
+
.replace(/\{%[\s\S]*?%\}/g, ' ')
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
async function emitDisambiguation(payload, code = 4) {
|
|
121
|
+
const json = {
|
|
122
|
+
needsClarification: true,
|
|
123
|
+
file: FILE,
|
|
124
|
+
request: REQUEST,
|
|
125
|
+
...payload,
|
|
126
|
+
}
|
|
127
|
+
await mkdir(OUTPUT_DIR, { recursive: true })
|
|
128
|
+
await writeFile(join(OUTPUT_DIR, 'tweak.json'), JSON.stringify(json, null, 2), 'utf8')
|
|
129
|
+
process.stdout.write(JSON.stringify(json))
|
|
130
|
+
process.stdout.write('\n')
|
|
131
|
+
process.exit(code)
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
async function emitOk(payload) {
|
|
135
|
+
const json = {
|
|
136
|
+
needsClarification: false,
|
|
137
|
+
file: FILE,
|
|
138
|
+
request: REQUEST,
|
|
139
|
+
...payload,
|
|
140
|
+
}
|
|
141
|
+
await mkdir(OUTPUT_DIR, { recursive: true })
|
|
142
|
+
await writeFile(join(OUTPUT_DIR, 'tweak.json'), JSON.stringify(json, null, 2), 'utf8')
|
|
143
|
+
process.stdout.write(JSON.stringify(json))
|
|
144
|
+
process.stdout.write('\n')
|
|
145
|
+
process.exit(0)
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
// ─── Edit application helpers ────────────────────────────────────────────────
|
|
149
|
+
|
|
150
|
+
function applyCssRuleEdit(source, css, newValue) {
|
|
151
|
+
// css = { declStart, declEnd, currentValue, ... }; offsets are absolute in
|
|
152
|
+
// `source` and bracket EXACTLY the value text (no leading space, no
|
|
153
|
+
// trailing terminator). Splice the new value in.
|
|
154
|
+
if (!css || css.declStart == null || css.declEnd == null) return null
|
|
155
|
+
const before = source.slice(0, css.declStart)
|
|
156
|
+
const after = source.slice(css.declEnd)
|
|
157
|
+
return before + String(newValue) + after
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
function readInlineStyleProperty(styleAttr, property) {
|
|
161
|
+
if (!styleAttr) return null
|
|
162
|
+
const re = new RegExp(`(?:^|;)\\s*${escapeRegex(property)}\\s*:\\s*([^;]+)`, 'i')
|
|
163
|
+
const m = re.exec(styleAttr)
|
|
164
|
+
return m ? m[1].trim() : null
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
function setInlineStyleProperty(styleAttr, property, newValue) {
|
|
168
|
+
const cur = String(styleAttr || '')
|
|
169
|
+
const re = new RegExp(`(^|;)\\s*${escapeRegex(property)}\\s*:\\s*[^;]*`, 'i')
|
|
170
|
+
if (re.test(cur)) {
|
|
171
|
+
return cur.replace(re, (_m, sep) => `${sep || ''}${sep ? ' ' : ''}${property}: ${newValue}`)
|
|
172
|
+
}
|
|
173
|
+
if (cur.trim().length === 0) return `${property}: ${newValue}`
|
|
174
|
+
return `${cur.replace(/;\s*$/, '')}; ${property}: ${newValue}`
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
function escapeRegex(s) {
|
|
178
|
+
return String(s).replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
function formatCssValue(value, property) {
|
|
182
|
+
if (!value) return ''
|
|
183
|
+
if (value.kind === 'pixels') return `${value.parsed}px`
|
|
184
|
+
if (value.kind === 'length') return `${value.parsed.value}${value.parsed.unit}`
|
|
185
|
+
if (value.kind === 'color') return String(value.parsed)
|
|
186
|
+
if (value.kind === 'number') {
|
|
187
|
+
// For numeric values targeting font-weight, leave as-is. For font-size,
|
|
188
|
+
// assume pixels.
|
|
189
|
+
if (property === 'font-weight') return String(value.parsed)
|
|
190
|
+
if (property === 'font-size' || property === 'width' || property === 'height') {
|
|
191
|
+
return `${value.parsed}px`
|
|
192
|
+
}
|
|
193
|
+
return String(value.parsed)
|
|
194
|
+
}
|
|
195
|
+
if (value.kind === 'text') return String(value.parsed)
|
|
196
|
+
if (value.kind === 'url') return String(value.parsed)
|
|
197
|
+
return String(value.parsed ?? value.raw ?? '')
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
// ─── Validation invocation (sb-validate-render) ──────────────────────────────
|
|
201
|
+
|
|
202
|
+
function findValidateRenderScript() {
|
|
203
|
+
// sb-tweak lives at .claude/skills/sb-tweak/. sb-validate-render lives at
|
|
204
|
+
// .claude/skills/sb-validate-render/. We resolve sibling-style.
|
|
205
|
+
const sibling = resolve(SKILL_ROOT, '..', 'sb-validate-render', 'scripts', 'validate-render.mjs')
|
|
206
|
+
return sibling
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
async function runValidateRender({ file, preset, outputDir }) {
|
|
210
|
+
const script = findValidateRenderScript()
|
|
211
|
+
if (!(await exists(script))) {
|
|
212
|
+
log(`sb-validate-render not found at ${script} — skipping validation`)
|
|
213
|
+
return null
|
|
214
|
+
}
|
|
215
|
+
const args = [
|
|
216
|
+
script,
|
|
217
|
+
'--file', file,
|
|
218
|
+
'--preset', preset,
|
|
219
|
+
'--output-dir', outputDir,
|
|
220
|
+
]
|
|
221
|
+
log(`invoking sb-validate-render: node ${args.join(' ')}`)
|
|
222
|
+
const r = spawnSync('node', args, { encoding: 'utf8' })
|
|
223
|
+
if (r.error) {
|
|
224
|
+
log(`validate-render spawn error: ${r.error.message}`)
|
|
225
|
+
return { passed: false, error: r.error.message, exitCode: 1 }
|
|
226
|
+
}
|
|
227
|
+
let parsed = null
|
|
228
|
+
if (r.stdout) {
|
|
229
|
+
try {
|
|
230
|
+
parsed = JSON.parse(r.stdout.trim().split('\n').filter(Boolean).pop())
|
|
231
|
+
} catch {
|
|
232
|
+
// ignore — fall through to raw stdout
|
|
233
|
+
}
|
|
234
|
+
}
|
|
235
|
+
return {
|
|
236
|
+
passed: r.status === 0,
|
|
237
|
+
exitCode: r.status,
|
|
238
|
+
stderr: r.stderr,
|
|
239
|
+
report: parsed,
|
|
240
|
+
screenshot: parsed?.screenshot || join(outputDir, 'screenshot.png'),
|
|
241
|
+
warnings: parsed?.warnings || [],
|
|
242
|
+
}
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
// ─── Main ────────────────────────────────────────────────────────────────────
|
|
246
|
+
|
|
247
|
+
async function main() {
|
|
248
|
+
if (!(await exists(FILE))) fail(`--file not found: ${FILE}`, 2)
|
|
249
|
+
await mkdir(OUTPUT_DIR, { recursive: true })
|
|
250
|
+
|
|
251
|
+
// 1. Parse intent
|
|
252
|
+
const intent = parseIntent(REQUEST)
|
|
253
|
+
log(`intent: action=${intent.action} target=${JSON.stringify(intent.target)} property=${intent.property} value=${intent.value?.raw} confidence=${intent.confidence} (${intent.language})`)
|
|
254
|
+
|
|
255
|
+
if (intent.language === 'unknown') {
|
|
256
|
+
return emitDisambiguation({
|
|
257
|
+
reason: 'language-unsupported',
|
|
258
|
+
hint: 'Only Portuguese (PT) and English (EN) are supported. Please rephrase.',
|
|
259
|
+
intent,
|
|
260
|
+
})
|
|
261
|
+
}
|
|
262
|
+
if (intent.confidence < 0.7) {
|
|
263
|
+
return emitDisambiguation({
|
|
264
|
+
reason: intent.reason || 'low-confidence',
|
|
265
|
+
hint: 'The request could not be parsed confidently. Specify the action verb, target, and value (e.g. "increase the hero title to 32px").',
|
|
266
|
+
intent,
|
|
267
|
+
})
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
// 2. Lazy-load cheerio
|
|
271
|
+
let cheerio
|
|
272
|
+
try {
|
|
273
|
+
cheerio = await import('cheerio')
|
|
274
|
+
} catch (err) {
|
|
275
|
+
process.stderr.write(
|
|
276
|
+
`[sb-tweak] missing dependency 'cheerio': ${err?.message || err}\n` +
|
|
277
|
+
`Install with: npm i cheerio\n`,
|
|
278
|
+
)
|
|
279
|
+
process.exit(1)
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
// 3. Read file
|
|
283
|
+
log(`reading ${FILE}`)
|
|
284
|
+
const source = await readFile(FILE, 'utf8')
|
|
285
|
+
const looksLikeLiquid = /(\{%|\{\{)/.test(source)
|
|
286
|
+
const htmlForParse = looksLikeLiquid ? liquidToHtmlish(source) : source
|
|
287
|
+
const $ = cheerio.load(htmlForParse, { decodeEntities: false })
|
|
288
|
+
|
|
289
|
+
// Pre-extract style blocks once so the locator and the edit applier share
|
|
290
|
+
// the same offsets.
|
|
291
|
+
const styleBlocks = splitStyleBlocks(source)
|
|
292
|
+
|
|
293
|
+
// 4. Locate candidates (or use --target-selector if provided)
|
|
294
|
+
let chosen = null
|
|
295
|
+
let candidates = []
|
|
296
|
+
|
|
297
|
+
if (TARGET_SELECTOR) {
|
|
298
|
+
const $el = $(TARGET_SELECTOR).first()
|
|
299
|
+
if ($el.length === 0) {
|
|
300
|
+
return emitDisambiguation({
|
|
301
|
+
reason: 'no-candidates',
|
|
302
|
+
hint: `--target-selector "${TARGET_SELECTOR}" matched no element. Re-check the selector against the file.`,
|
|
303
|
+
intent,
|
|
304
|
+
})
|
|
305
|
+
}
|
|
306
|
+
chosen = buildCandidateFromElement($, $el, source, intent, styleBlocks)
|
|
307
|
+
} else {
|
|
308
|
+
candidates = locateCandidates(intent, $, source)
|
|
309
|
+
// Inject CSS-block context for the css-rule scope (the locator does this
|
|
310
|
+
// via cssBlocks if passed; here we re-run rule discovery against the
|
|
311
|
+
// shared styleBlocks for consistency).
|
|
312
|
+
candidates = candidates.map((c) => addCssRuleHint(c, styleBlocks, intent))
|
|
313
|
+
if (candidates.length === 0) {
|
|
314
|
+
return emitDisambiguation({
|
|
315
|
+
reason: 'no-candidates',
|
|
316
|
+
hint: `No element of type "${intent.target.type}" was found in the file.`,
|
|
317
|
+
intent,
|
|
318
|
+
})
|
|
319
|
+
}
|
|
320
|
+
if (isAmbiguous(candidates)) {
|
|
321
|
+
return emitDisambiguation({
|
|
322
|
+
reason: 'multiple-candidates',
|
|
323
|
+
hint: 're-invoke with --target-selector "<chosen-selector>" using one of the candidates below',
|
|
324
|
+
intent,
|
|
325
|
+
candidates: candidates.slice(0, 5).map(slimCandidate),
|
|
326
|
+
})
|
|
327
|
+
}
|
|
328
|
+
chosen = candidates[0]
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
log(`chosen: ${chosen.selector} (applyTo=${chosen.applyTo})`)
|
|
332
|
+
|
|
333
|
+
// 5. Compute the edit + apply (or simulate) it.
|
|
334
|
+
const result = applyEdit({
|
|
335
|
+
source,
|
|
336
|
+
$,
|
|
337
|
+
chosen,
|
|
338
|
+
intent,
|
|
339
|
+
cheerioApi: cheerio,
|
|
340
|
+
styleBlocks,
|
|
341
|
+
})
|
|
342
|
+
|
|
343
|
+
if (!result.changed) {
|
|
344
|
+
// Idempotent path — already in requested state.
|
|
345
|
+
return emitOk({
|
|
346
|
+
intent,
|
|
347
|
+
selector: chosen.selector,
|
|
348
|
+
changes: [],
|
|
349
|
+
diff: 'no changes (already applied)',
|
|
350
|
+
validation: null,
|
|
351
|
+
outputPath: OUTPUT_PATH,
|
|
352
|
+
dryRun: DRY_RUN,
|
|
353
|
+
candidates: TARGET_SELECTOR ? undefined : candidates.slice(0, 5).map(slimCandidate),
|
|
354
|
+
})
|
|
355
|
+
}
|
|
356
|
+
|
|
357
|
+
// 6. Persist (unless dry-run).
|
|
358
|
+
if (!DRY_RUN) {
|
|
359
|
+
if (OUTPUT_PATH !== FILE) await mkdir(dirname(OUTPUT_PATH), { recursive: true })
|
|
360
|
+
await writeFile(OUTPUT_PATH, result.nextSource, 'utf8')
|
|
361
|
+
log(`wrote ${OUTPUT_PATH} (${result.nextSource.length} bytes)`)
|
|
362
|
+
} else {
|
|
363
|
+
log(`dry-run: would write ${OUTPUT_PATH} (${result.nextSource.length} bytes)`)
|
|
364
|
+
}
|
|
365
|
+
|
|
366
|
+
// 7. Optionally invoke validate-render.
|
|
367
|
+
let validation = null
|
|
368
|
+
if (SHOULD_VALIDATE && !DRY_RUN) {
|
|
369
|
+
validation = await runValidateRender({
|
|
370
|
+
file: OUTPUT_PATH,
|
|
371
|
+
preset: PRESET,
|
|
372
|
+
outputDir: OUTPUT_DIR,
|
|
373
|
+
})
|
|
374
|
+
if (validation && validation.passed === false) {
|
|
375
|
+
// Revert and escalate.
|
|
376
|
+
log(`validation failed (exit ${validation.exitCode}) — reverting ${OUTPUT_PATH}`)
|
|
377
|
+
await writeFile(OUTPUT_PATH, source, 'utf8')
|
|
378
|
+
const json = {
|
|
379
|
+
needsClarification: false,
|
|
380
|
+
file: FILE,
|
|
381
|
+
request: REQUEST,
|
|
382
|
+
intent,
|
|
383
|
+
selector: chosen.selector,
|
|
384
|
+
changes: result.changes,
|
|
385
|
+
diff: summarizeChanges(result.changes),
|
|
386
|
+
validation,
|
|
387
|
+
reverted: true,
|
|
388
|
+
outputPath: OUTPUT_PATH,
|
|
389
|
+
dryRun: false,
|
|
390
|
+
}
|
|
391
|
+
await writeFile(join(OUTPUT_DIR, 'tweak.json'), JSON.stringify(json, null, 2), 'utf8')
|
|
392
|
+
process.stdout.write(JSON.stringify(json))
|
|
393
|
+
process.stdout.write('\n')
|
|
394
|
+
process.exit(3)
|
|
395
|
+
}
|
|
396
|
+
}
|
|
397
|
+
|
|
398
|
+
return emitOk({
|
|
399
|
+
intent,
|
|
400
|
+
selector: chosen.selector,
|
|
401
|
+
changes: result.changes,
|
|
402
|
+
diff: summarizeChanges(result.changes),
|
|
403
|
+
validation,
|
|
404
|
+
outputPath: OUTPUT_PATH,
|
|
405
|
+
dryRun: DRY_RUN,
|
|
406
|
+
})
|
|
407
|
+
}
|
|
408
|
+
|
|
409
|
+
// ─── Edit dispatch ───────────────────────────────────────────────────────────
|
|
410
|
+
|
|
411
|
+
function applyEdit({ source, $, chosen, intent, cheerioApi, styleBlocks }) {
|
|
412
|
+
const { applyTo } = chosen
|
|
413
|
+
const value = intent.value
|
|
414
|
+
const property = intent.property
|
|
415
|
+
|
|
416
|
+
if (applyTo === 'remove-element') {
|
|
417
|
+
return applyRemoveElement({ source, $, chosen, cheerioApi })
|
|
418
|
+
}
|
|
419
|
+
if (applyTo === 'attr') {
|
|
420
|
+
return applyAttrEdit({ source, $, chosen, intent })
|
|
421
|
+
}
|
|
422
|
+
if (applyTo === 'text') {
|
|
423
|
+
return applyTextEdit({ source, $, chosen, intent })
|
|
424
|
+
}
|
|
425
|
+
if (applyTo === 'css-rule') {
|
|
426
|
+
return applyCssRuleScope({ source, chosen, intent })
|
|
427
|
+
}
|
|
428
|
+
if (applyTo === 'inline-style') {
|
|
429
|
+
return applyInlineStyleEdit({ source, $, chosen, intent })
|
|
430
|
+
}
|
|
431
|
+
return { changed: false, changes: [], nextSource: source }
|
|
432
|
+
}
|
|
433
|
+
|
|
434
|
+
function applyRemoveElement({ source, $, chosen, cheerioApi }) {
|
|
435
|
+
// We can't precisely splice the original source for cheerio-DOM removal
|
|
436
|
+
// without round-tripping through cheerio's serialization (which would
|
|
437
|
+
// reformat the whole file). So we use cheerio's serializer — acceptable
|
|
438
|
+
// since the user explicitly asked to remove a whole element and minor
|
|
439
|
+
// formatting normalization is fine.
|
|
440
|
+
const $el = locateElementByCandidate($, chosen)
|
|
441
|
+
if (!$el || $el.length === 0) {
|
|
442
|
+
return { changed: false, changes: [], nextSource: source }
|
|
443
|
+
}
|
|
444
|
+
$el.remove()
|
|
445
|
+
const nextSource = $.html()
|
|
446
|
+
return {
|
|
447
|
+
changed: true,
|
|
448
|
+
changes: [
|
|
449
|
+
{
|
|
450
|
+
selector: chosen.selector,
|
|
451
|
+
property: 'element',
|
|
452
|
+
before: chosen.snippet,
|
|
453
|
+
after: null,
|
|
454
|
+
scope: 'remove-element',
|
|
455
|
+
line: chosen.line,
|
|
456
|
+
},
|
|
457
|
+
],
|
|
458
|
+
nextSource,
|
|
459
|
+
}
|
|
460
|
+
}
|
|
461
|
+
|
|
462
|
+
function applyAttrEdit({ source, $, chosen, intent }) {
|
|
463
|
+
const $el = locateElementByCandidate($, chosen)
|
|
464
|
+
if (!$el || $el.length === 0) return { changed: false, changes: [], nextSource: source }
|
|
465
|
+
const name = chosen.attrName || intent.property
|
|
466
|
+
const before = $el.attr(name) ?? null
|
|
467
|
+
const after = String(intent.value?.parsed ?? '')
|
|
468
|
+
if (before === after) return { changed: false, changes: [], nextSource: source }
|
|
469
|
+
$el.attr(name, after)
|
|
470
|
+
return {
|
|
471
|
+
changed: true,
|
|
472
|
+
changes: [
|
|
473
|
+
{
|
|
474
|
+
selector: chosen.selector,
|
|
475
|
+
property: name,
|
|
476
|
+
before,
|
|
477
|
+
after,
|
|
478
|
+
scope: 'attr',
|
|
479
|
+
line: chosen.line,
|
|
480
|
+
},
|
|
481
|
+
],
|
|
482
|
+
nextSource: $.html(),
|
|
483
|
+
}
|
|
484
|
+
}
|
|
485
|
+
|
|
486
|
+
function applyTextEdit({ source, $, chosen, intent }) {
|
|
487
|
+
const $el = locateElementByCandidate($, chosen)
|
|
488
|
+
if (!$el || $el.length === 0) return { changed: false, changes: [], nextSource: source }
|
|
489
|
+
const before = $el.text()
|
|
490
|
+
const after = String(intent.value?.parsed ?? '')
|
|
491
|
+
if (before === after) return { changed: false, changes: [], nextSource: source }
|
|
492
|
+
$el.text(after)
|
|
493
|
+
return {
|
|
494
|
+
changed: true,
|
|
495
|
+
changes: [
|
|
496
|
+
{
|
|
497
|
+
selector: chosen.selector,
|
|
498
|
+
property: 'text',
|
|
499
|
+
before,
|
|
500
|
+
after,
|
|
501
|
+
scope: 'text',
|
|
502
|
+
line: chosen.line,
|
|
503
|
+
},
|
|
504
|
+
],
|
|
505
|
+
nextSource: $.html(),
|
|
506
|
+
}
|
|
507
|
+
}
|
|
508
|
+
|
|
509
|
+
function applyCssRuleScope({ source, chosen, intent }) {
|
|
510
|
+
const css = chosen.css
|
|
511
|
+
if (!css) return { changed: false, changes: [], nextSource: source }
|
|
512
|
+
const property = intent.property
|
|
513
|
+
let nextValue = formatCssValue(intent.value, property)
|
|
514
|
+
|
|
515
|
+
// Increase / decrease semantics: when the action is sizing-relative and we
|
|
516
|
+
// have a number, treat it as the new absolute value. (We don't compute
|
|
517
|
+
// relative deltas in V1 — the user states the new value explicitly.)
|
|
518
|
+
if (intent.action === 'increase' || intent.action === 'decrease') {
|
|
519
|
+
if (!nextValue && intent.value?.kind === 'number') nextValue = `${intent.value.parsed}px`
|
|
520
|
+
}
|
|
521
|
+
if (!nextValue) return { changed: false, changes: [], nextSource: source }
|
|
522
|
+
|
|
523
|
+
const currentValue = (css.currentValue || '').trim()
|
|
524
|
+
if (currentValue === nextValue.trim()) {
|
|
525
|
+
return { changed: false, changes: [], nextSource: source }
|
|
526
|
+
}
|
|
527
|
+
const nextSource = applyCssRuleEdit(source, css, nextValue)
|
|
528
|
+
if (!nextSource) return { changed: false, changes: [], nextSource: source }
|
|
529
|
+
const line = computeLineFromOffset(source, css.declStart)
|
|
530
|
+
return {
|
|
531
|
+
changed: true,
|
|
532
|
+
changes: [
|
|
533
|
+
{
|
|
534
|
+
selector: css.selector || chosen.selector,
|
|
535
|
+
property,
|
|
536
|
+
before: currentValue || null,
|
|
537
|
+
after: nextValue,
|
|
538
|
+
scope: 'css-rule',
|
|
539
|
+
line,
|
|
540
|
+
},
|
|
541
|
+
],
|
|
542
|
+
nextSource,
|
|
543
|
+
}
|
|
544
|
+
}
|
|
545
|
+
|
|
546
|
+
function applyInlineStyleEdit({ source, $, chosen, intent }) {
|
|
547
|
+
const $el = locateElementByCandidate($, chosen)
|
|
548
|
+
if (!$el || $el.length === 0) return { changed: false, changes: [], nextSource: source }
|
|
549
|
+
const property = intent.property || chosen.cssProperty
|
|
550
|
+
if (!property) return { changed: false, changes: [], nextSource: source }
|
|
551
|
+
const value = formatCssValue(intent.value, property)
|
|
552
|
+
if (!value) return { changed: false, changes: [], nextSource: source }
|
|
553
|
+
|
|
554
|
+
const cur = $el.attr('style') || ''
|
|
555
|
+
const before = readInlineStyleProperty(cur, property)
|
|
556
|
+
if (before != null && before === value) {
|
|
557
|
+
return { changed: false, changes: [], nextSource: source }
|
|
558
|
+
}
|
|
559
|
+
const next = setInlineStyleProperty(cur, property, value)
|
|
560
|
+
$el.attr('style', next)
|
|
561
|
+
return {
|
|
562
|
+
changed: true,
|
|
563
|
+
changes: [
|
|
564
|
+
{
|
|
565
|
+
selector: chosen.selector,
|
|
566
|
+
property,
|
|
567
|
+
before,
|
|
568
|
+
after: value,
|
|
569
|
+
scope: 'inline-style',
|
|
570
|
+
line: chosen.line,
|
|
571
|
+
},
|
|
572
|
+
],
|
|
573
|
+
nextSource: $.html(),
|
|
574
|
+
}
|
|
575
|
+
}
|
|
576
|
+
|
|
577
|
+
// ─── Utilities ───────────────────────────────────────────────────────────────
|
|
578
|
+
|
|
579
|
+
function locateElementByCandidate($, chosen) {
|
|
580
|
+
// Prefer exact selector lookup when the candidate carries one. When the
|
|
581
|
+
// selector resolves to many elements, fall back to the candidate's stored
|
|
582
|
+
// index (set by collectDomCandidates).
|
|
583
|
+
if (chosen.selector) {
|
|
584
|
+
const all = $(chosen.selector)
|
|
585
|
+
if (all.length === 1) return all
|
|
586
|
+
if (chosen.idx != null && all.length > chosen.idx) return $(all[chosen.idx])
|
|
587
|
+
if (all.length > 0) return all.first()
|
|
588
|
+
}
|
|
589
|
+
return chosen.$el || $()
|
|
590
|
+
}
|
|
591
|
+
|
|
592
|
+
function buildCandidateFromElement($, $el, source, intent, styleBlocks) {
|
|
593
|
+
const tag = ($el[0]?.tagName || $el[0]?.name || '').toLowerCase()
|
|
594
|
+
const classes = String($el.attr('class') || '').split(/\s+/).filter(Boolean)
|
|
595
|
+
const id = $el.attr('id') || null
|
|
596
|
+
const outerHtml = $.html($el)
|
|
597
|
+
const snippet = outerHtml.length > 120 ? outerHtml.slice(0, 117) + '...' : outerHtml
|
|
598
|
+
const selector = id ? `#${id}` : (classes.length ? `${tag}.${classes[0]}` : tag)
|
|
599
|
+
const line = lineOfHtml(source, outerHtml)
|
|
600
|
+
const cand = { tag, classes, id, $el, snippet, selector, line, score: 1, idx: 0 }
|
|
601
|
+
return decideApplyTo(cand, intent, styleBlocks)
|
|
602
|
+
}
|
|
603
|
+
|
|
604
|
+
function lineOfHtml(source, html) {
|
|
605
|
+
const head = String(html || '').slice(0, 80)
|
|
606
|
+
const i = source.indexOf(head)
|
|
607
|
+
if (i < 0) return 0
|
|
608
|
+
let line = 1
|
|
609
|
+
for (let j = 0; j < i; j++) {
|
|
610
|
+
if (source.charCodeAt(j) === 10) line++
|
|
611
|
+
}
|
|
612
|
+
return line
|
|
613
|
+
}
|
|
614
|
+
|
|
615
|
+
function decideApplyTo(cand, intent, styleBlocks) {
|
|
616
|
+
const { action, property, value } = intent
|
|
617
|
+
if (action === 'remove') return { ...cand, applyTo: 'remove-element' }
|
|
618
|
+
if (property === 'alt' || property === 'src' || property === 'href' || property === 'id' || property === 'class') {
|
|
619
|
+
return { ...cand, applyTo: 'attr', attrName: property }
|
|
620
|
+
}
|
|
621
|
+
if (property === 'text') return { ...cand, applyTo: 'text' }
|
|
622
|
+
if (property) {
|
|
623
|
+
const cssMatch = findCssRuleAcrossBlocks(styleBlocks, cand.classes, cand.tag, property)
|
|
624
|
+
if (cssMatch) return { ...cand, applyTo: 'css-rule', css: cssMatch }
|
|
625
|
+
return { ...cand, applyTo: 'inline-style', cssProperty: property }
|
|
626
|
+
}
|
|
627
|
+
if (value && value.kind === 'text') return { ...cand, applyTo: 'text' }
|
|
628
|
+
return { ...cand, applyTo: 'inline-style', cssProperty: 'font-size' }
|
|
629
|
+
}
|
|
630
|
+
|
|
631
|
+
function addCssRuleHint(candidate, styleBlocks, intent) {
|
|
632
|
+
// For css-rule candidates from the locator, ensure the rule offsets match
|
|
633
|
+
// our shared styleBlocks (the locator's own scan also uses splitStyleBlocks
|
|
634
|
+
// but we re-confirm here so a single source of truth is used end-to-end).
|
|
635
|
+
if (candidate.applyTo !== 'css-rule') return candidate
|
|
636
|
+
if (!intent.property) return candidate
|
|
637
|
+
const match = findCssRuleAcrossBlocks(styleBlocks, candidate.classes, candidate.tag, intent.property)
|
|
638
|
+
if (match) return { ...candidate, css: match }
|
|
639
|
+
// Rule disappeared between scans (shouldn't happen) → fall back to inline style.
|
|
640
|
+
return { ...candidate, applyTo: 'inline-style', cssProperty: intent.property, css: undefined }
|
|
641
|
+
}
|
|
642
|
+
|
|
643
|
+
function slimCandidate(c) {
|
|
644
|
+
return {
|
|
645
|
+
selector: c.selector,
|
|
646
|
+
snippet: c.snippet,
|
|
647
|
+
score: Number((c.score ?? 0).toFixed(2)),
|
|
648
|
+
line: c.line ?? 0,
|
|
649
|
+
applyTo: c.applyTo || null,
|
|
650
|
+
}
|
|
651
|
+
}
|
|
652
|
+
|
|
653
|
+
main().catch((err) => {
|
|
654
|
+
process.stderr.write(`[sb-tweak] fatal: ${err?.stack || err}\n`)
|
|
655
|
+
process.exit(1)
|
|
656
|
+
})
|