@maccesar/aiskills 1.7.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/README.md +531 -0
- package/bin/aiskills.js +76 -0
- package/lib/cache.js +49 -0
- package/lib/cleanup.js +77 -0
- package/lib/commands/auto-update.js +131 -0
- package/lib/commands/doctor.js +139 -0
- package/lib/commands/list.js +77 -0
- package/lib/commands/skills.js +263 -0
- package/lib/commands/status.js +94 -0
- package/lib/commands/uninstall.js +182 -0
- package/lib/commands/update.js +149 -0
- package/lib/config.js +90 -0
- package/lib/downloader.js +110 -0
- package/lib/hooks.js +74 -0
- package/lib/installer.js +114 -0
- package/lib/platform.js +112 -0
- package/lib/prompts/checkboxCancel.js +264 -0
- package/lib/prompts/selectCancel.js +204 -0
- package/lib/symlink.js +154 -0
- package/lib/utils.js +49 -0
- package/package.json +61 -0
- package/skills/humaniza/SKILL.md +51 -0
- package/skills/humaniza/agents/openai.yaml +4 -0
- package/skills/humaniza/references/ai-patterns-es.md +51 -0
- package/skills/humaniza/references/checklist.md +9 -0
- package/skills/humaniza/references/examples.md +17 -0
- package/skills/humaniza/references/lexicon-es-mx.md +36 -0
- package/skills/humaniza/references/modes-es-mx.md +41 -0
- package/skills/humaniza/references/voice-es-mx.md +24 -0
- package/skills/refactoring-ui/SKILL.md +59 -0
- package/skills/refactoring-ui/references/01-design-process.md +72 -0
- package/skills/refactoring-ui/references/02-visual-hierarchy.md +84 -0
- package/skills/refactoring-ui/references/03-layout-spacing.md +69 -0
- package/skills/refactoring-ui/references/04-typography.md +70 -0
- package/skills/refactoring-ui/references/05-color.md +96 -0
- package/skills/refactoring-ui/references/06-depth-shadows.md +74 -0
- package/skills/refactoring-ui/references/07-images.md +75 -0
- package/skills/refactoring-ui/references/08-finishing-touches.md +91 -0
- package/skills/stitch-showcase/SKILL.md +411 -0
- package/skills/stitch-showcase/references/01-navbar.md +52 -0
- package/skills/stitch-showcase/references/02-hero.md +56 -0
- package/skills/stitch-showcase/references/03-design-system.md +102 -0
- package/skills/stitch-showcase/references/04-screen-gallery.md +102 -0
- package/skills/stitch-showcase/references/05-viewer-web.md +105 -0
- package/skills/stitch-showcase/references/06-viewer-mobile.md +104 -0
- package/skills/stitch-showcase/references/07-theme-system.md +77 -0
- package/skills/stitch-showcase/references/08-type-detection.md +81 -0
- package/skills/stitch-showcase/references/09-quality-standards.md +126 -0
- package/skills/stitch-showcase/references/10-component-standardization.md +40 -0
- package/skills/stitch-showcase/references/11-component-catalog.md +70 -0
- package/skills/stitch-showcase/references/catalog-template.html +841 -0
- package/skills/stitch-showcase/references/index.html +299 -0
- package/skills/stitch-showcase/references/viewer.html +412 -0
- package/skills/stitch-showcase/scripts/__pycache__/build_showcase.cpython-314.pyc +0 -0
- package/skills/stitch-showcase/scripts/__pycache__/component_utils.cpython-313.pyc +0 -0
- package/skills/stitch-showcase/scripts/__pycache__/detect_components.cpython-313.pyc +0 -0
- package/skills/stitch-showcase/scripts/__pycache__/extract_catalog.cpython-313.pyc +0 -0
- package/skills/stitch-showcase/scripts/__pycache__/extract_text.cpython-313.pyc +0 -0
- package/skills/stitch-showcase/scripts/__pycache__/extract_zips.cpython-313.pyc +0 -0
- package/skills/stitch-showcase/scripts/__pycache__/parse_design_md.cpython-313.pyc +0 -0
- package/skills/stitch-showcase/scripts/apply_canonical.py +238 -0
- package/skills/stitch-showcase/scripts/build_showcase.py +2103 -0
- package/skills/stitch-showcase/scripts/component_utils.py +398 -0
- package/skills/stitch-showcase/scripts/detect_components.py +284 -0
- package/skills/stitch-showcase/scripts/extract_catalog.py +913 -0
- package/skills/stitch-showcase/scripts/extract_text.py +268 -0
- package/skills/stitch-showcase/scripts/extract_zips.py +178 -0
- package/skills/stitch-showcase/scripts/parse_design_md.py +397 -0
- package/skills/vscode-extension-dev/SKILL.md +114 -0
- package/skills/vscode-extension-dev/references/api-patterns.md +625 -0
- package/skills/vscode-extension-dev/references/architecture.md +287 -0
- package/skills/vscode-extension-dev/references/package-json-schema.md +345 -0
- package/skills/vscode-extension-dev/references/publishing.md +251 -0
|
@@ -0,0 +1,398 @@
|
|
|
1
|
+
"""
|
|
2
|
+
component_utils.py — Shared HTML parsing helpers for component detection and catalog.
|
|
3
|
+
|
|
4
|
+
Uses only stdlib (html.parser, re, difflib). No external dependencies.
|
|
5
|
+
|
|
6
|
+
Usage:
|
|
7
|
+
from component_utils import (
|
|
8
|
+
parse_dom_tree, extract_semantic_blocks, dom_signature,
|
|
9
|
+
normalize_html, text_similarity, strip_tags, extract_inline_styles,
|
|
10
|
+
)
|
|
11
|
+
"""
|
|
12
|
+
import re
|
|
13
|
+
import hashlib
|
|
14
|
+
from html.parser import HTMLParser
|
|
15
|
+
from difflib import SequenceMatcher
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
# ─── DOM Tree Parser ──────────────────────────────────────────────────────────
|
|
19
|
+
|
|
20
|
+
class _DOMNode:
|
|
21
|
+
"""Lightweight DOM node for tree comparison."""
|
|
22
|
+
__slots__ = ("tag", "attrs", "children", "text", "classes")
|
|
23
|
+
|
|
24
|
+
def __init__(self, tag: str, attrs: dict = None):
|
|
25
|
+
self.tag = tag
|
|
26
|
+
self.attrs = attrs or {}
|
|
27
|
+
self.children = []
|
|
28
|
+
self.text = ""
|
|
29
|
+
self.classes = self.attrs.get("class", "").split()
|
|
30
|
+
|
|
31
|
+
def node_count(self) -> int:
|
|
32
|
+
return 1 + sum(c.node_count() for c in self.children)
|
|
33
|
+
|
|
34
|
+
def to_signature(self) -> str:
|
|
35
|
+
"""Tag-only tree string for structural comparison."""
|
|
36
|
+
if not self.children:
|
|
37
|
+
return self.tag
|
|
38
|
+
child_sigs = " ".join(c.to_signature() for c in self.children)
|
|
39
|
+
return f"{self.tag}({child_sigs})"
|
|
40
|
+
|
|
41
|
+
def to_dict(self) -> dict:
|
|
42
|
+
return {
|
|
43
|
+
"tag": self.tag,
|
|
44
|
+
"classes": self.classes,
|
|
45
|
+
"text": self.text.strip()[:200] if self.text else "",
|
|
46
|
+
"children": [c.to_dict() for c in self.children],
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
class _TreeBuilder(HTMLParser):
|
|
51
|
+
"""Build a simplified DOM tree from HTML."""
|
|
52
|
+
|
|
53
|
+
VOID_TAGS = frozenset([
|
|
54
|
+
"area", "base", "br", "col", "embed", "hr", "img", "input",
|
|
55
|
+
"link", "meta", "param", "source", "track", "wbr",
|
|
56
|
+
])
|
|
57
|
+
SKIP_TAGS = frozenset(["script", "style", "svg", "noscript"])
|
|
58
|
+
|
|
59
|
+
def __init__(self):
|
|
60
|
+
super().__init__()
|
|
61
|
+
self.root = _DOMNode("root")
|
|
62
|
+
self._stack = [self.root]
|
|
63
|
+
self._skip_depth = 0
|
|
64
|
+
|
|
65
|
+
def handle_starttag(self, tag, attrs):
|
|
66
|
+
tag = tag.lower()
|
|
67
|
+
if self._skip_depth > 0:
|
|
68
|
+
if tag not in self.VOID_TAGS:
|
|
69
|
+
self._skip_depth += 1
|
|
70
|
+
return
|
|
71
|
+
if tag in self.SKIP_TAGS:
|
|
72
|
+
self._skip_depth = 1
|
|
73
|
+
return
|
|
74
|
+
|
|
75
|
+
attr_dict = {k: v for k, v in attrs if k and v}
|
|
76
|
+
node = _DOMNode(tag, attr_dict)
|
|
77
|
+
self._stack[-1].children.append(node)
|
|
78
|
+
if tag not in self.VOID_TAGS:
|
|
79
|
+
self._stack.append(node)
|
|
80
|
+
|
|
81
|
+
def handle_endtag(self, tag):
|
|
82
|
+
tag = tag.lower()
|
|
83
|
+
if self._skip_depth > 0:
|
|
84
|
+
self._skip_depth -= 1
|
|
85
|
+
return
|
|
86
|
+
if tag in self.VOID_TAGS or tag in self.SKIP_TAGS:
|
|
87
|
+
return
|
|
88
|
+
if len(self._stack) > 1 and self._stack[-1].tag == tag:
|
|
89
|
+
self._stack.pop()
|
|
90
|
+
|
|
91
|
+
def handle_data(self, data):
|
|
92
|
+
if self._skip_depth > 0:
|
|
93
|
+
return
|
|
94
|
+
text = data.strip()
|
|
95
|
+
if text and self._stack:
|
|
96
|
+
self._stack[-1].text += " " + text
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
def parse_dom_tree(html: str) -> _DOMNode:
|
|
100
|
+
"""Parse HTML into a simplified DOM tree."""
|
|
101
|
+
builder = _TreeBuilder()
|
|
102
|
+
try:
|
|
103
|
+
builder.feed(html)
|
|
104
|
+
except Exception:
|
|
105
|
+
pass
|
|
106
|
+
return builder.root
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
# ─── Semantic Block Extraction ────────────────────────────────────────────────
|
|
110
|
+
|
|
111
|
+
# Tags and patterns that identify shared components
|
|
112
|
+
SEMANTIC_SELECTORS = {
|
|
113
|
+
"navbar": {
|
|
114
|
+
"tags": ["nav"],
|
|
115
|
+
"roles": ["navigation"],
|
|
116
|
+
"classes": ["nav", "navbar", "navigation", "top-bar", "topbar", "app-bar", "appbar", "header-nav"],
|
|
117
|
+
},
|
|
118
|
+
"header": {
|
|
119
|
+
"tags": ["header"],
|
|
120
|
+
"roles": ["banner"],
|
|
121
|
+
"classes": ["header", "site-header", "page-header", "app-header"],
|
|
122
|
+
},
|
|
123
|
+
"footer": {
|
|
124
|
+
"tags": ["footer"],
|
|
125
|
+
"roles": ["contentinfo"],
|
|
126
|
+
"classes": ["footer", "site-footer", "page-footer", "app-footer"],
|
|
127
|
+
},
|
|
128
|
+
"sidebar": {
|
|
129
|
+
"tags": ["aside"],
|
|
130
|
+
"roles": ["complementary"],
|
|
131
|
+
"classes": ["sidebar", "side-bar", "side-nav", "sidenav", "drawer", "nav-rail"],
|
|
132
|
+
},
|
|
133
|
+
"tabbar": {
|
|
134
|
+
"tags": [],
|
|
135
|
+
"roles": ["tablist"],
|
|
136
|
+
"classes": ["tabbar", "tab-bar", "bottom-nav", "bottom-navigation", "bottom-bar", "bottombar"],
|
|
137
|
+
},
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
# CSS position patterns for fallback detection
|
|
141
|
+
POSITION_PATTERNS = {
|
|
142
|
+
"navbar": re.compile(
|
|
143
|
+
r"position\s*:\s*(?:fixed|sticky)[^;]*;[^}]*top\s*:\s*0",
|
|
144
|
+
re.IGNORECASE | re.DOTALL,
|
|
145
|
+
),
|
|
146
|
+
"tabbar": re.compile(
|
|
147
|
+
r"position\s*:\s*(?:fixed|sticky)[^;]*;[^}]*bottom\s*:\s*0",
|
|
148
|
+
re.IGNORECASE | re.DOTALL,
|
|
149
|
+
),
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
|
|
153
|
+
def extract_semantic_blocks(html: str) -> dict:
|
|
154
|
+
"""
|
|
155
|
+
Extract semantic component blocks from HTML.
|
|
156
|
+
|
|
157
|
+
Returns dict mapping component type to list of HTML snippets:
|
|
158
|
+
{"navbar": ["<nav>...</nav>"], "footer": ["<footer>...</footer>"], ...}
|
|
159
|
+
"""
|
|
160
|
+
results = {}
|
|
161
|
+
|
|
162
|
+
for comp_type, selectors in SEMANTIC_SELECTORS.items():
|
|
163
|
+
blocks = []
|
|
164
|
+
|
|
165
|
+
# 1. Semantic tags
|
|
166
|
+
for tag in selectors["tags"]:
|
|
167
|
+
blocks.extend(_extract_tag_blocks(html, tag))
|
|
168
|
+
|
|
169
|
+
# 2. ARIA roles
|
|
170
|
+
for role in selectors["roles"]:
|
|
171
|
+
blocks.extend(_extract_by_role(html, role))
|
|
172
|
+
|
|
173
|
+
# 3. CSS class patterns
|
|
174
|
+
for cls in selectors["classes"]:
|
|
175
|
+
blocks.extend(_extract_by_class(html, cls))
|
|
176
|
+
|
|
177
|
+
# Deduplicate by content overlap
|
|
178
|
+
unique = _deduplicate_blocks(blocks)
|
|
179
|
+
if unique:
|
|
180
|
+
results[comp_type] = unique
|
|
181
|
+
|
|
182
|
+
# 4. Fallback: position-based detection
|
|
183
|
+
for comp_type, pattern in POSITION_PATTERNS.items():
|
|
184
|
+
if comp_type not in results:
|
|
185
|
+
blocks = _extract_by_position_pattern(html, pattern)
|
|
186
|
+
if blocks:
|
|
187
|
+
results[comp_type] = blocks
|
|
188
|
+
|
|
189
|
+
return results
|
|
190
|
+
|
|
191
|
+
|
|
192
|
+
def _extract_tag_blocks(html: str, tag: str) -> list:
|
|
193
|
+
"""Extract all blocks of a given HTML tag."""
|
|
194
|
+
pattern = re.compile(
|
|
195
|
+
rf"<{tag}[\s>].*?</{tag}>",
|
|
196
|
+
re.IGNORECASE | re.DOTALL,
|
|
197
|
+
)
|
|
198
|
+
return pattern.findall(html)
|
|
199
|
+
|
|
200
|
+
|
|
201
|
+
def _extract_by_role(html: str, role: str) -> list:
|
|
202
|
+
"""Extract elements with a specific ARIA role."""
|
|
203
|
+
pattern = re.compile(
|
|
204
|
+
rf'<(\w+)[^>]*\brole\s*=\s*["\']?{re.escape(role)}["\']?[^>]*>.*?</\1>',
|
|
205
|
+
re.IGNORECASE | re.DOTALL,
|
|
206
|
+
)
|
|
207
|
+
return [m.group(0) for m in pattern.finditer(html)]
|
|
208
|
+
|
|
209
|
+
|
|
210
|
+
def _extract_by_class(html: str, cls: str) -> list:
|
|
211
|
+
"""Extract elements whose class attribute contains the given class name."""
|
|
212
|
+
pattern = re.compile(
|
|
213
|
+
rf'<(\w+)[^>]*\bclass\s*=\s*"[^"]*\b{re.escape(cls)}\b[^"]*"[^>]*>.*?</\1>',
|
|
214
|
+
re.IGNORECASE | re.DOTALL,
|
|
215
|
+
)
|
|
216
|
+
return [m.group(0) for m in pattern.finditer(html)]
|
|
217
|
+
|
|
218
|
+
|
|
219
|
+
def _extract_by_position_pattern(html: str, pattern: re.Pattern) -> list:
|
|
220
|
+
"""
|
|
221
|
+
Extract elements whose inline style matches a CSS position pattern.
|
|
222
|
+
|
|
223
|
+
Looks for the pattern in <style> blocks, then tries to find the associated
|
|
224
|
+
element by class name.
|
|
225
|
+
"""
|
|
226
|
+
blocks = []
|
|
227
|
+
# Check inline styles on elements
|
|
228
|
+
for m in re.finditer(r'<(\w+)[^>]*style="([^"]*)"[^>]*>.*?</\1>', html, re.DOTALL | re.IGNORECASE):
|
|
229
|
+
if pattern.search(m.group(2)):
|
|
230
|
+
blocks.append(m.group(0))
|
|
231
|
+
return blocks
|
|
232
|
+
|
|
233
|
+
|
|
234
|
+
def _deduplicate_blocks(blocks: list) -> list:
|
|
235
|
+
"""Remove blocks that are substrings of other blocks."""
|
|
236
|
+
if len(blocks) <= 1:
|
|
237
|
+
return blocks
|
|
238
|
+
sorted_blocks = sorted(blocks, key=len, reverse=True)
|
|
239
|
+
unique = []
|
|
240
|
+
for b in sorted_blocks:
|
|
241
|
+
if not any(b in u for u in unique if u != b):
|
|
242
|
+
unique.append(b)
|
|
243
|
+
return unique
|
|
244
|
+
|
|
245
|
+
|
|
246
|
+
# ─── Comparison Utilities ─────────────────────────────────────────────────────
|
|
247
|
+
|
|
248
|
+
def dom_signature(html: str) -> str:
|
|
249
|
+
"""Generate a tag-only tree signature for structural comparison."""
|
|
250
|
+
tree = parse_dom_tree(html)
|
|
251
|
+
if tree.children:
|
|
252
|
+
return " ".join(c.to_signature() for c in tree.children)
|
|
253
|
+
return ""
|
|
254
|
+
|
|
255
|
+
|
|
256
|
+
def normalize_html(html: str) -> str:
|
|
257
|
+
"""
|
|
258
|
+
Normalize HTML for deduplication hashing.
|
|
259
|
+
|
|
260
|
+
Strips whitespace, removes variable content (text nodes), keeps structure.
|
|
261
|
+
"""
|
|
262
|
+
# Remove all text content between tags
|
|
263
|
+
normalized = re.sub(r">\s+<", "><", html)
|
|
264
|
+
# Remove whitespace
|
|
265
|
+
normalized = re.sub(r"\s+", " ", normalized).strip()
|
|
266
|
+
# Remove text between tags (keep only structure)
|
|
267
|
+
normalized = re.sub(r">([^<]+)<", "><", normalized)
|
|
268
|
+
return normalized
|
|
269
|
+
|
|
270
|
+
|
|
271
|
+
def html_hash(html: str) -> str:
|
|
272
|
+
"""Hash of normalized HTML for deduplication."""
|
|
273
|
+
return hashlib.md5(normalize_html(html).encode("utf-8")).hexdigest()
|
|
274
|
+
|
|
275
|
+
|
|
276
|
+
def text_similarity(a: str, b: str) -> float:
|
|
277
|
+
"""Text similarity ratio using SequenceMatcher (0.0 to 1.0)."""
|
|
278
|
+
if not a and not b:
|
|
279
|
+
return 1.0
|
|
280
|
+
if not a or not b:
|
|
281
|
+
return 0.0
|
|
282
|
+
return SequenceMatcher(None, a, b).ratio()
|
|
283
|
+
|
|
284
|
+
|
|
285
|
+
def tree_similarity(sig_a: str, sig_b: str) -> float:
|
|
286
|
+
"""Structural similarity between two DOM signatures."""
|
|
287
|
+
return text_similarity(sig_a, sig_b)
|
|
288
|
+
|
|
289
|
+
|
|
290
|
+
def class_overlap(classes_a: list, classes_b: list) -> float:
|
|
291
|
+
"""Jaccard similarity of CSS class lists."""
|
|
292
|
+
set_a = set(classes_a)
|
|
293
|
+
set_b = set(classes_b)
|
|
294
|
+
if not set_a and not set_b:
|
|
295
|
+
return 1.0
|
|
296
|
+
if not set_a or not set_b:
|
|
297
|
+
return 0.0
|
|
298
|
+
intersection = set_a & set_b
|
|
299
|
+
union = set_a | set_b
|
|
300
|
+
return len(intersection) / len(union)
|
|
301
|
+
|
|
302
|
+
|
|
303
|
+
def component_similarity(html_a: str, html_b: str) -> float:
|
|
304
|
+
"""
|
|
305
|
+
Weighted similarity score between two component HTML snippets.
|
|
306
|
+
|
|
307
|
+
Weights: tree structure 50% + CSS classes 30% + visible text 20%
|
|
308
|
+
"""
|
|
309
|
+
sig_a = dom_signature(html_a)
|
|
310
|
+
sig_b = dom_signature(html_b)
|
|
311
|
+
tree_score = tree_similarity(sig_a, sig_b)
|
|
312
|
+
|
|
313
|
+
classes_a = _extract_all_classes(html_a)
|
|
314
|
+
classes_b = _extract_all_classes(html_b)
|
|
315
|
+
class_score = class_overlap(classes_a, classes_b)
|
|
316
|
+
|
|
317
|
+
text_a = strip_tags(html_a)
|
|
318
|
+
text_b = strip_tags(html_b)
|
|
319
|
+
text_score = text_similarity(text_a, text_b)
|
|
320
|
+
|
|
321
|
+
return tree_score * 0.5 + class_score * 0.3 + text_score * 0.2
|
|
322
|
+
|
|
323
|
+
|
|
324
|
+
def _extract_all_classes(html: str) -> list:
|
|
325
|
+
"""Extract all CSS class names from HTML."""
|
|
326
|
+
classes = []
|
|
327
|
+
for m in re.finditer(r'class="([^"]*)"', html, re.IGNORECASE):
|
|
328
|
+
classes.extend(m.group(1).split())
|
|
329
|
+
return classes
|
|
330
|
+
|
|
331
|
+
|
|
332
|
+
# ─── Text & Style Extraction ─────────────────────────────────────────────────
|
|
333
|
+
|
|
334
|
+
def strip_tags(html: str) -> str:
|
|
335
|
+
"""Remove all HTML tags, decode entities, normalize whitespace."""
|
|
336
|
+
from html import unescape
|
|
337
|
+
text = re.sub(r"<(script|style|svg|noscript)[^>]*>.*?</\1>", " ", html, flags=re.DOTALL | re.IGNORECASE)
|
|
338
|
+
text = re.sub(r"<[^>]+>", " ", text)
|
|
339
|
+
text = unescape(text)
|
|
340
|
+
text = re.sub(r"\s+", " ", text).strip()
|
|
341
|
+
return text
|
|
342
|
+
|
|
343
|
+
|
|
344
|
+
def extract_inline_styles(html: str) -> dict:
|
|
345
|
+
"""
|
|
346
|
+
Extract common CSS properties from inline styles in the outermost element.
|
|
347
|
+
|
|
348
|
+
Returns dict with keys like 'background-color', 'color', 'border-radius', etc.
|
|
349
|
+
"""
|
|
350
|
+
m = re.search(r'style="([^"]*)"', html, re.IGNORECASE)
|
|
351
|
+
if not m:
|
|
352
|
+
return {}
|
|
353
|
+
|
|
354
|
+
style_str = m.group(1)
|
|
355
|
+
props = {}
|
|
356
|
+
for prop in style_str.split(";"):
|
|
357
|
+
prop = prop.strip()
|
|
358
|
+
if ":" in prop:
|
|
359
|
+
key, val = prop.split(":", 1)
|
|
360
|
+
props[key.strip().lower()] = val.strip()
|
|
361
|
+
return props
|
|
362
|
+
|
|
363
|
+
|
|
364
|
+
def extract_css_classes_from_block(html: str) -> list:
|
|
365
|
+
"""Extract CSS classes from the outermost element of an HTML block."""
|
|
366
|
+
m = re.match(r'<\w+[^>]*class="([^"]*)"', html, re.IGNORECASE)
|
|
367
|
+
if m:
|
|
368
|
+
return m.group(1).split()
|
|
369
|
+
return []
|
|
370
|
+
|
|
371
|
+
|
|
372
|
+
def detect_button_variant(html: str, classes: list) -> str:
|
|
373
|
+
"""Detect button variant (primary/secondary/danger/outline/ghost) from classes and styles."""
|
|
374
|
+
cls_str = " ".join(classes).lower()
|
|
375
|
+
if any(k in cls_str for k in ("primary", "btn-primary", "cta")):
|
|
376
|
+
return "primary"
|
|
377
|
+
if any(k in cls_str for k in ("secondary", "btn-secondary")):
|
|
378
|
+
return "secondary"
|
|
379
|
+
if any(k in cls_str for k in ("danger", "destructive", "btn-danger", "btn-red", "error")):
|
|
380
|
+
return "danger"
|
|
381
|
+
if any(k in cls_str for k in ("outline", "bordered", "btn-outline")):
|
|
382
|
+
return "outline"
|
|
383
|
+
if any(k in cls_str for k in ("ghost", "text", "link", "btn-ghost", "btn-text")):
|
|
384
|
+
return "ghost"
|
|
385
|
+
|
|
386
|
+
# Check inline styles for background color hints
|
|
387
|
+
styles = extract_inline_styles(html)
|
|
388
|
+
bg = styles.get("background-color", "") + styles.get("background", "")
|
|
389
|
+
if any(c in bg.lower() for c in ("red", "#e", "#f44", "#ef4", "rgb(239", "rgb(244")):
|
|
390
|
+
return "danger"
|
|
391
|
+
|
|
392
|
+
return "default"
|
|
393
|
+
|
|
394
|
+
|
|
395
|
+
def count_dom_nodes(html: str) -> int:
|
|
396
|
+
"""Count DOM nodes in an HTML snippet."""
|
|
397
|
+
tree = parse_dom_tree(html)
|
|
398
|
+
return tree.node_count()
|
|
@@ -0,0 +1,284 @@
|
|
|
1
|
+
"""
|
|
2
|
+
detect_components.py — Detect shared components across Stitch screen HTMLs.
|
|
3
|
+
|
|
4
|
+
Finds navbars, footers, tabbars, sidebars, and headers that appear across
|
|
5
|
+
multiple screens. Compares variants and recommends a canonical version.
|
|
6
|
+
|
|
7
|
+
Uses only stdlib (html.parser, difflib, re, json). No external dependencies.
|
|
8
|
+
|
|
9
|
+
Usage:
|
|
10
|
+
# As a module (from build_showcase.py):
|
|
11
|
+
from detect_components import detect_shared_components
|
|
12
|
+
|
|
13
|
+
# Standalone:
|
|
14
|
+
python detect_components.py /path/to/assets/
|
|
15
|
+
"""
|
|
16
|
+
import re
|
|
17
|
+
import sys
|
|
18
|
+
import json
|
|
19
|
+
from pathlib import Path
|
|
20
|
+
|
|
21
|
+
# Sibling import
|
|
22
|
+
sys.path.insert(0, str(Path(__file__).parent))
|
|
23
|
+
import component_utils as cu
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
# Screens containing these slugs get priority when choosing canonical version
|
|
27
|
+
HOME_PRIORITY_SLUGS = frozenset([
|
|
28
|
+
"home", "main", "dashboard", "inicio", "principal", "landing",
|
|
29
|
+
"index", "home_screen", "pantalla_principal",
|
|
30
|
+
])
|
|
31
|
+
|
|
32
|
+
# Minimum screens a component must appear in to be considered "shared"
|
|
33
|
+
MIN_SHARED_SCREENS = 2
|
|
34
|
+
|
|
35
|
+
# Default similarity threshold for grouping variants
|
|
36
|
+
DEFAULT_SIMILARITY_THRESHOLD = 0.85
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def detect_shared_components(
|
|
40
|
+
assets_dir: Path,
|
|
41
|
+
threshold: float = DEFAULT_SIMILARITY_THRESHOLD,
|
|
42
|
+
) -> dict:
|
|
43
|
+
"""
|
|
44
|
+
Detect shared components across all screen HTMLs in assets_dir.
|
|
45
|
+
|
|
46
|
+
Args:
|
|
47
|
+
assets_dir: Directory containing screen .html files
|
|
48
|
+
threshold: Minimum similarity score to group as same component (0.0-1.0)
|
|
49
|
+
|
|
50
|
+
Returns:
|
|
51
|
+
Dict with component types as keys, each containing:
|
|
52
|
+
- found_in: number of screens with this component
|
|
53
|
+
- total_screens: total screens analyzed
|
|
54
|
+
- canonical: recommended version with slug, html_snippet, score
|
|
55
|
+
- variants: list of variant diffs
|
|
56
|
+
"""
|
|
57
|
+
html_files = sorted(assets_dir.glob("*.html"))
|
|
58
|
+
if not html_files:
|
|
59
|
+
return {}
|
|
60
|
+
|
|
61
|
+
total_screens = len(html_files)
|
|
62
|
+
|
|
63
|
+
# Step 1: Extract semantic blocks from each screen
|
|
64
|
+
screen_components = {}
|
|
65
|
+
for html_path in html_files:
|
|
66
|
+
slug = html_path.stem
|
|
67
|
+
try:
|
|
68
|
+
html = html_path.read_text(encoding="utf-8", errors="replace")
|
|
69
|
+
except Exception:
|
|
70
|
+
continue
|
|
71
|
+
|
|
72
|
+
blocks = cu.extract_semantic_blocks(html)
|
|
73
|
+
if blocks:
|
|
74
|
+
screen_components[slug] = blocks
|
|
75
|
+
|
|
76
|
+
if not screen_components:
|
|
77
|
+
return {}
|
|
78
|
+
|
|
79
|
+
# Step 2: For each component type, collect across screens and group variants
|
|
80
|
+
results = {}
|
|
81
|
+
component_types = set()
|
|
82
|
+
for blocks in screen_components.values():
|
|
83
|
+
component_types.update(blocks.keys())
|
|
84
|
+
|
|
85
|
+
for comp_type in sorted(component_types):
|
|
86
|
+
# Collect all instances of this component type
|
|
87
|
+
instances = []
|
|
88
|
+
for slug, blocks in screen_components.items():
|
|
89
|
+
if comp_type in blocks:
|
|
90
|
+
for block_html in blocks[comp_type]:
|
|
91
|
+
instances.append({
|
|
92
|
+
"slug": slug,
|
|
93
|
+
"html": block_html,
|
|
94
|
+
"signature": cu.dom_signature(block_html),
|
|
95
|
+
"node_count": cu.count_dom_nodes(block_html),
|
|
96
|
+
"text": cu.strip_tags(block_html),
|
|
97
|
+
})
|
|
98
|
+
|
|
99
|
+
if len(instances) < MIN_SHARED_SCREENS:
|
|
100
|
+
continue
|
|
101
|
+
|
|
102
|
+
# Step 3: Group similar instances
|
|
103
|
+
groups = _group_by_similarity(instances, threshold)
|
|
104
|
+
|
|
105
|
+
# Only keep groups that span multiple screens
|
|
106
|
+
for group in groups:
|
|
107
|
+
screen_slugs = list(dict.fromkeys(inst["slug"] for inst in group))
|
|
108
|
+
if len(screen_slugs) < MIN_SHARED_SCREENS:
|
|
109
|
+
continue
|
|
110
|
+
|
|
111
|
+
# Step 4: Choose canonical version
|
|
112
|
+
canonical = _choose_canonical(group)
|
|
113
|
+
|
|
114
|
+
# Step 5: Build variant list with differences
|
|
115
|
+
variants = _build_variants(group, canonical)
|
|
116
|
+
|
|
117
|
+
results[f"{comp_type}s"] = {
|
|
118
|
+
"found_in": len(screen_slugs),
|
|
119
|
+
"total_screens": total_screens,
|
|
120
|
+
"canonical": {
|
|
121
|
+
"slug": canonical["slug"],
|
|
122
|
+
"html_snippet": _truncate_html(canonical["html"], 2000),
|
|
123
|
+
"node_count": canonical["node_count"],
|
|
124
|
+
"score": 1.0,
|
|
125
|
+
},
|
|
126
|
+
"variants": variants,
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
return results
|
|
130
|
+
|
|
131
|
+
|
|
132
|
+
def _group_by_similarity(instances: list, threshold: float) -> list:
|
|
133
|
+
"""
|
|
134
|
+
Group component instances by similarity score.
|
|
135
|
+
|
|
136
|
+
Uses greedy clustering: assign each instance to the first group
|
|
137
|
+
whose representative it matches above threshold.
|
|
138
|
+
"""
|
|
139
|
+
groups = []
|
|
140
|
+
|
|
141
|
+
for inst in instances:
|
|
142
|
+
placed = False
|
|
143
|
+
for group in groups:
|
|
144
|
+
rep = group[0]
|
|
145
|
+
score = cu.component_similarity(rep["html"], inst["html"])
|
|
146
|
+
if score >= threshold:
|
|
147
|
+
group.append(inst)
|
|
148
|
+
placed = True
|
|
149
|
+
break
|
|
150
|
+
|
|
151
|
+
if not placed:
|
|
152
|
+
groups.append([inst])
|
|
153
|
+
|
|
154
|
+
return groups
|
|
155
|
+
|
|
156
|
+
|
|
157
|
+
def _choose_canonical(group: list) -> dict:
|
|
158
|
+
"""
|
|
159
|
+
Choose the canonical (best) version of a component.
|
|
160
|
+
|
|
161
|
+
Priority:
|
|
162
|
+
1. From a "home" or "main" screen
|
|
163
|
+
2. Most DOM nodes (most complete version)
|
|
164
|
+
3. First encountered
|
|
165
|
+
"""
|
|
166
|
+
# Sort by: home priority (desc), node count (desc)
|
|
167
|
+
def sort_key(inst):
|
|
168
|
+
is_home = any(h in inst["slug"] for h in HOME_PRIORITY_SLUGS)
|
|
169
|
+
return (-int(is_home), -inst["node_count"])
|
|
170
|
+
|
|
171
|
+
sorted_group = sorted(group, key=sort_key)
|
|
172
|
+
return sorted_group[0]
|
|
173
|
+
|
|
174
|
+
|
|
175
|
+
def _build_variants(group: list, canonical: dict) -> list:
|
|
176
|
+
"""Build variant entries comparing each screen's version to canonical."""
|
|
177
|
+
seen_slugs = set()
|
|
178
|
+
variants = []
|
|
179
|
+
|
|
180
|
+
for inst in group:
|
|
181
|
+
if inst["slug"] == canonical["slug"]:
|
|
182
|
+
continue
|
|
183
|
+
if inst["slug"] in seen_slugs:
|
|
184
|
+
continue
|
|
185
|
+
seen_slugs.add(inst["slug"])
|
|
186
|
+
|
|
187
|
+
similarity = cu.component_similarity(canonical["html"], inst["html"])
|
|
188
|
+
differences = _describe_differences(canonical, inst)
|
|
189
|
+
|
|
190
|
+
variants.append({
|
|
191
|
+
"slug": inst["slug"],
|
|
192
|
+
"similarity": round(similarity, 3),
|
|
193
|
+
"differences": differences,
|
|
194
|
+
"node_count": inst["node_count"],
|
|
195
|
+
})
|
|
196
|
+
|
|
197
|
+
# Sort by similarity descending
|
|
198
|
+
variants.sort(key=lambda v: -v["similarity"])
|
|
199
|
+
return variants
|
|
200
|
+
|
|
201
|
+
|
|
202
|
+
def _describe_differences(canonical: dict, variant: dict) -> str:
|
|
203
|
+
"""Generate a human-readable description of differences between two component versions."""
|
|
204
|
+
diffs = []
|
|
205
|
+
|
|
206
|
+
# Node count difference
|
|
207
|
+
node_diff = variant["node_count"] - canonical["node_count"]
|
|
208
|
+
if node_diff < -2:
|
|
209
|
+
diffs.append(f"{abs(node_diff)} fewer DOM nodes")
|
|
210
|
+
elif node_diff > 2:
|
|
211
|
+
diffs.append(f"{node_diff} more DOM nodes")
|
|
212
|
+
|
|
213
|
+
# Text content differences
|
|
214
|
+
canon_text = canonical["text"]
|
|
215
|
+
var_text = variant["text"]
|
|
216
|
+
if canon_text != var_text:
|
|
217
|
+
# Find missing/added text fragments
|
|
218
|
+
canon_words = set(canon_text.lower().split())
|
|
219
|
+
var_words = set(var_text.lower().split())
|
|
220
|
+
missing = canon_words - var_words
|
|
221
|
+
added = var_words - canon_words
|
|
222
|
+
|
|
223
|
+
# Filter noise (single chars, numbers)
|
|
224
|
+
missing = {w for w in missing if len(w) > 2 and not w.isdigit()}
|
|
225
|
+
added = {w for w in added if len(w) > 2 and not w.isdigit()}
|
|
226
|
+
|
|
227
|
+
if missing and len(missing) <= 5:
|
|
228
|
+
diffs.append(f"Missing text: {', '.join(sorted(missing)[:3])}")
|
|
229
|
+
if added and len(added) <= 5:
|
|
230
|
+
diffs.append(f"Added text: {', '.join(sorted(added)[:3])}")
|
|
231
|
+
|
|
232
|
+
# Class differences
|
|
233
|
+
canon_classes = set(cu._extract_all_classes(canonical["html"]))
|
|
234
|
+
var_classes = set(cu._extract_all_classes(variant["html"]))
|
|
235
|
+
class_diff = canon_classes.symmetric_difference(var_classes)
|
|
236
|
+
if class_diff and len(class_diff) <= 8:
|
|
237
|
+
diffs.append(f"Different classes: {len(class_diff)} changed")
|
|
238
|
+
|
|
239
|
+
if not diffs:
|
|
240
|
+
# Structure check
|
|
241
|
+
if canonical["signature"] != variant.get("signature", ""):
|
|
242
|
+
diffs.append("Different DOM structure")
|
|
243
|
+
else:
|
|
244
|
+
diffs.append("Minor text/style differences")
|
|
245
|
+
|
|
246
|
+
return "; ".join(diffs)
|
|
247
|
+
|
|
248
|
+
|
|
249
|
+
def _truncate_html(html: str, max_len: int) -> str:
|
|
250
|
+
"""Truncate HTML to max_len chars, trying to close at a tag boundary."""
|
|
251
|
+
if len(html) <= max_len:
|
|
252
|
+
return html
|
|
253
|
+
|
|
254
|
+
truncated = html[:max_len]
|
|
255
|
+
# Try to close at the last complete tag
|
|
256
|
+
last_close = truncated.rfind(">")
|
|
257
|
+
if last_close > max_len * 0.7:
|
|
258
|
+
truncated = truncated[:last_close + 1]
|
|
259
|
+
|
|
260
|
+
return truncated + "<!-- truncated -->"
|
|
261
|
+
|
|
262
|
+
|
|
263
|
+
# ─── CLI ──────────────────────────────────────────────────────────────────────
|
|
264
|
+
|
|
265
|
+
if __name__ == "__main__":
|
|
266
|
+
if len(sys.argv) < 2:
|
|
267
|
+
print("Usage: python detect_components.py /path/to/assets/", file=sys.stderr)
|
|
268
|
+
sys.exit(1)
|
|
269
|
+
|
|
270
|
+
assets = Path(sys.argv[1]).resolve()
|
|
271
|
+
if not assets.is_dir():
|
|
272
|
+
print(f"Error: '{assets}' is not a directory.", file=sys.stderr)
|
|
273
|
+
sys.exit(1)
|
|
274
|
+
|
|
275
|
+
threshold = float(sys.argv[2]) if len(sys.argv) > 2 else DEFAULT_SIMILARITY_THRESHOLD
|
|
276
|
+
|
|
277
|
+
result = detect_shared_components(assets, threshold)
|
|
278
|
+
if not result:
|
|
279
|
+
print("No shared components detected.", file=sys.stderr)
|
|
280
|
+
sys.exit(0)
|
|
281
|
+
|
|
282
|
+
print(json.dumps(result, ensure_ascii=False, indent=2))
|
|
283
|
+
comp_count = sum(1 for v in result.values() if v["found_in"] >= MIN_SHARED_SCREENS)
|
|
284
|
+
print(f"\n--- {comp_count} shared component type(s) detected ---", file=sys.stderr)
|