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,324 @@
1
+ // intent-parser.mjs — Parse a natural-language tweak request (PT or EN) into a
2
+ // structured `{action, target, property, value, confidence, language}` object.
3
+ //
4
+ // Pure function, no I/O, no deps. Tested in isolation.
5
+ //
6
+ // The parser is intentionally conservative: when it can't confidently identify
7
+ // every required field (action + target + value-when-needed), it returns a
8
+ // `confidence` below 0.7 so the CLI can exit with code 4 and ask the user to
9
+ // rephrase. It never guesses with high confidence.
10
+ //
11
+ // See ../../references/tweak-patterns.md for the canonical catalog of supported
12
+ // requests. The dictionaries below are derived from that catalog.
13
+
14
+ // ─── Dictionaries ────────────────────────────────────────────────────────────
15
+ //
16
+ // Each list is pre-sorted longest-first so multi-word phrases beat single-word
17
+ // ones (e.g. "cor de fundo" must match before "cor"). Word boundaries are
18
+ // applied at match time so "principal" doesn't accidentally match "main" inside
19
+ // "domain".
20
+
21
+ const ACTION_VERBS = [
22
+ // increase
23
+ { match: /\b(aumenta(?:r)?|aumente|maior|cresce(?:r)?|cresça|increase|enlarge|grow|bigger|larger)\b/i, action: 'increase', lang: 'pt-or-en' },
24
+ // decrease
25
+ { match: /\b(diminui(?:r)?|diminua|menor|encolhe(?:r)?|reduz(?:ir)?|reduza|decrease|reduce|shrink|smaller)\b/i, action: 'decrease', lang: 'pt-or-en' },
26
+ // set / change / replace (broadest — checked AFTER add/remove because "muda" is generic)
27
+ { match: /\b(muda(?:r)?|mude|troca(?:r)?|troque|altera(?:r)?|altere|seta(?:r)?|define(?:r)?|defina|set|change|replace|update|swap)\b/i, action: 'set', lang: 'pt-or-en' },
28
+ // add
29
+ { match: /\b(adiciona(?:r)?|adicione|coloca(?:r)?|coloque|p[oõ]e(?:r)?|insere(?:r)?|insira|add|insert|put)\b/i, action: 'add', lang: 'pt-or-en' },
30
+ // remove
31
+ { match: /\b(remove(?:r)?|remova|tira(?:r)?|tire|apaga(?:r)?|apague|deleta(?:r)?|delete|drop)\b/i, action: 'remove', lang: 'pt-or-en' },
32
+ ]
33
+
34
+ // Order matters: longer / more specific words first. We use Unicode-aware
35
+ // boundaries `(?<![\p{L}\p{N}])` / `(?![\p{L}\p{N}])` with the `u` flag so
36
+ // accented words like "ícone", "último", "rodapé" match at start-of-word
37
+ // (`\b` only sees ASCII word chars and would miss those).
38
+ const TARGET_TYPES = [
39
+ { match: /(?<![\p{L}\p{N}])(subt[ií]tulo|subtitle|subheading)(?![\p{L}\p{N}])/iu, type: 'subheading' },
40
+ { match: /(?<![\p{L}\p{N}])(t[ií]tulo|titulo|title|heading|headline|h1|h2|h3)(?![\p{L}\p{N}])/iu, type: 'heading' },
41
+ { match: /(?<![\p{L}\p{N}])(imagem|image|picture|photo|foto|<?img>?)(?![\p{L}\p{N}])/iu, type: 'image' },
42
+ { match: /(?<![\p{L}\p{N}])(bot[aã]o|botao|button|btn|cta)(?![\p{L}\p{N}])/iu, type: 'button' },
43
+ { match: /(?<![\p{L}\p{N}])(par[aá]grafo|paragrafo|paragraph|copy|description|descri[cç][aã]o)(?![\p{L}\p{N}])/iu, type: 'text' },
44
+ { match: /(?<![\p{L}\p{N}])(link|anchor|[aâ]ncora)(?![\p{L}\p{N}])/iu, type: 'link' },
45
+ { match: /(?<![\p{L}\p{N}])([ií]cone|icone|icon)(?![\p{L}\p{N}])/iu, type: 'icon' },
46
+ { match: /(?<![\p{L}\p{N}])(fundo|background|bg)(?![\p{L}\p{N}])/iu, type: 'background' },
47
+ { match: /(?<![\p{L}\p{N}])(se[cç][aã]o|secao|section)(?![\p{L}\p{N}])/iu, type: 'section' },
48
+ { match: /(?<![\p{L}\p{N}])texto(?![\p{L}\p{N}])/iu, type: 'text' },
49
+ { match: /(?<![\p{L}\p{N}])text(?![\p{L}\p{N}])/iu, type: 'text' },
50
+ ]
51
+
52
+ const QUALIFIERS = [
53
+ { match: /\b(hero|principal|main)\b/i, qualifier: 'hero' },
54
+ { match: /\b(footer|rodap[eé])\b/i, qualifier: 'footer' },
55
+ { match: /\b(header|topo|cabe[cç]alho|cabecalho)\b/i, qualifier: 'header' },
56
+ // Worded ordinals first (use \b for word boundaries)
57
+ { match: /\b(primeir[oa]|first|1st)\b/i, qualifier: 'first' },
58
+ { match: /\b(segund[oa]|second|2nd)\b/i, qualifier: 'second' },
59
+ { match: /\b(terceir[oa]|third|3rd)\b/i, qualifier: 'third' },
60
+ { match: /\b([uú]ltim[oa]|last|final)\b/i, qualifier: 'last' },
61
+ // Numeric ordinals with non-word suffix chars (º, °, o, a) — \b doesn't work
62
+ // around these so we use explicit boundaries.
63
+ { match: /(?:^|[^\w])1\s*[º°oa](?=$|[^\w])/i, qualifier: 'first' },
64
+ { match: /(?:^|[^\w])2\s*[º°oa](?=$|[^\w])/i, qualifier: 'second' },
65
+ { match: /(?:^|[^\w])3\s*[º°oa](?=$|[^\w])/i, qualifier: 'third' },
66
+ ]
67
+
68
+ // Property phrases — checked from most-specific to most-generic so that
69
+ // "cor de fundo" beats "cor" and "background-color" beats "background".
70
+ const PROPERTY_PHRASES = [
71
+ { match: /\b(cor\s+(?:de|do)\s+fundo|background\s*[- ]?\s*color|bg\s*[- ]?\s*color)\b/i, property: 'background-color' },
72
+ { match: /\b(cor\s+do\s+texto|text\s*[- ]?\s*color)\b/i, property: 'color' },
73
+ { match: /\b(font\s*[- ]?\s*size|tamanho\s+(?:da\s+)?fonte)\b/i, property: 'font-size' },
74
+ { match: /\b(font\s*[- ]?\s*weight|peso\s+(?:da\s+)?fonte)\b/i, property: 'font-weight' },
75
+ { match: /\bfundo\b/i, property: 'background-color' }, // "muda o fundo do hero"
76
+ { match: /\bbackground\b/i, property: 'background-color' },
77
+ { match: /\bcor\b/i, property: 'color' },
78
+ { match: /\bcolor\b/i, property: 'color' },
79
+ { match: /\b(altura|height)\b/i, property: 'height' },
80
+ { match: /\b(largura|width)\b/i, property: 'width' },
81
+ { match: /\b(espa[cç]amento|padding)\b/i, property: 'padding' },
82
+ { match: /\b(margem|margin)\b/i, property: 'margin' },
83
+ { match: /\bweight\b/i, property: 'font-weight' },
84
+ { match: /\bsize\b/i, property: 'font-size' },
85
+ { match: /\b(tamanho|size)\b/i, property: 'font-size' },
86
+ { match: /\balt\b/i, property: 'alt' },
87
+ { match: /\bsrc\b/i, property: 'src' },
88
+ { match: /\bhref\b/i, property: 'href' },
89
+ { match: /\b(texto|text|content|conte[uú]do)\b/i, property: 'text' },
90
+ ]
91
+
92
+ const COLOR_NAMES = new Set([
93
+ 'red', 'blue', 'green', 'white', 'black', 'gray', 'grey', 'yellow', 'orange',
94
+ 'purple', 'pink', 'brown', 'cyan', 'magenta', 'lime', 'navy', 'teal',
95
+ 'maroon', 'olive', 'silver', 'gold', 'transparent',
96
+ // PT
97
+ 'vermelho', 'azul', 'verde', 'branco', 'preto', 'cinza', 'amarelo', 'laranja',
98
+ 'roxo', 'rosa', 'marrom', 'ouro', 'prata', 'transparente',
99
+ ])
100
+
101
+ // Indicator words that confirm the language. The dictionaries are deliberately
102
+ // language-exclusive — words that appear in both languages (hero, footer,
103
+ // header, color, image when stripped of context, etc.) are dropped to keep
104
+ // the signal sharp. Coarse heuristic: count hits per language and pick the
105
+ // majority.
106
+ const PT_MARKERS = /\b(de|da|do|para|pra|na|no|nas|nos|uma|com|que|essa|esse|aquele|este|esta|aquela|t[ií]tulo|titulo|imagem|bot[aã]o|botao|fundo|cor|tamanho|peso|altura|largura|primeir|segund|terceir|[uú]ltim|principal|rodap|cabe[cç]alho|cabecalho|pra)\b/i
107
+ const EN_MARKERS = /\b(the|to|of|on|in|with|that|this|those|these|title|button|background|color|size|weight|height|width|first|second|third|last|hero|footer|header|alt|src|href|change|increase|decrease|set|add|remove)\b/i
108
+
109
+ // ─── Public API ──────────────────────────────────────────────────────────────
110
+
111
+ export function parseIntent(rawRequest) {
112
+ const request = String(rawRequest || '').trim()
113
+ if (!request) {
114
+ return zero('empty-request')
115
+ }
116
+
117
+ const language = detectLanguage(request)
118
+ if (language === 'unknown') {
119
+ return {
120
+ action: null, target: null, property: null, value: null,
121
+ confidence: 0.1,
122
+ language: 'unknown',
123
+ reason: 'language-unsupported',
124
+ raw: request,
125
+ }
126
+ }
127
+
128
+ const action = matchFirst(ACTION_VERBS, request, 'action')
129
+ const targetType = matchFirst(TARGET_TYPES, request, 'type')
130
+ const qualifier = matchFirst(QUALIFIERS, request, 'qualifier')
131
+ const property = matchFirst(PROPERTY_PHRASES, request, 'property')
132
+ const value = extractValue(request)
133
+
134
+ const target = targetType ? { type: targetType } : null
135
+ if (target && qualifier) target.qualifier = qualifier
136
+
137
+ // Property defaulting: when a target type is identified but no explicit
138
+ // property phrase is found, derive a sensible default from action + value.
139
+ let resolvedProperty = property
140
+ if (target && !resolvedProperty) {
141
+ resolvedProperty = defaultProperty({ action, target, value })
142
+ }
143
+
144
+ // Compute confidence.
145
+ let confidence = 0
146
+ if (action) confidence += 0.3
147
+ if (target) confidence += 0.3
148
+ if (resolvedProperty) confidence += 0.15
149
+ if (qualifier || target?.qualifier) confidence += 0.05
150
+ // Value scoring: 'remove' doesn't need a value; 'set'/'add'/'increase'/
151
+ // 'decrease' do. When action is missing entirely, we award no value bonus
152
+ // — the request is too underspecified to call confident.
153
+ const needsValue = action && action !== 'remove'
154
+ if (action && !needsValue) confidence += 0.2
155
+ else if (action && value) confidence += 0.2
156
+
157
+ // Language penalty: weak language signal slightly reduces confidence.
158
+ if (language === 'mixed') confidence -= 0.05
159
+
160
+ // Sanity caps.
161
+ if (confidence > 1) confidence = 1
162
+ if (confidence < 0) confidence = 0
163
+
164
+ // Reason for low confidence (helps the user fix the rephrasing).
165
+ let reason = null
166
+ if (!action) reason = 'no-action-verb'
167
+ else if (!target) reason = 'no-target-type'
168
+ else if (needsValue && !value) reason = 'no-value'
169
+
170
+ return {
171
+ action: action || null,
172
+ target,
173
+ property: resolvedProperty || null,
174
+ value,
175
+ confidence: Number(confidence.toFixed(2)),
176
+ language,
177
+ reason,
178
+ raw: request,
179
+ }
180
+ }
181
+
182
+ // ─── Helpers ─────────────────────────────────────────────────────────────────
183
+
184
+ function zero(reason) {
185
+ return {
186
+ action: null, target: null, property: null, value: null,
187
+ confidence: 0, language: 'unknown', reason, raw: '',
188
+ }
189
+ }
190
+
191
+ function matchFirst(table, str, key) {
192
+ for (const entry of table) {
193
+ if (entry.match.test(str)) return entry[key]
194
+ }
195
+ return null
196
+ }
197
+
198
+ function detectLanguage(str) {
199
+ const ptCount = countMatches(str, PT_MARKERS)
200
+ const enCount = countMatches(str, EN_MARKERS)
201
+ if (ptCount === 0 && enCount === 0) {
202
+ // Fallback: if any unique action verb is present, lean that way.
203
+ if (/\b(aumenta|diminui|muda|troca|adiciona|tira|aumentar|diminuir|mudar|trocar|adicionar|remover|tirar)\b/i.test(str)) return 'pt'
204
+ if (/\b(increase|decrease|change|replace|enlarge|shrink|reduce)\b/i.test(str)) return 'en'
205
+ return 'unknown'
206
+ }
207
+ if (ptCount > enCount) return 'pt'
208
+ if (enCount > ptCount) return 'en'
209
+ return 'mixed' // tied — caller treats as low-confidence-leaning
210
+ }
211
+
212
+ function countMatches(str, re) {
213
+ // Make a global flag clone so we can iterate.
214
+ const g = new RegExp(re.source, re.flags.includes('g') ? re.flags : re.flags + 'g')
215
+ let n = 0
216
+ while (g.exec(str) !== null) n++
217
+ return n
218
+ }
219
+
220
+ // extractValue — returns one of:
221
+ // {kind: 'pixels', raw, parsed: number}
222
+ // {kind: 'length', raw, parsed: {value: number, unit: string}}
223
+ // {kind: 'color', raw, parsed: string}
224
+ // {kind: 'text', raw, parsed: string} // quoted strings only
225
+ // {kind: 'url', raw, parsed: string}
226
+ // {kind: 'number', raw, parsed: number}
227
+ // null
228
+ //
229
+ // Order matters: we check the most specific patterns first so a quoted hex
230
+ // (`"#fff"`) is parsed as text, while a bare hex is parsed as color.
231
+ export function extractValue(request) {
232
+ // 1. URL
233
+ const urlMatch = request.match(/https?:\/\/[^\s)'"]+/i)
234
+ if (urlMatch) return { kind: 'url', raw: urlMatch[0], parsed: urlMatch[0] }
235
+
236
+ // 2. Quoted text (single or double) — captures the inner content.
237
+ const quoted = request.match(/(?:'([^']+)')|(?:"([^"]+)")/)
238
+ if (quoted) {
239
+ const inner = quoted[1] ?? quoted[2]
240
+ return { kind: 'text', raw: quoted[0], parsed: inner }
241
+ }
242
+
243
+ // 3. Pixel value
244
+ const pxMatch = request.match(/(\d+(?:\.\d+)?)\s*px\b/i)
245
+ if (pxMatch) return { kind: 'pixels', raw: pxMatch[0], parsed: parseFloat(pxMatch[1]) }
246
+
247
+ // 4. Other length units (rem, em, %, vw, vh). `\b` doesn't fire after `%`
248
+ // (non-word) so we use a non-letter lookahead to terminate the match.
249
+ const lenMatch = request.match(/(\d+(?:\.\d+)?)\s*(rem|em|vw|vh|%)(?![a-zA-Z0-9])/i)
250
+ if (lenMatch) {
251
+ return {
252
+ kind: 'length',
253
+ raw: lenMatch[0],
254
+ parsed: { value: parseFloat(lenMatch[1]), unit: lenMatch[2].toLowerCase() },
255
+ }
256
+ }
257
+
258
+ // 5. Hex color
259
+ const hexMatch = request.match(/#[0-9a-f]{3,8}\b/i)
260
+ if (hexMatch) return { kind: 'color', raw: hexMatch[0], parsed: hexMatch[0] }
261
+
262
+ // 6. rgb/rgba/hsl
263
+ const fnColor = request.match(/(rgba?|hsla?)\s*\([^)]+\)/i)
264
+ if (fnColor) return { kind: 'color', raw: fnColor[0], parsed: fnColor[0] }
265
+
266
+ // 7. Color name — only on word boundaries
267
+ for (const name of COLOR_NAMES) {
268
+ const re = new RegExp(`\\b${name}\\b`, 'i')
269
+ if (re.test(request)) {
270
+ return { kind: 'color', raw: name, parsed: name }
271
+ }
272
+ }
273
+
274
+ // 8. Bare number — only useful when the action verb implies a numeric value.
275
+ const numMatch = request.match(/\b(\d+(?:\.\d+)?)\b/)
276
+ if (numMatch) return { kind: 'number', raw: numMatch[0], parsed: parseFloat(numMatch[1]) }
277
+
278
+ return null
279
+ }
280
+
281
+ // defaultProperty — when no explicit property phrase was found, pick a sensible
282
+ // default based on the action, target, and value kind. Returning null means
283
+ // "couldn't infer", which lowers confidence.
284
+ export function defaultProperty({ action, target, value }) {
285
+ if (!target) return null
286
+ const t = target.type
287
+
288
+ // Sizing actions imply font-size for typographic targets.
289
+ if (action === 'increase' || action === 'decrease') {
290
+ if (t === 'heading' || t === 'subheading' || t === 'text' || t === 'button' || t === 'link') return 'font-size'
291
+ if (t === 'image' || t === 'icon') return 'width'
292
+ return 'font-size'
293
+ }
294
+
295
+ // Remove rarely needs a property — it removes the element wholesale.
296
+ if (action === 'remove') return null
297
+
298
+ // Set / add: infer from value kind.
299
+ if (value) {
300
+ if (value.kind === 'color') {
301
+ // For 'background' target, default to background-color; for buttons,
302
+ // default to background-color too (common ask). For text-bearing
303
+ // elements, default to color.
304
+ if (t === 'background' || t === 'button' || t === 'section') return 'background-color'
305
+ return 'color'
306
+ }
307
+ if (value.kind === 'pixels' || value.kind === 'length' || value.kind === 'number') {
308
+ if (t === 'image' || t === 'icon') return 'width'
309
+ return 'font-size'
310
+ }
311
+ if (value.kind === 'url') {
312
+ if (t === 'link') return 'href'
313
+ return 'src'
314
+ }
315
+ if (value.kind === 'text') {
316
+ // Text values default to text content unless the target is image/icon
317
+ // (then alt is the right default).
318
+ if (t === 'image' || t === 'icon') return 'alt'
319
+ return 'text'
320
+ }
321
+ }
322
+
323
+ return null
324
+ }
@@ -0,0 +1,248 @@
1
+ #!/usr/bin/env node
2
+ // test-diff-summarizer.mjs — Unit tests for lib/diff-summarizer.mjs.
3
+ // Covers: summarizeChanges, formatLine per scope, diffLines, computeLineFromOffset.
4
+
5
+ import { strict as assert } from 'node:assert'
6
+ import {
7
+ summarizeChanges,
8
+ formatLine,
9
+ diffLines,
10
+ computeLineFromOffset,
11
+ } from '../lib/diff-summarizer.mjs'
12
+
13
+ let passed = 0
14
+ let failed = 0
15
+
16
+ function test(name, fn) {
17
+ try {
18
+ fn()
19
+ process.stdout.write(`ok - ${name}\n`)
20
+ passed++
21
+ } catch (err) {
22
+ process.stdout.write(`not ok - ${name}\n ${err.message}\n`)
23
+ failed++
24
+ }
25
+ }
26
+
27
+ // ─── summarizeChanges ─────────────────────────────────────────────────────────
28
+
29
+ test('summarizeChanges: empty array → idempotent sentinel', () => {
30
+ assert.equal(summarizeChanges([]), 'no changes (already applied)')
31
+ })
32
+
33
+ test('summarizeChanges: null → idempotent sentinel', () => {
34
+ assert.equal(summarizeChanges(null), 'no changes (already applied)')
35
+ })
36
+
37
+ test('summarizeChanges: undefined → idempotent sentinel', () => {
38
+ assert.equal(summarizeChanges(undefined), 'no changes (already applied)')
39
+ })
40
+
41
+ test('summarizeChanges: single change', () => {
42
+ const out = summarizeChanges([
43
+ { selector: '.hero__title', property: 'font-size', before: '27px', after: '32px', scope: 'css-rule', line: 87 },
44
+ ])
45
+ assert.match(out, /Changed/)
46
+ assert.match(out, /font-size/)
47
+ assert.match(out, /27px/)
48
+ assert.match(out, /32px/)
49
+ assert.match(out, /line 87/)
50
+ })
51
+
52
+ test('summarizeChanges: multiple changes joined by newline', () => {
53
+ const out = summarizeChanges([
54
+ { selector: '.a', property: 'color', before: 'red', after: 'blue', scope: 'css-rule', line: 1 },
55
+ { selector: '.b', property: 'alt', before: null, after: 'logo', scope: 'attr', line: 2 },
56
+ ])
57
+ assert.equal(out.split('\n').length, 2)
58
+ })
59
+
60
+ // ─── formatLine per scope ─────────────────────────────────────────────────────
61
+
62
+ test('formatLine: css-rule', () => {
63
+ const line = formatLine({
64
+ selector: '.hero__title', property: 'font-size', before: '27px', after: '32px', scope: 'css-rule', line: 87,
65
+ })
66
+ assert.match(line, /Changed `\.hero__title` font-size/)
67
+ assert.match(line, /27px/)
68
+ assert.match(line, /32px/)
69
+ assert.match(line, /line 87/)
70
+ })
71
+
72
+ test('formatLine: inline-style add (no before)', () => {
73
+ const line = formatLine({
74
+ selector: 'h1.title', property: 'color', before: null, after: 'red', scope: 'inline-style',
75
+ })
76
+ assert.match(line, /Added inline color/)
77
+ assert.match(line, /red/)
78
+ })
79
+
80
+ test('formatLine: inline-style change (with before)', () => {
81
+ const line = formatLine({
82
+ selector: 'h1', property: 'color', before: 'red', after: 'blue', scope: 'inline-style',
83
+ })
84
+ assert.match(line, /Changed inline color/)
85
+ assert.match(line, /red/)
86
+ assert.match(line, /blue/)
87
+ })
88
+
89
+ test('formatLine: attr add', () => {
90
+ const line = formatLine({
91
+ selector: 'img.hero', property: 'alt', before: null, after: 'Hero shot', scope: 'attr',
92
+ })
93
+ assert.match(line, /Added alt=/)
94
+ assert.match(line, /Hero shot/)
95
+ })
96
+
97
+ test('formatLine: attr remove', () => {
98
+ const line = formatLine({
99
+ selector: 'img.hero', property: 'alt', before: 'old', after: null, scope: 'attr',
100
+ })
101
+ assert.match(line, /Removed alt/)
102
+ })
103
+
104
+ test('formatLine: attr change', () => {
105
+ const line = formatLine({
106
+ selector: 'a.link', property: 'href', before: 'https://a', after: 'https://b', scope: 'attr',
107
+ })
108
+ assert.match(line, /Changed href/)
109
+ assert.match(line, /https:\/\/a/)
110
+ assert.match(line, /https:\/\/b/)
111
+ })
112
+
113
+ test('formatLine: text', () => {
114
+ const line = formatLine({
115
+ selector: 'h1.hero__title', property: 'text', before: 'Welcome', after: 'Hello', scope: 'text',
116
+ })
117
+ assert.match(line, /Changed text of/)
118
+ assert.match(line, /Welcome/)
119
+ assert.match(line, /Hello/)
120
+ })
121
+
122
+ test('formatLine: remove-element', () => {
123
+ const line = formatLine({
124
+ selector: 'button.cta', property: 'element', before: '<button>Buy</button>', after: null, scope: 'remove-element',
125
+ })
126
+ assert.match(line, /Removed element/)
127
+ })
128
+
129
+ test('formatLine: includes line number when provided', () => {
130
+ const line = formatLine({
131
+ selector: '.x', property: 'y', before: 'a', after: 'b', scope: 'css-rule', line: 42,
132
+ })
133
+ assert.match(line, /line 42/)
134
+ })
135
+
136
+ test('formatLine: omits line number when missing', () => {
137
+ const line = formatLine({
138
+ selector: '.x', property: 'y', before: 'a', after: 'b', scope: 'css-rule',
139
+ })
140
+ assert.doesNotMatch(line, /line/)
141
+ })
142
+
143
+ test('formatLine: null change returns empty string', () => {
144
+ assert.equal(formatLine(null), '')
145
+ })
146
+
147
+ test('formatLine: handles null before/after gracefully', () => {
148
+ // `∅` is the canonical empty-value marker
149
+ const line = formatLine({
150
+ selector: '.x', property: 'color', before: null, after: 'red', scope: 'css-rule',
151
+ })
152
+ assert.match(line, /∅|null/)
153
+ assert.match(line, /red/)
154
+ })
155
+
156
+ test('formatLine: unknown scope falls back to generic', () => {
157
+ const line = formatLine({
158
+ selector: '.x', property: 'foo', before: 'a', after: 'b', scope: 'mystery',
159
+ })
160
+ // generic path still produces something
161
+ assert.match(line, /Changed foo|Updated/)
162
+ })
163
+
164
+ test('formatLine: idempotent quoting (already-quoted strings preserved)', () => {
165
+ const line = formatLine({
166
+ selector: '.x', property: 'color', before: '`red`', after: '`blue`', scope: 'css-rule',
167
+ })
168
+ // Backticks not double-wrapped
169
+ assert.equal(line.match(/`red`/g).length, 1)
170
+ })
171
+
172
+ // ─── diffLines ───────────────────────────────────────────────────────────────
173
+
174
+ test('diffLines: identical input returns empty', () => {
175
+ const out = diffLines('a\nb\nc', 'a\nb\nc')
176
+ assert.equal(out, '')
177
+ })
178
+
179
+ test('diffLines: single line change', () => {
180
+ const out = diffLines('a\nold\nc', 'a\nnew\nc')
181
+ assert.match(out, /-\s*old/)
182
+ assert.match(out, /\+\s*new/)
183
+ })
184
+
185
+ test('diffLines: line addition', () => {
186
+ const out = diffLines('a\nb', 'a\nb\nc')
187
+ assert.match(out, /\+\s*c/)
188
+ })
189
+
190
+ test('diffLines: line removal', () => {
191
+ const out = diffLines('a\nb\nc', 'a\nb')
192
+ assert.match(out, /-\s*c/)
193
+ })
194
+
195
+ test('diffLines: truncates long diffs', () => {
196
+ const before = Array.from({ length: 100 }, (_, i) => `line${i}`).join('\n')
197
+ const after = Array.from({ length: 100 }, (_, i) => `line${i + 1}`).join('\n')
198
+ const out = diffLines(before, after, { maxLines: 5 })
199
+ assert.match(out, /truncated/)
200
+ })
201
+
202
+ test('diffLines: handles null/undefined input', () => {
203
+ // Should not throw
204
+ const out = diffLines(null, 'x\ny')
205
+ assert.match(out, /\+\s*x/)
206
+ })
207
+
208
+ test('diffLines: includes a hunk marker', () => {
209
+ const out = diffLines('a\nold\nc', 'a\nnew\nc')
210
+ assert.match(out, /@@/)
211
+ })
212
+
213
+ // ─── computeLineFromOffset ────────────────────────────────────────────────────
214
+
215
+ test('computeLineFromOffset: zero offset → line 1', () => {
216
+ assert.equal(computeLineFromOffset('hello', 0), 1)
217
+ })
218
+
219
+ test('computeLineFromOffset: counts newlines', () => {
220
+ assert.equal(computeLineFromOffset('line1\nline2\nline3', 12), 3)
221
+ })
222
+
223
+ test('computeLineFromOffset: offset at exact newline', () => {
224
+ // 'a\nb' — offset 1 is on the newline char itself; line should still be 1
225
+ // (we count 1-based; the `\n` belongs to line 1)
226
+ assert.equal(computeLineFromOffset('a\nb', 1), 1)
227
+ })
228
+
229
+ test('computeLineFromOffset: null source → 0', () => {
230
+ assert.equal(computeLineFromOffset(null, 5), 0)
231
+ })
232
+
233
+ test('computeLineFromOffset: null offset → 0', () => {
234
+ assert.equal(computeLineFromOffset('a\nb', null), 0)
235
+ })
236
+
237
+ test('computeLineFromOffset: offset past EOF clamps gracefully', () => {
238
+ assert.equal(computeLineFromOffset('a\nb', 9999), 2)
239
+ })
240
+
241
+ test('computeLineFromOffset: offset negative → 0', () => {
242
+ assert.equal(computeLineFromOffset('a\nb', -5), 0)
243
+ })
244
+
245
+ // ─── Done ─────────────────────────────────────────────────────────────────────
246
+
247
+ process.stdout.write(`\n1..${passed + failed}\n# passed ${passed}, failed ${failed}\n`)
248
+ process.exit(failed === 0 ? 0 : 1)