@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.
Files changed (73) hide show
  1. package/README.md +531 -0
  2. package/bin/aiskills.js +76 -0
  3. package/lib/cache.js +49 -0
  4. package/lib/cleanup.js +77 -0
  5. package/lib/commands/auto-update.js +131 -0
  6. package/lib/commands/doctor.js +139 -0
  7. package/lib/commands/list.js +77 -0
  8. package/lib/commands/skills.js +263 -0
  9. package/lib/commands/status.js +94 -0
  10. package/lib/commands/uninstall.js +182 -0
  11. package/lib/commands/update.js +149 -0
  12. package/lib/config.js +90 -0
  13. package/lib/downloader.js +110 -0
  14. package/lib/hooks.js +74 -0
  15. package/lib/installer.js +114 -0
  16. package/lib/platform.js +112 -0
  17. package/lib/prompts/checkboxCancel.js +264 -0
  18. package/lib/prompts/selectCancel.js +204 -0
  19. package/lib/symlink.js +154 -0
  20. package/lib/utils.js +49 -0
  21. package/package.json +61 -0
  22. package/skills/humaniza/SKILL.md +51 -0
  23. package/skills/humaniza/agents/openai.yaml +4 -0
  24. package/skills/humaniza/references/ai-patterns-es.md +51 -0
  25. package/skills/humaniza/references/checklist.md +9 -0
  26. package/skills/humaniza/references/examples.md +17 -0
  27. package/skills/humaniza/references/lexicon-es-mx.md +36 -0
  28. package/skills/humaniza/references/modes-es-mx.md +41 -0
  29. package/skills/humaniza/references/voice-es-mx.md +24 -0
  30. package/skills/refactoring-ui/SKILL.md +59 -0
  31. package/skills/refactoring-ui/references/01-design-process.md +72 -0
  32. package/skills/refactoring-ui/references/02-visual-hierarchy.md +84 -0
  33. package/skills/refactoring-ui/references/03-layout-spacing.md +69 -0
  34. package/skills/refactoring-ui/references/04-typography.md +70 -0
  35. package/skills/refactoring-ui/references/05-color.md +96 -0
  36. package/skills/refactoring-ui/references/06-depth-shadows.md +74 -0
  37. package/skills/refactoring-ui/references/07-images.md +75 -0
  38. package/skills/refactoring-ui/references/08-finishing-touches.md +91 -0
  39. package/skills/stitch-showcase/SKILL.md +411 -0
  40. package/skills/stitch-showcase/references/01-navbar.md +52 -0
  41. package/skills/stitch-showcase/references/02-hero.md +56 -0
  42. package/skills/stitch-showcase/references/03-design-system.md +102 -0
  43. package/skills/stitch-showcase/references/04-screen-gallery.md +102 -0
  44. package/skills/stitch-showcase/references/05-viewer-web.md +105 -0
  45. package/skills/stitch-showcase/references/06-viewer-mobile.md +104 -0
  46. package/skills/stitch-showcase/references/07-theme-system.md +77 -0
  47. package/skills/stitch-showcase/references/08-type-detection.md +81 -0
  48. package/skills/stitch-showcase/references/09-quality-standards.md +126 -0
  49. package/skills/stitch-showcase/references/10-component-standardization.md +40 -0
  50. package/skills/stitch-showcase/references/11-component-catalog.md +70 -0
  51. package/skills/stitch-showcase/references/catalog-template.html +841 -0
  52. package/skills/stitch-showcase/references/index.html +299 -0
  53. package/skills/stitch-showcase/references/viewer.html +412 -0
  54. package/skills/stitch-showcase/scripts/__pycache__/build_showcase.cpython-314.pyc +0 -0
  55. package/skills/stitch-showcase/scripts/__pycache__/component_utils.cpython-313.pyc +0 -0
  56. package/skills/stitch-showcase/scripts/__pycache__/detect_components.cpython-313.pyc +0 -0
  57. package/skills/stitch-showcase/scripts/__pycache__/extract_catalog.cpython-313.pyc +0 -0
  58. package/skills/stitch-showcase/scripts/__pycache__/extract_text.cpython-313.pyc +0 -0
  59. package/skills/stitch-showcase/scripts/__pycache__/extract_zips.cpython-313.pyc +0 -0
  60. package/skills/stitch-showcase/scripts/__pycache__/parse_design_md.cpython-313.pyc +0 -0
  61. package/skills/stitch-showcase/scripts/apply_canonical.py +238 -0
  62. package/skills/stitch-showcase/scripts/build_showcase.py +2103 -0
  63. package/skills/stitch-showcase/scripts/component_utils.py +398 -0
  64. package/skills/stitch-showcase/scripts/detect_components.py +284 -0
  65. package/skills/stitch-showcase/scripts/extract_catalog.py +913 -0
  66. package/skills/stitch-showcase/scripts/extract_text.py +268 -0
  67. package/skills/stitch-showcase/scripts/extract_zips.py +178 -0
  68. package/skills/stitch-showcase/scripts/parse_design_md.py +397 -0
  69. package/skills/vscode-extension-dev/SKILL.md +114 -0
  70. package/skills/vscode-extension-dev/references/api-patterns.md +625 -0
  71. package/skills/vscode-extension-dev/references/architecture.md +287 -0
  72. package/skills/vscode-extension-dev/references/package-json-schema.md +345 -0
  73. 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)