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