@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,913 @@
|
|
|
1
|
+
"""
|
|
2
|
+
extract_catalog.py — Extract atomic and composite UI components from Stitch HTMLs.
|
|
3
|
+
|
|
4
|
+
Produces a component catalog with deduplication, variant detection, and
|
|
5
|
+
design token extraction. Output is a JSON suitable for visual catalog generation.
|
|
6
|
+
|
|
7
|
+
Uses only stdlib (html.parser, re, json, hashlib). No external dependencies.
|
|
8
|
+
|
|
9
|
+
Usage:
|
|
10
|
+
# As a module (from build_showcase.py):
|
|
11
|
+
from extract_catalog import extract_component_catalog
|
|
12
|
+
|
|
13
|
+
# Standalone:
|
|
14
|
+
python extract_catalog.py /path/to/assets/
|
|
15
|
+
"""
|
|
16
|
+
import re
|
|
17
|
+
import sys
|
|
18
|
+
import json
|
|
19
|
+
from pathlib import Path
|
|
20
|
+
from html import unescape
|
|
21
|
+
|
|
22
|
+
# Sibling import
|
|
23
|
+
sys.path.insert(0, str(Path(__file__).parent))
|
|
24
|
+
import component_utils as cu
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
# ─── Tailwind / Head Extraction ──────────────────────────────────────────────
|
|
28
|
+
|
|
29
|
+
def extract_tailwind_head(assets_dir: Path) -> str:
|
|
30
|
+
"""
|
|
31
|
+
Extract Tailwind CDN script, config block, and Google Fonts links
|
|
32
|
+
from the first screen HTML.
|
|
33
|
+
|
|
34
|
+
All Stitch screens share the same Tailwind config, so reading one is enough.
|
|
35
|
+
|
|
36
|
+
Returns an HTML string safe to inject into a <head> section.
|
|
37
|
+
"""
|
|
38
|
+
html_files = sorted(assets_dir.glob("*.html"))
|
|
39
|
+
if not html_files:
|
|
40
|
+
return ""
|
|
41
|
+
|
|
42
|
+
try:
|
|
43
|
+
html = html_files[0].read_text(encoding="utf-8", errors="replace")
|
|
44
|
+
except Exception:
|
|
45
|
+
return ""
|
|
46
|
+
|
|
47
|
+
parts = []
|
|
48
|
+
|
|
49
|
+
# 1. Google Fonts <link> tags
|
|
50
|
+
for m in re.finditer(
|
|
51
|
+
r'<link[^>]*href="[^"]*fonts\.googleapis\.com[^"]*"[^>]*/?\s*>',
|
|
52
|
+
html, re.IGNORECASE,
|
|
53
|
+
):
|
|
54
|
+
parts.append(m.group(0))
|
|
55
|
+
|
|
56
|
+
# Also grab preconnect links for fonts
|
|
57
|
+
for m in re.finditer(
|
|
58
|
+
r'<link[^>]*rel="preconnect"[^>]*href="[^"]*(?:fonts\.googleapis|fonts\.gstatic)[^"]*"[^>]*/?\s*>',
|
|
59
|
+
html, re.IGNORECASE,
|
|
60
|
+
):
|
|
61
|
+
tag = m.group(0)
|
|
62
|
+
if tag not in parts:
|
|
63
|
+
parts.append(tag)
|
|
64
|
+
|
|
65
|
+
# 2. Tailwind CDN <script src="...tailwindcss..."> tag
|
|
66
|
+
for m in re.finditer(
|
|
67
|
+
r'<script[^>]*src="[^"]*tailwindcss[^"]*"[^>]*>\s*</script>',
|
|
68
|
+
html, re.IGNORECASE,
|
|
69
|
+
):
|
|
70
|
+
parts.append(m.group(0))
|
|
71
|
+
|
|
72
|
+
# 3. Tailwind config <script> block (may have id="tailwind-config" or inline)
|
|
73
|
+
# Match script blocks containing "tailwind.config"
|
|
74
|
+
for m in re.finditer(
|
|
75
|
+
r'<script[^>]*>([^<]*tailwind\.config\s*=\s*\{.*?)</script>',
|
|
76
|
+
html, re.DOTALL | re.IGNORECASE,
|
|
77
|
+
):
|
|
78
|
+
parts.append(f"<script>{m.group(1)}</script>")
|
|
79
|
+
|
|
80
|
+
return "\n ".join(parts)
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
# ─── Atomic Component Extractors ──────────────────────────────────────────────
|
|
84
|
+
|
|
85
|
+
def _extract_buttons(html: str, slug: str) -> list:
|
|
86
|
+
"""Extract button components: <button>, <a.btn>, <input type=submit>."""
|
|
87
|
+
buttons = []
|
|
88
|
+
|
|
89
|
+
patterns = [
|
|
90
|
+
(r"<button[^>]*>.*?</button>", "button"),
|
|
91
|
+
(r'<a[^>]*class="[^"]*\bbtn\b[^"]*"[^>]*>.*?</a>', "link-button"),
|
|
92
|
+
(r'<a[^>]*role="button"[^>]*>.*?</a>', "link-button"),
|
|
93
|
+
(r'<input[^>]*type="submit"[^>]*/?\s*>', "submit"),
|
|
94
|
+
]
|
|
95
|
+
|
|
96
|
+
for pattern, btn_type in patterns:
|
|
97
|
+
for m in re.finditer(pattern, html, re.DOTALL | re.IGNORECASE):
|
|
98
|
+
block = m.group(0)
|
|
99
|
+
text = cu.strip_tags(block).strip()
|
|
100
|
+
if not text or len(text) > 100:
|
|
101
|
+
continue
|
|
102
|
+
|
|
103
|
+
classes = cu.extract_css_classes_from_block(block)
|
|
104
|
+
variant = cu.detect_button_variant(block, classes)
|
|
105
|
+
styles = cu.extract_inline_styles(block)
|
|
106
|
+
|
|
107
|
+
buttons.append({
|
|
108
|
+
"type": "button",
|
|
109
|
+
"subtype": btn_type,
|
|
110
|
+
"variant": variant,
|
|
111
|
+
"text": text,
|
|
112
|
+
"html": block,
|
|
113
|
+
"hash": cu.html_hash(block),
|
|
114
|
+
"classes": classes,
|
|
115
|
+
"styles": _pick_style_props(styles, ["background-color", "background", "color",
|
|
116
|
+
"border-radius", "padding", "font-size"]),
|
|
117
|
+
"found_in": [slug],
|
|
118
|
+
})
|
|
119
|
+
|
|
120
|
+
return buttons
|
|
121
|
+
|
|
122
|
+
|
|
123
|
+
def _extract_headings(html: str, slug: str) -> list:
|
|
124
|
+
"""Extract heading components: h1-h6."""
|
|
125
|
+
headings = []
|
|
126
|
+
|
|
127
|
+
for m in re.finditer(r"<(h[1-6])[^>]*>(.*?)</\1>", html, re.DOTALL | re.IGNORECASE):
|
|
128
|
+
tag = m.group(1).lower()
|
|
129
|
+
block = m.group(0)
|
|
130
|
+
text = cu.strip_tags(m.group(2)).strip()
|
|
131
|
+
if not text or len(text) > 200:
|
|
132
|
+
continue
|
|
133
|
+
|
|
134
|
+
# Get the full tag with attributes
|
|
135
|
+
classes = cu.extract_css_classes_from_block(block)
|
|
136
|
+
styles = cu.extract_inline_styles(block)
|
|
137
|
+
|
|
138
|
+
headings.append({
|
|
139
|
+
"type": "heading",
|
|
140
|
+
"subtype": tag,
|
|
141
|
+
"text": text,
|
|
142
|
+
"html": block,
|
|
143
|
+
"hash": cu.html_hash(block),
|
|
144
|
+
"classes": classes,
|
|
145
|
+
"styles": _pick_style_props(styles, ["font-size", "font-weight", "color",
|
|
146
|
+
"font-family", "line-height"]),
|
|
147
|
+
"found_in": [slug],
|
|
148
|
+
})
|
|
149
|
+
|
|
150
|
+
return headings
|
|
151
|
+
|
|
152
|
+
|
|
153
|
+
def _extract_inputs(html: str, slug: str) -> list:
|
|
154
|
+
"""Extract form input components: input, select, textarea."""
|
|
155
|
+
inputs = []
|
|
156
|
+
|
|
157
|
+
# Input fields
|
|
158
|
+
for m in re.finditer(r"<input[^>]*/?\s*>", html, re.IGNORECASE):
|
|
159
|
+
block = m.group(0)
|
|
160
|
+
input_type = re.search(r'type="([^"]*)"', block, re.IGNORECASE)
|
|
161
|
+
input_type = input_type.group(1).lower() if input_type else "text"
|
|
162
|
+
|
|
163
|
+
# Skip hidden, submit (captured as buttons), and checkbox/radio (too simple)
|
|
164
|
+
if input_type in ("hidden", "submit"):
|
|
165
|
+
continue
|
|
166
|
+
|
|
167
|
+
placeholder = re.search(r'placeholder="([^"]*)"', block, re.IGNORECASE)
|
|
168
|
+
label_text = placeholder.group(1) if placeholder else input_type
|
|
169
|
+
|
|
170
|
+
classes = cu.extract_css_classes_from_block(block)
|
|
171
|
+
styles = cu.extract_inline_styles(block)
|
|
172
|
+
|
|
173
|
+
inputs.append({
|
|
174
|
+
"type": "input",
|
|
175
|
+
"subtype": input_type,
|
|
176
|
+
"text": label_text,
|
|
177
|
+
"html": block,
|
|
178
|
+
"hash": cu.html_hash(block),
|
|
179
|
+
"classes": classes,
|
|
180
|
+
"styles": _pick_style_props(styles, ["border", "border-radius", "padding",
|
|
181
|
+
"background-color", "color", "font-size"]),
|
|
182
|
+
"found_in": [slug],
|
|
183
|
+
})
|
|
184
|
+
|
|
185
|
+
# Textareas
|
|
186
|
+
for m in re.finditer(r"<textarea[^>]*>.*?</textarea>", html, re.DOTALL | re.IGNORECASE):
|
|
187
|
+
block = m.group(0)
|
|
188
|
+
placeholder = re.search(r'placeholder="([^"]*)"', block, re.IGNORECASE)
|
|
189
|
+
label_text = placeholder.group(1) if placeholder else "textarea"
|
|
190
|
+
|
|
191
|
+
inputs.append({
|
|
192
|
+
"type": "input",
|
|
193
|
+
"subtype": "textarea",
|
|
194
|
+
"text": label_text,
|
|
195
|
+
"html": block,
|
|
196
|
+
"hash": cu.html_hash(block),
|
|
197
|
+
"classes": cu.extract_css_classes_from_block(block),
|
|
198
|
+
"styles": cu.extract_inline_styles(block),
|
|
199
|
+
"found_in": [slug],
|
|
200
|
+
})
|
|
201
|
+
|
|
202
|
+
# Selects
|
|
203
|
+
for m in re.finditer(r"<select[^>]*>.*?</select>", html, re.DOTALL | re.IGNORECASE):
|
|
204
|
+
block = m.group(0)
|
|
205
|
+
# Get first option text
|
|
206
|
+
opt = re.search(r"<option[^>]*>(.*?)</option>", block, re.DOTALL | re.IGNORECASE)
|
|
207
|
+
label_text = cu.strip_tags(opt.group(1)).strip() if opt else "select"
|
|
208
|
+
|
|
209
|
+
inputs.append({
|
|
210
|
+
"type": "input",
|
|
211
|
+
"subtype": "select",
|
|
212
|
+
"text": label_text,
|
|
213
|
+
"html": block,
|
|
214
|
+
"hash": cu.html_hash(block),
|
|
215
|
+
"classes": cu.extract_css_classes_from_block(block),
|
|
216
|
+
"styles": cu.extract_inline_styles(block),
|
|
217
|
+
"found_in": [slug],
|
|
218
|
+
})
|
|
219
|
+
|
|
220
|
+
return inputs
|
|
221
|
+
|
|
222
|
+
|
|
223
|
+
def _extract_badges(html: str, slug: str) -> list:
|
|
224
|
+
"""Extract badge/tag/pill components."""
|
|
225
|
+
badges = []
|
|
226
|
+
|
|
227
|
+
patterns = [
|
|
228
|
+
r'<span[^>]*class="[^"]*\b(?:badge|tag|pill|chip|label)\b[^"]*"[^>]*>.*?</span>',
|
|
229
|
+
r'<div[^>]*class="[^"]*\b(?:badge|tag|pill|chip)\b[^"]*"[^>]*>.*?</div>',
|
|
230
|
+
]
|
|
231
|
+
|
|
232
|
+
for pattern in patterns:
|
|
233
|
+
for m in re.finditer(pattern, html, re.DOTALL | re.IGNORECASE):
|
|
234
|
+
block = m.group(0)
|
|
235
|
+
text = cu.strip_tags(block).strip()
|
|
236
|
+
if not text or len(text) > 50:
|
|
237
|
+
continue
|
|
238
|
+
|
|
239
|
+
badges.append({
|
|
240
|
+
"type": "badge",
|
|
241
|
+
"subtype": "badge",
|
|
242
|
+
"text": text,
|
|
243
|
+
"html": block,
|
|
244
|
+
"hash": cu.html_hash(block),
|
|
245
|
+
"classes": cu.extract_css_classes_from_block(block),
|
|
246
|
+
"styles": cu.extract_inline_styles(block),
|
|
247
|
+
"found_in": [slug],
|
|
248
|
+
})
|
|
249
|
+
|
|
250
|
+
return badges
|
|
251
|
+
|
|
252
|
+
|
|
253
|
+
def _extract_links(html: str, slug: str) -> list:
|
|
254
|
+
"""Extract standalone link components (not inside nav or buttons)."""
|
|
255
|
+
links = []
|
|
256
|
+
|
|
257
|
+
# Remove nav blocks to avoid capturing navigation links
|
|
258
|
+
clean_html = re.sub(r"<nav[^>]*>.*?</nav>", "", html, flags=re.DOTALL | re.IGNORECASE)
|
|
259
|
+
# Remove button-like links (already captured)
|
|
260
|
+
clean_html = re.sub(r'<a[^>]*class="[^"]*\bbtn\b[^"]*"[^>]*>.*?</a>', "", clean_html, flags=re.DOTALL | re.IGNORECASE)
|
|
261
|
+
clean_html = re.sub(r'<a[^>]*role="button"[^>]*>.*?</a>', "", clean_html, flags=re.DOTALL | re.IGNORECASE)
|
|
262
|
+
|
|
263
|
+
for m in re.finditer(r"<a[^>]*>.*?</a>", clean_html, re.DOTALL | re.IGNORECASE):
|
|
264
|
+
block = m.group(0)
|
|
265
|
+
text = cu.strip_tags(block).strip()
|
|
266
|
+
if not text or len(text) > 80 or len(text) < 2:
|
|
267
|
+
continue
|
|
268
|
+
|
|
269
|
+
# Skip if it's just an image link
|
|
270
|
+
if re.search(r"<img[^>]*>", block, re.IGNORECASE) and not text.replace(" ", ""):
|
|
271
|
+
continue
|
|
272
|
+
|
|
273
|
+
links.append({
|
|
274
|
+
"type": "link",
|
|
275
|
+
"subtype": "link",
|
|
276
|
+
"text": text,
|
|
277
|
+
"html": block,
|
|
278
|
+
"hash": cu.html_hash(block),
|
|
279
|
+
"classes": cu.extract_css_classes_from_block(block),
|
|
280
|
+
"styles": cu.extract_inline_styles(block),
|
|
281
|
+
"found_in": [slug],
|
|
282
|
+
})
|
|
283
|
+
|
|
284
|
+
return links
|
|
285
|
+
|
|
286
|
+
|
|
287
|
+
def _extract_icons(html: str, slug: str) -> list:
|
|
288
|
+
"""Extract icon components: Material Symbols spans, icon SVGs."""
|
|
289
|
+
icons = []
|
|
290
|
+
|
|
291
|
+
# Material Symbols
|
|
292
|
+
for m in re.finditer(
|
|
293
|
+
r'<span[^>]*class="[^"]*\bmaterial[_-]symbols?[^"]*"[^>]*>(.*?)</span>',
|
|
294
|
+
html, re.DOTALL | re.IGNORECASE,
|
|
295
|
+
):
|
|
296
|
+
block = m.group(0)
|
|
297
|
+
name = cu.strip_tags(m.group(1)).strip()
|
|
298
|
+
if not name:
|
|
299
|
+
continue
|
|
300
|
+
|
|
301
|
+
icons.append({
|
|
302
|
+
"type": "icon",
|
|
303
|
+
"subtype": "material-symbols",
|
|
304
|
+
"text": name,
|
|
305
|
+
"html": block,
|
|
306
|
+
"hash": cu.html_hash(block),
|
|
307
|
+
"classes": cu.extract_css_classes_from_block(block),
|
|
308
|
+
"styles": {},
|
|
309
|
+
"found_in": [slug],
|
|
310
|
+
})
|
|
311
|
+
|
|
312
|
+
# SVG icons with icon class
|
|
313
|
+
for m in re.finditer(
|
|
314
|
+
r'<svg[^>]*class="[^"]*\bicon\b[^"]*"[^>]*>.*?</svg>',
|
|
315
|
+
html, re.DOTALL | re.IGNORECASE,
|
|
316
|
+
):
|
|
317
|
+
block = m.group(0)
|
|
318
|
+
icons.append({
|
|
319
|
+
"type": "icon",
|
|
320
|
+
"subtype": "svg",
|
|
321
|
+
"text": "svg-icon",
|
|
322
|
+
"html": block,
|
|
323
|
+
"hash": cu.html_hash(block),
|
|
324
|
+
"classes": [],
|
|
325
|
+
"styles": {},
|
|
326
|
+
"found_in": [slug],
|
|
327
|
+
})
|
|
328
|
+
|
|
329
|
+
return icons
|
|
330
|
+
|
|
331
|
+
|
|
332
|
+
# ─── Composite Component Extractors ──────────────────────────────────────────
|
|
333
|
+
|
|
334
|
+
def _extract_cards(html: str, slug: str) -> list:
|
|
335
|
+
"""Extract card components: divs with card/shadow classes containing image + text."""
|
|
336
|
+
cards = []
|
|
337
|
+
|
|
338
|
+
patterns = [
|
|
339
|
+
r'<div[^>]*class="[^"]*\b(?:card|shadow)\b[^"]*"[^>]*>.*?</div>\s*</div>',
|
|
340
|
+
r'<article[^>]*class="[^"]*\b(?:card)\b[^"]*"[^>]*>.*?</article>',
|
|
341
|
+
r'<div[^>]*class="[^"]*\bcard\b[^"]*"[^>]*>(?:(?!<div[^>]*class="[^"]*\bcard\b).)*?</div>',
|
|
342
|
+
]
|
|
343
|
+
|
|
344
|
+
for pattern in patterns:
|
|
345
|
+
for m in re.finditer(pattern, html, re.DOTALL | re.IGNORECASE):
|
|
346
|
+
block = m.group(0)
|
|
347
|
+
text = cu.strip_tags(block).strip()
|
|
348
|
+
|
|
349
|
+
# Cards should have some substance (text + possibly image)
|
|
350
|
+
if len(text) < 10:
|
|
351
|
+
continue
|
|
352
|
+
# Skip if too large (probably a wrapper, not a card)
|
|
353
|
+
if len(block) > 5000:
|
|
354
|
+
continue
|
|
355
|
+
|
|
356
|
+
has_image = bool(re.search(r"<img[^>]*>", block, re.IGNORECASE))
|
|
357
|
+
has_heading = bool(re.search(r"<h[1-6][^>]*>", block, re.IGNORECASE))
|
|
358
|
+
|
|
359
|
+
cards.append({
|
|
360
|
+
"type": "card",
|
|
361
|
+
"subtype": "card",
|
|
362
|
+
"text": text[:150],
|
|
363
|
+
"html": block,
|
|
364
|
+
"hash": cu.html_hash(block),
|
|
365
|
+
"classes": cu.extract_css_classes_from_block(block),
|
|
366
|
+
"styles": cu.extract_inline_styles(block),
|
|
367
|
+
"has_image": has_image,
|
|
368
|
+
"has_heading": has_heading,
|
|
369
|
+
"found_in": [slug],
|
|
370
|
+
})
|
|
371
|
+
|
|
372
|
+
return cards
|
|
373
|
+
|
|
374
|
+
|
|
375
|
+
def _extract_price_tables(html: str, slug: str) -> list:
|
|
376
|
+
"""Extract price table/pricing card components."""
|
|
377
|
+
tables = []
|
|
378
|
+
|
|
379
|
+
# Look for sections containing price indicators
|
|
380
|
+
price_pattern = re.compile(
|
|
381
|
+
r'<(?:div|section|article)[^>]*>(?=.*?(?:\$|€|£|price|plan|pricing|mes|month|year|año))'
|
|
382
|
+
r'.*?</(?:div|section|article)>',
|
|
383
|
+
re.DOTALL | re.IGNORECASE,
|
|
384
|
+
)
|
|
385
|
+
|
|
386
|
+
for m in price_pattern.finditer(html):
|
|
387
|
+
block = m.group(0)
|
|
388
|
+
text = cu.strip_tags(block).strip()
|
|
389
|
+
|
|
390
|
+
# Must have actual price content
|
|
391
|
+
if not re.search(r"[\$€£]\s*\d+|(?:free|gratis)", text, re.IGNORECASE):
|
|
392
|
+
continue
|
|
393
|
+
if len(block) > 8000:
|
|
394
|
+
continue
|
|
395
|
+
|
|
396
|
+
tables.append({
|
|
397
|
+
"type": "price_table",
|
|
398
|
+
"subtype": "pricing",
|
|
399
|
+
"text": text[:200],
|
|
400
|
+
"html": block,
|
|
401
|
+
"hash": cu.html_hash(block),
|
|
402
|
+
"classes": cu.extract_css_classes_from_block(block),
|
|
403
|
+
"styles": cu.extract_inline_styles(block),
|
|
404
|
+
"found_in": [slug],
|
|
405
|
+
})
|
|
406
|
+
|
|
407
|
+
return tables
|
|
408
|
+
|
|
409
|
+
|
|
410
|
+
def _extract_ctas(html: str, slug: str) -> list:
|
|
411
|
+
"""Extract CTA (call-to-action) sections: heading + text + prominent button."""
|
|
412
|
+
ctas = []
|
|
413
|
+
|
|
414
|
+
# Look for sections with heading + button combo
|
|
415
|
+
section_pattern = re.compile(
|
|
416
|
+
r'<(?:section|div)[^>]*>((?:(?!<(?:section|footer|header|nav)\b).)*?'
|
|
417
|
+
r'<h[1-3][^>]*>.*?</h[1-3]>'
|
|
418
|
+
r'(?:(?!<(?:section|footer|header|nav)\b).)*?'
|
|
419
|
+
r'<(?:button|a[^>]*class="[^"]*btn)[^>]*>.*?</(?:button|a)>'
|
|
420
|
+
r'(?:(?!<(?:section|footer|header|nav)\b).)*?'
|
|
421
|
+
r')</(?:section|div)>',
|
|
422
|
+
re.DOTALL | re.IGNORECASE,
|
|
423
|
+
)
|
|
424
|
+
|
|
425
|
+
for m in section_pattern.finditer(html):
|
|
426
|
+
block = m.group(0)
|
|
427
|
+
text = cu.strip_tags(block).strip()
|
|
428
|
+
|
|
429
|
+
if len(text) < 20 or len(block) > 5000:
|
|
430
|
+
continue
|
|
431
|
+
|
|
432
|
+
ctas.append({
|
|
433
|
+
"type": "cta",
|
|
434
|
+
"subtype": "cta",
|
|
435
|
+
"text": text[:200],
|
|
436
|
+
"html": block,
|
|
437
|
+
"hash": cu.html_hash(block),
|
|
438
|
+
"classes": cu.extract_css_classes_from_block(block),
|
|
439
|
+
"styles": cu.extract_inline_styles(block),
|
|
440
|
+
"found_in": [slug],
|
|
441
|
+
})
|
|
442
|
+
|
|
443
|
+
return ctas
|
|
444
|
+
|
|
445
|
+
|
|
446
|
+
def _extract_testimonials(html: str, slug: str) -> list:
|
|
447
|
+
"""Extract testimonial/quote blocks."""
|
|
448
|
+
testimonials = []
|
|
449
|
+
|
|
450
|
+
patterns = [
|
|
451
|
+
r'<(?:div|blockquote)[^>]*class="[^"]*\b(?:testimonial|quote|review)\b[^"]*"[^>]*>.*?</(?:div|blockquote)>',
|
|
452
|
+
r'<blockquote[^>]*>.*?</blockquote>',
|
|
453
|
+
]
|
|
454
|
+
|
|
455
|
+
for pattern in patterns:
|
|
456
|
+
for m in re.finditer(pattern, html, re.DOTALL | re.IGNORECASE):
|
|
457
|
+
block = m.group(0)
|
|
458
|
+
text = cu.strip_tags(block).strip()
|
|
459
|
+
if len(text) < 15 or len(block) > 5000:
|
|
460
|
+
continue
|
|
461
|
+
|
|
462
|
+
testimonials.append({
|
|
463
|
+
"type": "testimonial",
|
|
464
|
+
"subtype": "testimonial",
|
|
465
|
+
"text": text[:200],
|
|
466
|
+
"html": block,
|
|
467
|
+
"hash": cu.html_hash(block),
|
|
468
|
+
"classes": cu.extract_css_classes_from_block(block),
|
|
469
|
+
"styles": cu.extract_inline_styles(block),
|
|
470
|
+
"found_in": [slug],
|
|
471
|
+
})
|
|
472
|
+
|
|
473
|
+
return testimonials
|
|
474
|
+
|
|
475
|
+
|
|
476
|
+
def _extract_hero_sections(html: str, slug: str) -> list:
|
|
477
|
+
"""Extract hero sections: first large section with heading + text + button."""
|
|
478
|
+
heroes = []
|
|
479
|
+
|
|
480
|
+
# First section or large div at the top of body
|
|
481
|
+
body_match = re.search(r"<body[^>]*>(.*)", html, re.DOTALL | re.IGNORECASE)
|
|
482
|
+
if not body_match:
|
|
483
|
+
return []
|
|
484
|
+
|
|
485
|
+
body = body_match.group(1)
|
|
486
|
+
|
|
487
|
+
# Remove nav/header from consideration
|
|
488
|
+
body_clean = re.sub(r"<(?:nav|header)[^>]*>.*?</(?:nav|header)>", "", body, flags=re.DOTALL | re.IGNORECASE)
|
|
489
|
+
|
|
490
|
+
# Find first section/div with a heading
|
|
491
|
+
hero_pattern = re.compile(
|
|
492
|
+
r'<(?:section|div)[^>]*>(?:(?!<(?:section)\b).)*?<h[12][^>]*>.*?</h[12]>.*?</(?:section|div)>',
|
|
493
|
+
re.DOTALL | re.IGNORECASE,
|
|
494
|
+
)
|
|
495
|
+
|
|
496
|
+
m = hero_pattern.search(body_clean)
|
|
497
|
+
if m:
|
|
498
|
+
block = m.group(0)
|
|
499
|
+
text = cu.strip_tags(block).strip()
|
|
500
|
+
if 20 < len(text) < 1000 and len(block) < 8000:
|
|
501
|
+
has_button = bool(re.search(r"<(?:button|a[^>]*btn)", block, re.IGNORECASE))
|
|
502
|
+
heroes.append({
|
|
503
|
+
"type": "hero",
|
|
504
|
+
"subtype": "hero",
|
|
505
|
+
"text": text[:250],
|
|
506
|
+
"html": block,
|
|
507
|
+
"hash": cu.html_hash(block),
|
|
508
|
+
"classes": cu.extract_css_classes_from_block(block),
|
|
509
|
+
"styles": cu.extract_inline_styles(block),
|
|
510
|
+
"has_button": has_button,
|
|
511
|
+
"found_in": [slug],
|
|
512
|
+
})
|
|
513
|
+
|
|
514
|
+
return heroes
|
|
515
|
+
|
|
516
|
+
|
|
517
|
+
# ─── Design Token Extraction ─────────────────────────────────────────────────
|
|
518
|
+
|
|
519
|
+
def _extract_design_tokens(html_files: list) -> dict:
|
|
520
|
+
"""
|
|
521
|
+
Extract design tokens (colors, fonts, border-radius) from all HTML files.
|
|
522
|
+
|
|
523
|
+
Returns aggregated tokens with usage frequency.
|
|
524
|
+
"""
|
|
525
|
+
colors = {}
|
|
526
|
+
fonts = {}
|
|
527
|
+
radii = {}
|
|
528
|
+
|
|
529
|
+
for html_path in html_files:
|
|
530
|
+
try:
|
|
531
|
+
html = html_path.read_text(encoding="utf-8", errors="replace")
|
|
532
|
+
except Exception:
|
|
533
|
+
continue
|
|
534
|
+
|
|
535
|
+
# Colors
|
|
536
|
+
for m in re.finditer(r"(?:color|background(?:-color)?)\s*:\s*(#[0-9a-fA-F]{3,8})\b", html):
|
|
537
|
+
c = m.group(1).upper()
|
|
538
|
+
if len(c) == 4:
|
|
539
|
+
c = f"#{c[1]*2}{c[2]*2}{c[3]*2}"
|
|
540
|
+
if c not in ("#000000", "#FFFFFF"):
|
|
541
|
+
colors[c] = colors.get(c, 0) + 1
|
|
542
|
+
|
|
543
|
+
# Also from CSS variables
|
|
544
|
+
for m in re.finditer(r"--[a-z\-]+\s*:\s*(#[0-9a-fA-F]{6})\b", html, re.IGNORECASE):
|
|
545
|
+
c = m.group(1).upper()
|
|
546
|
+
if c not in ("#000000", "#FFFFFF"):
|
|
547
|
+
colors[c] = colors.get(c, 0) + 1
|
|
548
|
+
|
|
549
|
+
# Fonts
|
|
550
|
+
for m in re.finditer(r"font-family:\s*['\"]?([A-Z][a-zA-Z\s]+)", html):
|
|
551
|
+
f = m.group(1).strip().rstrip(",")
|
|
552
|
+
if f.lower() not in ("sans-serif", "serif", "monospace", "system-ui", "inherit"):
|
|
553
|
+
fonts[f] = fonts.get(f, 0) + 1
|
|
554
|
+
|
|
555
|
+
for m in re.finditer(r"fonts\.googleapis\.com/css2?\?family=([^&\"' ]+)", html):
|
|
556
|
+
f = m.group(1).replace("+", " ").split(":")[0]
|
|
557
|
+
fonts[f] = fonts.get(f, 0) + 1
|
|
558
|
+
|
|
559
|
+
# Border radius
|
|
560
|
+
for m in re.finditer(r"border-radius\s*:\s*([^;}{\"]+)", html, re.IGNORECASE):
|
|
561
|
+
r_val = m.group(1).strip()
|
|
562
|
+
if r_val and r_val != "0":
|
|
563
|
+
radii[r_val] = radii.get(r_val, 0) + 1
|
|
564
|
+
|
|
565
|
+
# Sort by frequency, take top values
|
|
566
|
+
top_colors = sorted(colors.items(), key=lambda x: -x[1])[:12]
|
|
567
|
+
top_fonts = sorted(fonts.items(), key=lambda x: -x[1])[:5]
|
|
568
|
+
top_radii = sorted(radii.items(), key=lambda x: -x[1])[:6]
|
|
569
|
+
|
|
570
|
+
# Try to identify primary color (most used non-gray)
|
|
571
|
+
primary = None
|
|
572
|
+
for c, _ in top_colors:
|
|
573
|
+
hex_clean = c.lstrip("#")
|
|
574
|
+
try:
|
|
575
|
+
r, g, b = int(hex_clean[0:2], 16), int(hex_clean[2:4], 16), int(hex_clean[4:6], 16)
|
|
576
|
+
except (ValueError, IndexError):
|
|
577
|
+
continue
|
|
578
|
+
# Skip near-grays
|
|
579
|
+
if max(abs(r-g), abs(g-b), abs(r-b)) > 30:
|
|
580
|
+
primary = c
|
|
581
|
+
break
|
|
582
|
+
|
|
583
|
+
return {
|
|
584
|
+
"colors": {c: count for c, count in top_colors},
|
|
585
|
+
"primary": primary,
|
|
586
|
+
"fonts": [f for f, _ in top_fonts],
|
|
587
|
+
"border_radius": [r for r, _ in top_radii],
|
|
588
|
+
}
|
|
589
|
+
|
|
590
|
+
|
|
591
|
+
# ─── Context Detection ──────────────────────────────────────────────────────
|
|
592
|
+
|
|
593
|
+
_CONTEXT_TAGS = {"form", "section", "header", "footer", "nav", "aside", "main", "article"}
|
|
594
|
+
|
|
595
|
+
|
|
596
|
+
def _detect_context(html: str, match_start: int) -> str:
|
|
597
|
+
"""
|
|
598
|
+
Determine the semantic context of an extracted component by finding
|
|
599
|
+
its nearest ancestor semantic tag.
|
|
600
|
+
|
|
601
|
+
Returns a context string like 'form', 'header', 'nav', or 'content' (default).
|
|
602
|
+
"""
|
|
603
|
+
# Search backwards from match position for the nearest opening semantic tag
|
|
604
|
+
preceding = html[:match_start]
|
|
605
|
+
|
|
606
|
+
best_tag = "content"
|
|
607
|
+
best_pos = -1
|
|
608
|
+
|
|
609
|
+
for tag in _CONTEXT_TAGS:
|
|
610
|
+
# Find the last opening tag of this type before the match
|
|
611
|
+
pos = preceding.rfind(f"<{tag}")
|
|
612
|
+
if pos == -1:
|
|
613
|
+
pos = preceding.rfind(f"<{tag.upper()}")
|
|
614
|
+
if pos > best_pos:
|
|
615
|
+
# Make sure it hasn't been closed before our position
|
|
616
|
+
close_pos = preceding.rfind(f"</{tag}>", pos)
|
|
617
|
+
if close_pos == -1:
|
|
618
|
+
close_pos = preceding.rfind(f"</{tag.upper()}>", pos)
|
|
619
|
+
if close_pos == -1: # tag is still open at our position
|
|
620
|
+
best_tag = tag
|
|
621
|
+
best_pos = pos
|
|
622
|
+
|
|
623
|
+
return best_tag
|
|
624
|
+
|
|
625
|
+
|
|
626
|
+
def _extract_with_context(html: str, pattern: str, flags: int = re.DOTALL | re.IGNORECASE):
|
|
627
|
+
"""Yield (match, context) tuples for each regex match with its semantic context."""
|
|
628
|
+
for m in re.finditer(pattern, html, flags):
|
|
629
|
+
context = _detect_context(html, m.start())
|
|
630
|
+
yield m, context
|
|
631
|
+
|
|
632
|
+
|
|
633
|
+
# ─── Similarity Clustering for Atomics ──────────────────────────────────────
|
|
634
|
+
|
|
635
|
+
ATOMIC_SIMILARITY_THRESHOLD = 0.85
|
|
636
|
+
|
|
637
|
+
|
|
638
|
+
def _cluster_atomic_components(components: list) -> list:
|
|
639
|
+
"""
|
|
640
|
+
Cluster similar atomic components using weighted similarity scoring.
|
|
641
|
+
|
|
642
|
+
Groups components that are similar in structure (>= 85% threshold) and
|
|
643
|
+
share the same context (form buttons separate from CTA buttons).
|
|
644
|
+
|
|
645
|
+
Returns list of cluster dicts:
|
|
646
|
+
{
|
|
647
|
+
"canonical": <component>,
|
|
648
|
+
"variants": [<component>, ...],
|
|
649
|
+
"similarity": <float>,
|
|
650
|
+
"context": <str>,
|
|
651
|
+
}
|
|
652
|
+
"""
|
|
653
|
+
if not components:
|
|
654
|
+
return []
|
|
655
|
+
|
|
656
|
+
# Group by context first
|
|
657
|
+
by_context = {}
|
|
658
|
+
for comp in components:
|
|
659
|
+
ctx = comp.get("context", "content")
|
|
660
|
+
by_context.setdefault(ctx, []).append(comp)
|
|
661
|
+
|
|
662
|
+
clusters = []
|
|
663
|
+
for context, group in by_context.items():
|
|
664
|
+
# Greedy clustering within this context
|
|
665
|
+
context_clusters = []
|
|
666
|
+
for comp in group:
|
|
667
|
+
placed = False
|
|
668
|
+
for cluster in context_clusters:
|
|
669
|
+
rep = cluster[0]
|
|
670
|
+
score = cu.component_similarity(rep["html"], comp["html"])
|
|
671
|
+
if score >= ATOMIC_SIMILARITY_THRESHOLD:
|
|
672
|
+
cluster.append(comp)
|
|
673
|
+
placed = True
|
|
674
|
+
break
|
|
675
|
+
if not placed:
|
|
676
|
+
context_clusters.append([comp])
|
|
677
|
+
|
|
678
|
+
# Convert to cluster dicts with canonical selection
|
|
679
|
+
for group_members in context_clusters:
|
|
680
|
+
if len(group_members) < 1:
|
|
681
|
+
continue
|
|
682
|
+
|
|
683
|
+
canonical = _choose_atomic_canonical(group_members)
|
|
684
|
+
variants = [c for c in group_members if c is not canonical]
|
|
685
|
+
|
|
686
|
+
# Compute similarity scores for variants
|
|
687
|
+
variant_data = []
|
|
688
|
+
for v in variants:
|
|
689
|
+
sim = cu.component_similarity(canonical["html"], v["html"])
|
|
690
|
+
variant_data.append({**v, "_similarity": round(sim, 3)})
|
|
691
|
+
|
|
692
|
+
clusters.append({
|
|
693
|
+
"canonical": canonical,
|
|
694
|
+
"variants": variant_data,
|
|
695
|
+
"context": context,
|
|
696
|
+
})
|
|
697
|
+
|
|
698
|
+
return clusters
|
|
699
|
+
|
|
700
|
+
|
|
701
|
+
def _choose_atomic_canonical(group: list) -> dict:
|
|
702
|
+
"""
|
|
703
|
+
Choose the canonical version of an atomic component.
|
|
704
|
+
|
|
705
|
+
Priority: home-like slug → most found_in screens → most DOM nodes → first.
|
|
706
|
+
"""
|
|
707
|
+
HOME_SLUGS = {"home", "main", "dashboard", "inicio", "principal", "landing", "index"}
|
|
708
|
+
|
|
709
|
+
def sort_key(comp):
|
|
710
|
+
slugs = comp.get("found_in", [])
|
|
711
|
+
is_home = any(any(h in s for h in HOME_SLUGS) for s in slugs)
|
|
712
|
+
node_count = cu.count_dom_nodes(comp.get("html", ""))
|
|
713
|
+
return (-int(is_home), -len(slugs), -node_count)
|
|
714
|
+
|
|
715
|
+
return sorted(group, key=sort_key)[0]
|
|
716
|
+
|
|
717
|
+
|
|
718
|
+
# ─── Main Catalog Builder ────────────────────────────────────────────────────
|
|
719
|
+
|
|
720
|
+
def extract_component_catalog(assets_dir: Path) -> dict:
|
|
721
|
+
"""
|
|
722
|
+
Extract all atomic and composite components from screen HTMLs.
|
|
723
|
+
|
|
724
|
+
Args:
|
|
725
|
+
assets_dir: Directory containing screen .html files
|
|
726
|
+
|
|
727
|
+
Returns:
|
|
728
|
+
Complete catalog dict with 'atomic', 'composite', 'design_tokens', and 'clusters' keys.
|
|
729
|
+
"""
|
|
730
|
+
html_files = sorted(assets_dir.glob("*.html"))
|
|
731
|
+
if not html_files:
|
|
732
|
+
return {}
|
|
733
|
+
|
|
734
|
+
# Collect all components across screens
|
|
735
|
+
all_components = []
|
|
736
|
+
|
|
737
|
+
for html_path in html_files:
|
|
738
|
+
slug = html_path.stem
|
|
739
|
+
try:
|
|
740
|
+
html = html_path.read_text(encoding="utf-8", errors="replace")
|
|
741
|
+
except Exception:
|
|
742
|
+
continue
|
|
743
|
+
|
|
744
|
+
# Strip invisible content for component extraction
|
|
745
|
+
clean = _strip_invisible(html)
|
|
746
|
+
|
|
747
|
+
# Atomic components (with context detection)
|
|
748
|
+
for extractor in (_extract_buttons, _extract_headings, _extract_inputs,
|
|
749
|
+
_extract_badges, _extract_links, _extract_icons):
|
|
750
|
+
components = extractor(clean, slug)
|
|
751
|
+
# Add context to each atomic component
|
|
752
|
+
for comp in components:
|
|
753
|
+
comp["context"] = _detect_context(clean, clean.find(comp["html"][:50])) if comp["html"] else "content"
|
|
754
|
+
all_components.extend(components)
|
|
755
|
+
|
|
756
|
+
# Composite components
|
|
757
|
+
all_components.extend(_extract_cards(clean, slug))
|
|
758
|
+
all_components.extend(_extract_price_tables(html, slug))
|
|
759
|
+
all_components.extend(_extract_ctas(clean, slug))
|
|
760
|
+
all_components.extend(_extract_testimonials(clean, slug))
|
|
761
|
+
all_components.extend(_extract_hero_sections(html, slug))
|
|
762
|
+
|
|
763
|
+
# Deduplicate by normalized HTML hash
|
|
764
|
+
deduped = _deduplicate_components(all_components)
|
|
765
|
+
|
|
766
|
+
# Organize into catalog structure
|
|
767
|
+
catalog = _organize_catalog(deduped)
|
|
768
|
+
|
|
769
|
+
# Build similarity clusters for atomic components
|
|
770
|
+
atomic_types = {"button", "heading", "input", "badge", "link", "icon"}
|
|
771
|
+
clusters_by_type = {}
|
|
772
|
+
for comp in deduped:
|
|
773
|
+
if comp["type"] in atomic_types:
|
|
774
|
+
clusters_by_type.setdefault(comp["type"], []).append(comp)
|
|
775
|
+
|
|
776
|
+
catalog["clusters"] = {}
|
|
777
|
+
for comp_type, components in clusters_by_type.items():
|
|
778
|
+
type_clusters = _cluster_atomic_components(components)
|
|
779
|
+
if type_clusters:
|
|
780
|
+
catalog["clusters"][f"{comp_type}s"] = [
|
|
781
|
+
{
|
|
782
|
+
"canonical": {
|
|
783
|
+
"text": c["canonical"].get("text", ""),
|
|
784
|
+
"variant": c["canonical"].get("variant", c["canonical"].get("subtype", "")),
|
|
785
|
+
"found_in": c["canonical"].get("found_in", []),
|
|
786
|
+
"html": c["canonical"].get("html", ""),
|
|
787
|
+
},
|
|
788
|
+
"variants": [
|
|
789
|
+
{
|
|
790
|
+
"text": v.get("text", ""),
|
|
791
|
+
"variant": v.get("variant", v.get("subtype", "")),
|
|
792
|
+
"found_in": v.get("found_in", []),
|
|
793
|
+
"similarity": v.get("_similarity", 0),
|
|
794
|
+
"html": v.get("html", ""),
|
|
795
|
+
}
|
|
796
|
+
for v in c["variants"]
|
|
797
|
+
],
|
|
798
|
+
"context": c["context"],
|
|
799
|
+
}
|
|
800
|
+
for c in type_clusters
|
|
801
|
+
]
|
|
802
|
+
|
|
803
|
+
# Extract design tokens
|
|
804
|
+
catalog["design_tokens"] = _extract_design_tokens(html_files)
|
|
805
|
+
|
|
806
|
+
return catalog
|
|
807
|
+
|
|
808
|
+
|
|
809
|
+
def _strip_invisible(html: str) -> str:
|
|
810
|
+
"""Remove script, style, and noscript blocks (keep SVG for icon detection)."""
|
|
811
|
+
html = re.sub(r"<script[^>]*>.*?</script>", "", html, flags=re.DOTALL | re.IGNORECASE)
|
|
812
|
+
html = re.sub(r"<style[^>]*>.*?</style>", "", html, flags=re.DOTALL | re.IGNORECASE)
|
|
813
|
+
html = re.sub(r"<noscript[^>]*>.*?</noscript>", "", html, flags=re.DOTALL | re.IGNORECASE)
|
|
814
|
+
html = re.sub(r"<!--.*?-->", "", html, flags=re.DOTALL)
|
|
815
|
+
return html
|
|
816
|
+
|
|
817
|
+
|
|
818
|
+
def _deduplicate_components(components: list) -> list:
|
|
819
|
+
"""
|
|
820
|
+
Deduplicate components by normalized HTML hash.
|
|
821
|
+
|
|
822
|
+
Same hash = same component structure, just merge found_in lists and increment count.
|
|
823
|
+
"""
|
|
824
|
+
seen = {}
|
|
825
|
+
for comp in components:
|
|
826
|
+
h = comp["hash"]
|
|
827
|
+
if h in seen:
|
|
828
|
+
# Merge found_in
|
|
829
|
+
existing = seen[h]
|
|
830
|
+
for slug in comp["found_in"]:
|
|
831
|
+
if slug not in existing["found_in"]:
|
|
832
|
+
existing["found_in"].append(slug)
|
|
833
|
+
else:
|
|
834
|
+
seen[h] = comp
|
|
835
|
+
|
|
836
|
+
return list(seen.values())
|
|
837
|
+
|
|
838
|
+
|
|
839
|
+
def _organize_catalog(components: list) -> dict:
|
|
840
|
+
"""Organize flat component list into atomic/composite categories."""
|
|
841
|
+
atomic_types = {"button", "heading", "input", "badge", "link", "icon"}
|
|
842
|
+
composite_types = {"card", "price_table", "cta", "testimonial", "hero"}
|
|
843
|
+
|
|
844
|
+
atomic = {}
|
|
845
|
+
composite = {}
|
|
846
|
+
|
|
847
|
+
for comp in components:
|
|
848
|
+
comp_type = comp["type"]
|
|
849
|
+
# Build output entry
|
|
850
|
+
entry = {
|
|
851
|
+
"variant": comp.get("variant", comp.get("subtype", "")),
|
|
852
|
+
"text": comp.get("text", ""),
|
|
853
|
+
"html": comp["html"],
|
|
854
|
+
"styles": comp.get("styles", {}),
|
|
855
|
+
"found_in": comp["found_in"],
|
|
856
|
+
"count": len(comp["found_in"]),
|
|
857
|
+
}
|
|
858
|
+
|
|
859
|
+
if comp_type in atomic_types:
|
|
860
|
+
key = f"{comp_type}s"
|
|
861
|
+
atomic.setdefault(key, []).append(entry)
|
|
862
|
+
elif comp_type in composite_types:
|
|
863
|
+
key = f"{comp_type}s"
|
|
864
|
+
composite.setdefault(key, []).append(entry)
|
|
865
|
+
|
|
866
|
+
# Sort each category: most frequent first
|
|
867
|
+
for key in atomic:
|
|
868
|
+
atomic[key].sort(key=lambda x: -x["count"])
|
|
869
|
+
for key in composite:
|
|
870
|
+
composite[key].sort(key=lambda x: -x["count"])
|
|
871
|
+
|
|
872
|
+
return {"atomic": atomic, "composite": composite}
|
|
873
|
+
|
|
874
|
+
|
|
875
|
+
# ─── Utility ──────────────────────────────────────────────────────────────────
|
|
876
|
+
|
|
877
|
+
def _pick_style_props(styles: dict, keys: list) -> dict:
|
|
878
|
+
"""Pick only the specified CSS property keys from a styles dict."""
|
|
879
|
+
return {k: v for k, v in styles.items() if k in keys}
|
|
880
|
+
|
|
881
|
+
|
|
882
|
+
# ─── CLI ──────────────────────────────────────────────────────────────────────
|
|
883
|
+
|
|
884
|
+
if __name__ == "__main__":
|
|
885
|
+
if len(sys.argv) < 2:
|
|
886
|
+
print("Usage: python extract_catalog.py /path/to/assets/", file=sys.stderr)
|
|
887
|
+
sys.exit(1)
|
|
888
|
+
|
|
889
|
+
assets = Path(sys.argv[1]).resolve()
|
|
890
|
+
if not assets.is_dir():
|
|
891
|
+
print(f"Error: '{assets}' is not a directory.", file=sys.stderr)
|
|
892
|
+
sys.exit(1)
|
|
893
|
+
|
|
894
|
+
result = extract_component_catalog(assets)
|
|
895
|
+
if not result:
|
|
896
|
+
print("No components found.", file=sys.stderr)
|
|
897
|
+
sys.exit(0)
|
|
898
|
+
|
|
899
|
+
# Print without html field (too verbose for CLI)
|
|
900
|
+
def _strip_html_field(obj):
|
|
901
|
+
if isinstance(obj, dict):
|
|
902
|
+
return {k: _strip_html_field(v) for k, v in obj.items() if k != "html"}
|
|
903
|
+
if isinstance(obj, list):
|
|
904
|
+
return [_strip_html_field(i) for i in obj]
|
|
905
|
+
return obj
|
|
906
|
+
|
|
907
|
+
summary = _strip_html_field(result)
|
|
908
|
+
print(json.dumps(summary, ensure_ascii=False, indent=2))
|
|
909
|
+
|
|
910
|
+
# Stats
|
|
911
|
+
atomic_count = sum(len(v) for v in result.get("atomic", {}).values())
|
|
912
|
+
composite_count = sum(len(v) for v in result.get("composite", {}).values())
|
|
913
|
+
print(f"\n--- {atomic_count} atomic + {composite_count} composite components ---", file=sys.stderr)
|