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