component-mapper 0.1.0__py3-none-any.whl
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.
- component_mapper/__init__.py +4 -0
- component_mapper/cache/__init__.py +0 -0
- component_mapper/cache/mapping_cache.py +72 -0
- component_mapper/config.py +247 -0
- component_mapper/mcp/__init__.py +0 -0
- component_mapper/mcp/official_client.py +182 -0
- component_mapper/mcp/registry_fetcher.py +214 -0
- component_mapper/models.py +159 -0
- component_mapper/pipeline.py +182 -0
- component_mapper/registry/__init__.py +0 -0
- component_mapper/registry/astro_generator.py +390 -0
- component_mapper/registry/custom_registry.py +127 -0
- component_mapper/registry/prop_mapper.py +370 -0
- component_mapper/registry/signature_index.py +694 -0
- component_mapper/stages/__init__.py +0 -0
- component_mapper/stages/astro_stage.py +122 -0
- component_mapper/stages/cache_lookup.py +93 -0
- component_mapper/stages/llm_mapper.py +509 -0
- component_mapper/stages/structural_match.py +145 -0
- component_mapper/utils/__init__.py +0 -0
- component_mapper/utils/similarity.py +69 -0
- component_mapper/utils/source_parser.py +292 -0
- component_mapper-0.1.0.dist-info/METADATA +16 -0
- component_mapper-0.1.0.dist-info/RECORD +25 -0
- component_mapper-0.1.0.dist-info/WHEEL +4 -0
|
@@ -0,0 +1,694 @@
|
|
|
1
|
+
import json
|
|
2
|
+
import logging
|
|
3
|
+
import time
|
|
4
|
+
from pathlib import Path
|
|
5
|
+
from typing import Any
|
|
6
|
+
|
|
7
|
+
from component_mapper.config import MapperSettings
|
|
8
|
+
from component_mapper.models import (
|
|
9
|
+
ComponentSignature,
|
|
10
|
+
RankedCandidate,
|
|
11
|
+
RegistrySource,
|
|
12
|
+
InteractivityMode,
|
|
13
|
+
CustomComponentDefinition,
|
|
14
|
+
)
|
|
15
|
+
from component_mapper.utils.source_parser import parse_source
|
|
16
|
+
from component_mapper.utils.similarity import (
|
|
17
|
+
skeleton_similarity,
|
|
18
|
+
jaccard_similarity,
|
|
19
|
+
composite_score,
|
|
20
|
+
rank_candidates,
|
|
21
|
+
)
|
|
22
|
+
from component_mapper.mcp.official_client import OfficialMCPClient
|
|
23
|
+
from component_mapper.mcp.registry_fetcher import RegistryFetcher
|
|
24
|
+
from segment_classifier.models import ComponentType
|
|
25
|
+
from segment_classifier.utils.html_normalizer import NormalizedSegment
|
|
26
|
+
|
|
27
|
+
logger = logging.getLogger(__name__)
|
|
28
|
+
|
|
29
|
+
# Static type map: ComponentType → list of Shadcn component names
|
|
30
|
+
TYPE_MAP: dict[ComponentType, list[str]] = {
|
|
31
|
+
ComponentType.LAYOUT_HEADER: ["navigation-menu"],
|
|
32
|
+
ComponentType.LAYOUT_FOOTER: [],
|
|
33
|
+
ComponentType.LAYOUT_NAV: ["navigation-menu", "menubar"],
|
|
34
|
+
ComponentType.LAYOUT_SIDEBAR: ["sidebar"],
|
|
35
|
+
ComponentType.LAYOUT_BREADCRUMB: ["breadcrumb"],
|
|
36
|
+
ComponentType.COLLECTION_PRODUCT_CARD: ["card"],
|
|
37
|
+
ComponentType.COLLECTION_BLOG_CARD: ["card"],
|
|
38
|
+
ComponentType.COLLECTION_NEWS_ITEM: ["card"],
|
|
39
|
+
ComponentType.COLLECTION_PRODUCT_LIST: ["table"],
|
|
40
|
+
ComponentType.COLLECTION_BLOG_LIST: ["card"],
|
|
41
|
+
ComponentType.COLLECTION_NEWS_LIST: ["card"],
|
|
42
|
+
ComponentType.SECTION_HERO: [],
|
|
43
|
+
ComponentType.SECTION_FEATURE_GRID: ["card"],
|
|
44
|
+
ComponentType.SECTION_TESTIMONIAL: ["card"],
|
|
45
|
+
ComponentType.SECTION_CTA: ["card", "button"],
|
|
46
|
+
ComponentType.SECTION_FAQ: ["accordion"],
|
|
47
|
+
ComponentType.SECTION_PRICING: ["card"],
|
|
48
|
+
ComponentType.UI_FORM: ["form", "input", "select"],
|
|
49
|
+
ComponentType.UI_MODAL: ["dialog", "sheet"],
|
|
50
|
+
ComponentType.UI_TABLE: ["table"],
|
|
51
|
+
ComponentType.UI_CAROUSEL: ["carousel"],
|
|
52
|
+
ComponentType.UI_PAGINATION: ["pagination", "bundui/pagination", "hextaui/pagination"],
|
|
53
|
+
ComponentType.UI_SEARCH: ["command"],
|
|
54
|
+
ComponentType.CONTENT_ARTICLE: [],
|
|
55
|
+
ComponentType.CONTENT_RICH_TEXT: [],
|
|
56
|
+
ComponentType.CONTENT_MEDIA: ["aspect-ratio"],
|
|
57
|
+
ComponentType.UNKNOWN: [],
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
# Known Shadcn components (fallback if MCP unavailable)
|
|
61
|
+
KNOWN_SHADCN_COMPONENTS = [
|
|
62
|
+
"accordion",
|
|
63
|
+
"alert",
|
|
64
|
+
"alert-dialog",
|
|
65
|
+
"aspect-ratio",
|
|
66
|
+
"avatar",
|
|
67
|
+
"badge",
|
|
68
|
+
"breadcrumb",
|
|
69
|
+
"button",
|
|
70
|
+
"calendar",
|
|
71
|
+
"card",
|
|
72
|
+
"carousel",
|
|
73
|
+
"checkbox",
|
|
74
|
+
"collapsible",
|
|
75
|
+
"command",
|
|
76
|
+
"context-menu",
|
|
77
|
+
"data-table",
|
|
78
|
+
"date-picker",
|
|
79
|
+
"dialog",
|
|
80
|
+
"drawer",
|
|
81
|
+
"dropdown-menu",
|
|
82
|
+
"form",
|
|
83
|
+
"hover-card",
|
|
84
|
+
"input",
|
|
85
|
+
"input-otp",
|
|
86
|
+
"label",
|
|
87
|
+
"menubar",
|
|
88
|
+
"navigation-menu",
|
|
89
|
+
"pagination",
|
|
90
|
+
"popover",
|
|
91
|
+
"progress",
|
|
92
|
+
"radio-group",
|
|
93
|
+
"resizable",
|
|
94
|
+
"scroll-area",
|
|
95
|
+
"select",
|
|
96
|
+
"separator",
|
|
97
|
+
"sheet",
|
|
98
|
+
"sidebar",
|
|
99
|
+
"skeleton",
|
|
100
|
+
"slider",
|
|
101
|
+
"sonner",
|
|
102
|
+
"switch",
|
|
103
|
+
"table",
|
|
104
|
+
"tabs",
|
|
105
|
+
"textarea",
|
|
106
|
+
"toast",
|
|
107
|
+
"toggle",
|
|
108
|
+
"toggle-group",
|
|
109
|
+
"tooltip",
|
|
110
|
+
]
|
|
111
|
+
|
|
112
|
+
# Component type hints for building signatures without source code
|
|
113
|
+
COMPONENT_HINTS: dict[str, dict[str, Any]] = {
|
|
114
|
+
"card": {
|
|
115
|
+
"compatible": [
|
|
116
|
+
ComponentType.COLLECTION_PRODUCT_CARD,
|
|
117
|
+
ComponentType.COLLECTION_BLOG_CARD,
|
|
118
|
+
ComponentType.SECTION_FEATURE_GRID,
|
|
119
|
+
ComponentType.SECTION_TESTIMONIAL,
|
|
120
|
+
ComponentType.SECTION_CTA,
|
|
121
|
+
ComponentType.SECTION_PRICING,
|
|
122
|
+
],
|
|
123
|
+
"skeleton": "div>[div>h3+p, div, div>button]",
|
|
124
|
+
"root": "div",
|
|
125
|
+
"classes": ["card", "content", "header", "footer"],
|
|
126
|
+
},
|
|
127
|
+
"navigation-menu": {
|
|
128
|
+
"compatible": [ComponentType.LAYOUT_HEADER, ComponentType.LAYOUT_NAV],
|
|
129
|
+
"skeleton": "nav>[ul>[li>a]]",
|
|
130
|
+
"root": "nav",
|
|
131
|
+
"classes": ["nav", "menu"],
|
|
132
|
+
},
|
|
133
|
+
"accordion": {
|
|
134
|
+
"compatible": [ComponentType.SECTION_FAQ],
|
|
135
|
+
"skeleton": "div>[div>button+div>p]",
|
|
136
|
+
"root": "div",
|
|
137
|
+
"classes": ["accordion", "item"],
|
|
138
|
+
},
|
|
139
|
+
"dialog": {
|
|
140
|
+
"compatible": [ComponentType.UI_MODAL],
|
|
141
|
+
"skeleton": "div>[div>h2+p+div>button]",
|
|
142
|
+
"root": "div",
|
|
143
|
+
"classes": ["modal", "dialog"],
|
|
144
|
+
},
|
|
145
|
+
"table": {
|
|
146
|
+
"compatible": [ComponentType.UI_TABLE, ComponentType.COLLECTION_PRODUCT_LIST],
|
|
147
|
+
"skeleton": "table>[thead>tr>th, tbody>tr>td]",
|
|
148
|
+
"root": "table",
|
|
149
|
+
"classes": ["table"],
|
|
150
|
+
},
|
|
151
|
+
"carousel": {
|
|
152
|
+
"compatible": [ComponentType.UI_CAROUSEL],
|
|
153
|
+
"skeleton": "div>[div>[div>img], button, button]",
|
|
154
|
+
"root": "div",
|
|
155
|
+
"classes": ["carousel"],
|
|
156
|
+
},
|
|
157
|
+
"form": {
|
|
158
|
+
"compatible": [ComponentType.UI_FORM],
|
|
159
|
+
"skeleton": "form>[div>label+input, button]",
|
|
160
|
+
"root": "form",
|
|
161
|
+
"classes": ["form"],
|
|
162
|
+
},
|
|
163
|
+
"command": {
|
|
164
|
+
"compatible": [ComponentType.UI_SEARCH],
|
|
165
|
+
"skeleton": "div>[input, div>[div>span]]",
|
|
166
|
+
"root": "div",
|
|
167
|
+
"classes": ["search", "command"],
|
|
168
|
+
},
|
|
169
|
+
"pagination": {
|
|
170
|
+
"compatible": [ComponentType.UI_PAGINATION],
|
|
171
|
+
"skeleton": "nav>[ul>[li>a]+li>a+[li>a]]",
|
|
172
|
+
"root": "nav",
|
|
173
|
+
"classes": ["pagination"],
|
|
174
|
+
"install": "npx shadcn@latest add pagination",
|
|
175
|
+
},
|
|
176
|
+
# ── External open-source registries (verified from ui.shadcn.com/docs/directory) ──
|
|
177
|
+
"bundui/pagination": {
|
|
178
|
+
"compatible": [ComponentType.UI_PAGINATION],
|
|
179
|
+
# Same nav>ul>li>a structure; bundui wraps the official pagination with
|
|
180
|
+
# its own styling tokens — skeleton is identical to the official component.
|
|
181
|
+
"skeleton": "nav>[ul>[li>a+li>a+li>a+li>span+li>a]]",
|
|
182
|
+
"root": "nav",
|
|
183
|
+
"classes": ["pagination"],
|
|
184
|
+
"install": "npx shadcn@latest add @bundui/pagination",
|
|
185
|
+
"registry": "bundui",
|
|
186
|
+
"namespace": "@bundui",
|
|
187
|
+
},
|
|
188
|
+
"hextaui/pagination": {
|
|
189
|
+
"compatible": [ComponentType.UI_PAGINATION],
|
|
190
|
+
# HextaUI pagination uses data-slot attributes and buttonVariants;
|
|
191
|
+
# structure matches official but adds touch-manipulation and tabular-nums.
|
|
192
|
+
"skeleton": "nav>[ul>[li>a+li>a+li>a+li>span+li>a]]",
|
|
193
|
+
"root": "nav",
|
|
194
|
+
"classes": ["pagination"],
|
|
195
|
+
"install": "npx shadcn@latest add @hextaui/pagination",
|
|
196
|
+
"registry": "hextaui",
|
|
197
|
+
"namespace": "@hextaui",
|
|
198
|
+
},
|
|
199
|
+
"breadcrumb": {
|
|
200
|
+
"compatible": [ComponentType.LAYOUT_BREADCRUMB],
|
|
201
|
+
"skeleton": "nav>[ol>[li>a+li>a]]",
|
|
202
|
+
"root": "nav",
|
|
203
|
+
"classes": ["breadcrumb"],
|
|
204
|
+
},
|
|
205
|
+
"sidebar": {
|
|
206
|
+
"compatible": [ComponentType.LAYOUT_SIDEBAR],
|
|
207
|
+
"skeleton": "aside>[div>[nav>ul>li>a]]",
|
|
208
|
+
"root": "aside",
|
|
209
|
+
"classes": ["sidebar", "nav"],
|
|
210
|
+
},
|
|
211
|
+
"sheet": {
|
|
212
|
+
"compatible": [ComponentType.UI_MODAL],
|
|
213
|
+
"skeleton": "div>[div>h2+p+div]",
|
|
214
|
+
"root": "div",
|
|
215
|
+
"classes": ["modal", "sheet"],
|
|
216
|
+
},
|
|
217
|
+
"aspect-ratio": {
|
|
218
|
+
"compatible": [ComponentType.CONTENT_MEDIA],
|
|
219
|
+
"skeleton": "div>[img]",
|
|
220
|
+
"root": "div",
|
|
221
|
+
"classes": ["media"],
|
|
222
|
+
},
|
|
223
|
+
"menubar": {
|
|
224
|
+
"compatible": [ComponentType.LAYOUT_NAV],
|
|
225
|
+
"skeleton": "div>[div>[button]]",
|
|
226
|
+
"root": "div",
|
|
227
|
+
"classes": ["menu", "nav"],
|
|
228
|
+
},
|
|
229
|
+
"select": {
|
|
230
|
+
"compatible": [ComponentType.UI_FORM],
|
|
231
|
+
"skeleton": "div>[button>span+span, div>[div>span]]",
|
|
232
|
+
"root": "div",
|
|
233
|
+
"classes": ["form", "select"],
|
|
234
|
+
},
|
|
235
|
+
"input": {
|
|
236
|
+
"compatible": [ComponentType.UI_FORM],
|
|
237
|
+
"skeleton": "input",
|
|
238
|
+
"root": "input",
|
|
239
|
+
"classes": ["form", "input"],
|
|
240
|
+
},
|
|
241
|
+
"button": {
|
|
242
|
+
"compatible": [ComponentType.SECTION_CTA],
|
|
243
|
+
"skeleton": "button>span",
|
|
244
|
+
"root": "button",
|
|
245
|
+
"classes": [],
|
|
246
|
+
},
|
|
247
|
+
"badge": {
|
|
248
|
+
"compatible": [],
|
|
249
|
+
"skeleton": "span",
|
|
250
|
+
"root": "span",
|
|
251
|
+
"classes": ["badge"],
|
|
252
|
+
},
|
|
253
|
+
"avatar": {
|
|
254
|
+
"compatible": [],
|
|
255
|
+
"skeleton": "span>[img, span]",
|
|
256
|
+
"root": "span",
|
|
257
|
+
"classes": [],
|
|
258
|
+
},
|
|
259
|
+
"tabs": {
|
|
260
|
+
"compatible": [],
|
|
261
|
+
"skeleton": "div>[div>[button], div>[div]]",
|
|
262
|
+
"root": "div",
|
|
263
|
+
"classes": [],
|
|
264
|
+
},
|
|
265
|
+
"separator": {"compatible": [], "skeleton": "div", "root": "div", "classes": []},
|
|
266
|
+
"skeleton": {"compatible": [], "skeleton": "div", "root": "div", "classes": []},
|
|
267
|
+
"progress": {
|
|
268
|
+
"compatible": [],
|
|
269
|
+
"skeleton": "div>[div]",
|
|
270
|
+
"root": "div",
|
|
271
|
+
"classes": [],
|
|
272
|
+
},
|
|
273
|
+
"slider": {
|
|
274
|
+
"compatible": [],
|
|
275
|
+
"skeleton": "div>[span+span+span]",
|
|
276
|
+
"root": "div",
|
|
277
|
+
"classes": [],
|
|
278
|
+
},
|
|
279
|
+
"checkbox": {
|
|
280
|
+
"compatible": [],
|
|
281
|
+
"skeleton": "button",
|
|
282
|
+
"root": "button",
|
|
283
|
+
"classes": [],
|
|
284
|
+
},
|
|
285
|
+
"radio-group": {
|
|
286
|
+
"compatible": [],
|
|
287
|
+
"skeleton": "div>[div>[button+label]]",
|
|
288
|
+
"root": "div",
|
|
289
|
+
"classes": ["form"],
|
|
290
|
+
},
|
|
291
|
+
"switch": {
|
|
292
|
+
"compatible": [],
|
|
293
|
+
"skeleton": "button>span",
|
|
294
|
+
"root": "button",
|
|
295
|
+
"classes": [],
|
|
296
|
+
},
|
|
297
|
+
"toggle": {"compatible": [], "skeleton": "button", "root": "button", "classes": []},
|
|
298
|
+
"tooltip": {
|
|
299
|
+
"compatible": [],
|
|
300
|
+
"skeleton": "div>[button, div>p]",
|
|
301
|
+
"root": "div",
|
|
302
|
+
"classes": [],
|
|
303
|
+
},
|
|
304
|
+
"popover": {
|
|
305
|
+
"compatible": [],
|
|
306
|
+
"skeleton": "div>[button, div>div]",
|
|
307
|
+
"root": "div",
|
|
308
|
+
"classes": [],
|
|
309
|
+
},
|
|
310
|
+
"hover-card": {
|
|
311
|
+
"compatible": [],
|
|
312
|
+
"skeleton": "div>[span, div>div]",
|
|
313
|
+
"root": "div",
|
|
314
|
+
"classes": [],
|
|
315
|
+
},
|
|
316
|
+
"alert": {
|
|
317
|
+
"compatible": [],
|
|
318
|
+
"skeleton": "div>[span+h5+p]",
|
|
319
|
+
"root": "div",
|
|
320
|
+
"classes": [],
|
|
321
|
+
},
|
|
322
|
+
"alert-dialog": {
|
|
323
|
+
"compatible": [ComponentType.UI_MODAL],
|
|
324
|
+
"skeleton": "div>[div>[h2+p+div>[button+button]]]",
|
|
325
|
+
"root": "div",
|
|
326
|
+
"classes": ["modal"],
|
|
327
|
+
},
|
|
328
|
+
"calendar": {
|
|
329
|
+
"compatible": [],
|
|
330
|
+
"skeleton": "div>[div>[button+div+button], table>[thead>tr>th, tbody>tr>td]]",
|
|
331
|
+
"root": "div",
|
|
332
|
+
"classes": [],
|
|
333
|
+
},
|
|
334
|
+
"collapsible": {
|
|
335
|
+
"compatible": [],
|
|
336
|
+
"skeleton": "div>[div>button, div]",
|
|
337
|
+
"root": "div",
|
|
338
|
+
"classes": [],
|
|
339
|
+
},
|
|
340
|
+
"context-menu": {
|
|
341
|
+
"compatible": [],
|
|
342
|
+
"skeleton": "div>[div>span]",
|
|
343
|
+
"root": "div",
|
|
344
|
+
"classes": ["menu"],
|
|
345
|
+
},
|
|
346
|
+
"dropdown-menu": {
|
|
347
|
+
"compatible": [],
|
|
348
|
+
"skeleton": "div>[button, div>[div>span]]",
|
|
349
|
+
"root": "div",
|
|
350
|
+
"classes": ["menu"],
|
|
351
|
+
},
|
|
352
|
+
"label": {"compatible": [], "skeleton": "label", "root": "label", "classes": []},
|
|
353
|
+
"scroll-area": {
|
|
354
|
+
"compatible": [],
|
|
355
|
+
"skeleton": "div>[div, div>div]",
|
|
356
|
+
"root": "div",
|
|
357
|
+
"classes": [],
|
|
358
|
+
},
|
|
359
|
+
"sonner": {
|
|
360
|
+
"compatible": [],
|
|
361
|
+
"skeleton": "section>[ol>[li]]",
|
|
362
|
+
"root": "section",
|
|
363
|
+
"classes": [],
|
|
364
|
+
},
|
|
365
|
+
"resizable": {
|
|
366
|
+
"compatible": [],
|
|
367
|
+
"skeleton": "div>[div+div+div]",
|
|
368
|
+
"root": "div",
|
|
369
|
+
"classes": [],
|
|
370
|
+
},
|
|
371
|
+
"drawer": {
|
|
372
|
+
"compatible": [ComponentType.UI_MODAL],
|
|
373
|
+
"skeleton": "div>[div>[div>h2+p+div]]",
|
|
374
|
+
"root": "div",
|
|
375
|
+
"classes": ["modal"],
|
|
376
|
+
},
|
|
377
|
+
"input-otp": {
|
|
378
|
+
"compatible": [],
|
|
379
|
+
"skeleton": "div>[div>[div>input]]",
|
|
380
|
+
"root": "div",
|
|
381
|
+
"classes": ["form"],
|
|
382
|
+
},
|
|
383
|
+
"toggle-group": {
|
|
384
|
+
"compatible": [],
|
|
385
|
+
"skeleton": "div>[button]",
|
|
386
|
+
"root": "div",
|
|
387
|
+
"classes": [],
|
|
388
|
+
},
|
|
389
|
+
"toast": {
|
|
390
|
+
"compatible": [],
|
|
391
|
+
"skeleton": "div>[div>[div>p]]",
|
|
392
|
+
"root": "div",
|
|
393
|
+
"classes": [],
|
|
394
|
+
},
|
|
395
|
+
"date-picker": {
|
|
396
|
+
"compatible": [],
|
|
397
|
+
"skeleton": "div>[button>span+span, div]",
|
|
398
|
+
"root": "div",
|
|
399
|
+
"classes": ["form"],
|
|
400
|
+
},
|
|
401
|
+
"data-table": {
|
|
402
|
+
"compatible": [ComponentType.COLLECTION_PRODUCT_LIST, ComponentType.UI_TABLE],
|
|
403
|
+
"skeleton": "div>[div>input, div>[table>[thead>tr>th, tbody>tr>td]], div>[button]]",
|
|
404
|
+
"root": "div",
|
|
405
|
+
"classes": ["table"],
|
|
406
|
+
},
|
|
407
|
+
}
|
|
408
|
+
|
|
409
|
+
|
|
410
|
+
def _build_signature_from_hints(name: str, hints: dict) -> ComponentSignature:
|
|
411
|
+
"""Build a ComponentSignature from known hints (no source parsing needed)."""
|
|
412
|
+
classes = hints.get("classes", [])
|
|
413
|
+
# For external registry components (name contains "/"), use the registry namespace
|
|
414
|
+
# for the install command and derive a clean Astro import from the base component name.
|
|
415
|
+
base_name = name.split("/")[-1] # "bundui/pagination" → "pagination"
|
|
416
|
+
is_external = "/" in name
|
|
417
|
+
astro_import = f"@/components/ui/{base_name}"
|
|
418
|
+
install_cmd = hints.get("install") or (
|
|
419
|
+
f"npx shadcn@latest add {hints.get('namespace', '')}/{base_name}"
|
|
420
|
+
if is_external
|
|
421
|
+
else f"npx shadcn@latest add {name}"
|
|
422
|
+
)
|
|
423
|
+
description = hints.get("description") or (
|
|
424
|
+
f"{hints.get('registry', 'External').capitalize()} {base_name} component"
|
|
425
|
+
if is_external
|
|
426
|
+
else f"Shadcn {name} component"
|
|
427
|
+
)
|
|
428
|
+
return ComponentSignature(
|
|
429
|
+
component_name=name,
|
|
430
|
+
registry_source=RegistrySource.SHADCN,
|
|
431
|
+
dom_skeleton=hints.get("skeleton", "div"),
|
|
432
|
+
root_element=hints.get("root", "div"),
|
|
433
|
+
required_children=[],
|
|
434
|
+
optional_children=[],
|
|
435
|
+
structural_class_tokens=classes,
|
|
436
|
+
typical_nesting_depth=hints.get("skeleton", "").count(">"),
|
|
437
|
+
child_tag_counts={},
|
|
438
|
+
unique_tag_count=0,
|
|
439
|
+
compatible_component_types=hints.get("compatible", []),
|
|
440
|
+
interactivity=InteractivityMode.STATIC,
|
|
441
|
+
description=description,
|
|
442
|
+
props=[],
|
|
443
|
+
astro_import=astro_import,
|
|
444
|
+
install_command=install_cmd,
|
|
445
|
+
requires_client_directive=False,
|
|
446
|
+
)
|
|
447
|
+
|
|
448
|
+
|
|
449
|
+
def _build_signature_from_parsed(name: str, parsed) -> ComponentSignature:
|
|
450
|
+
"""Build ComponentSignature from ParsedSource."""
|
|
451
|
+
hints = COMPONENT_HINTS.get(name, {})
|
|
452
|
+
return ComponentSignature(
|
|
453
|
+
component_name=name,
|
|
454
|
+
registry_source=RegistrySource.SHADCN,
|
|
455
|
+
dom_skeleton=parsed.dom_skeleton or hints.get("skeleton", "div"),
|
|
456
|
+
root_element=parsed.root_element or hints.get("root", "div"),
|
|
457
|
+
required_children=parsed.required_children,
|
|
458
|
+
optional_children=parsed.optional_children,
|
|
459
|
+
structural_class_tokens=parsed.structural_class_tokens
|
|
460
|
+
or hints.get("classes", []),
|
|
461
|
+
typical_nesting_depth=parsed.typical_nesting_depth,
|
|
462
|
+
child_tag_counts=parsed.child_tag_counts,
|
|
463
|
+
unique_tag_count=len(parsed.child_tag_counts),
|
|
464
|
+
compatible_component_types=hints.get("compatible", []),
|
|
465
|
+
interactivity=parsed.interactivity,
|
|
466
|
+
description=f"Shadcn {name} component",
|
|
467
|
+
props=parsed.props,
|
|
468
|
+
astro_import=f"@/components/ui/{name}",
|
|
469
|
+
install_command=f"npx shadcn@latest add {name}",
|
|
470
|
+
requires_client_directive=parsed.interactivity != InteractivityMode.STATIC,
|
|
471
|
+
)
|
|
472
|
+
|
|
473
|
+
|
|
474
|
+
class SignatureIndex:
|
|
475
|
+
def __init__(
|
|
476
|
+
self,
|
|
477
|
+
settings: MapperSettings,
|
|
478
|
+
fetcher: RegistryFetcher,
|
|
479
|
+
mcp_client: OfficialMCPClient,
|
|
480
|
+
):
|
|
481
|
+
self._index: dict[str, ComponentSignature] = {}
|
|
482
|
+
self._settings = settings
|
|
483
|
+
self._fetcher = fetcher
|
|
484
|
+
self._mcp = mcp_client
|
|
485
|
+
self._built = False
|
|
486
|
+
|
|
487
|
+
async def build(self) -> None:
|
|
488
|
+
"""Build full index. Load from cache if fresh, else rebuild."""
|
|
489
|
+
cache_path = Path(self._settings.signature_index.index_cache_path)
|
|
490
|
+
if await self._load_from_cache(cache_path):
|
|
491
|
+
logger.info(
|
|
492
|
+
"Loaded signature index from cache (%d components)", len(self._index)
|
|
493
|
+
)
|
|
494
|
+
return
|
|
495
|
+
logger.info("Rebuilding signature index — this may take 5-15s on first run")
|
|
496
|
+
await self._rebuild()
|
|
497
|
+
await self._persist(cache_path)
|
|
498
|
+
self._built = True
|
|
499
|
+
|
|
500
|
+
async def _rebuild(self) -> None:
|
|
501
|
+
"""Fetch all components, parse sources, populate index."""
|
|
502
|
+
# ── Official Shadcn components ────────────────────────────────────────
|
|
503
|
+
mcp_names = await self._mcp.list_components()
|
|
504
|
+
names = mcp_names if mcp_names else KNOWN_SHADCN_COMPONENTS
|
|
505
|
+
logger.info("Building signatures for %d Shadcn components", len(names))
|
|
506
|
+
|
|
507
|
+
source_map = await self._fetcher.fetch_many(names, RegistrySource.SHADCN)
|
|
508
|
+
|
|
509
|
+
for name in names:
|
|
510
|
+
try:
|
|
511
|
+
reg_data = source_map.get(name, {})
|
|
512
|
+
files = reg_data.get("files", [])
|
|
513
|
+
source_code = files[0].get("content", "") if files else ""
|
|
514
|
+
|
|
515
|
+
if source_code:
|
|
516
|
+
parsed = parse_source(source_code)
|
|
517
|
+
sig = _build_signature_from_parsed(name, parsed)
|
|
518
|
+
else:
|
|
519
|
+
hints = COMPONENT_HINTS.get(
|
|
520
|
+
name,
|
|
521
|
+
{"skeleton": "div", "root": "div", "compatible": [], "classes": []},
|
|
522
|
+
)
|
|
523
|
+
sig = _build_signature_from_hints(name, hints)
|
|
524
|
+
|
|
525
|
+
self._index[name] = sig
|
|
526
|
+
except Exception as exc:
|
|
527
|
+
logger.warning("Failed to build signature for %s: %s", name, exc)
|
|
528
|
+
hints = COMPONENT_HINTS.get(
|
|
529
|
+
name,
|
|
530
|
+
{"skeleton": "div", "root": "div", "compatible": [], "classes": []},
|
|
531
|
+
)
|
|
532
|
+
self._index[name] = _build_signature_from_hints(name, hints)
|
|
533
|
+
|
|
534
|
+
# ── External open-source registries ───────────────────────────────────
|
|
535
|
+
ext_regs = self._settings.registry.external_registries
|
|
536
|
+
if ext_regs:
|
|
537
|
+
logger.info(
|
|
538
|
+
"Fetching components from %d external registries", len(ext_regs)
|
|
539
|
+
)
|
|
540
|
+
ext_source_map = await self._fetcher.fetch_all_external(ext_regs)
|
|
541
|
+
|
|
542
|
+
for compound_name, reg_data in ext_source_map.items():
|
|
543
|
+
# compound_name = "bundui/pagination", "hextaui/pagination", etc.
|
|
544
|
+
try:
|
|
545
|
+
files = reg_data.get("files", [])
|
|
546
|
+
source_code = files[0].get("content", "") if files else ""
|
|
547
|
+
|
|
548
|
+
if source_code:
|
|
549
|
+
parsed = parse_source(source_code)
|
|
550
|
+
sig = _build_signature_from_parsed(compound_name, parsed)
|
|
551
|
+
else:
|
|
552
|
+
hints = COMPONENT_HINTS.get(
|
|
553
|
+
compound_name,
|
|
554
|
+
{"skeleton": "div", "root": "div", "compatible": [], "classes": []},
|
|
555
|
+
)
|
|
556
|
+
sig = _build_signature_from_hints(compound_name, hints)
|
|
557
|
+
|
|
558
|
+
self._index[compound_name] = sig
|
|
559
|
+
logger.debug("Indexed external component: %s", compound_name)
|
|
560
|
+
except Exception as exc:
|
|
561
|
+
logger.warning(
|
|
562
|
+
"Failed to build signature for external %s: %s",
|
|
563
|
+
compound_name, exc,
|
|
564
|
+
)
|
|
565
|
+
hints = COMPONENT_HINTS.get(
|
|
566
|
+
compound_name,
|
|
567
|
+
{"skeleton": "div", "root": "div", "compatible": [], "classes": []},
|
|
568
|
+
)
|
|
569
|
+
self._index[compound_name] = _build_signature_from_hints(
|
|
570
|
+
compound_name, hints
|
|
571
|
+
)
|
|
572
|
+
|
|
573
|
+
logger.info("Signature index built: %d components total", len(self._index))
|
|
574
|
+
|
|
575
|
+
async def _persist(self, cache_path: Path) -> None:
|
|
576
|
+
cache_path.parent.mkdir(parents=True, exist_ok=True)
|
|
577
|
+
tmp = cache_path.with_suffix(".tmp")
|
|
578
|
+
data = {
|
|
579
|
+
"built_at": time.time(),
|
|
580
|
+
"components": {
|
|
581
|
+
k: v.model_dump(mode="json") for k, v in self._index.items()
|
|
582
|
+
},
|
|
583
|
+
}
|
|
584
|
+
tmp.write_text(json.dumps(data, indent=2))
|
|
585
|
+
tmp.replace(cache_path)
|
|
586
|
+
logger.debug("Persisted signature index to %s", cache_path)
|
|
587
|
+
|
|
588
|
+
async def _load_from_cache(self, cache_path: Path) -> bool:
|
|
589
|
+
if not cache_path.exists():
|
|
590
|
+
return False
|
|
591
|
+
try:
|
|
592
|
+
raw = json.loads(cache_path.read_text())
|
|
593
|
+
built_at = raw.get("built_at", 0)
|
|
594
|
+
ttl = self._settings.registry.http_cache_ttl_hours * 3600
|
|
595
|
+
if time.time() - built_at > ttl:
|
|
596
|
+
logger.debug("Signature cache expired")
|
|
597
|
+
return False
|
|
598
|
+
components = raw.get("components", {})
|
|
599
|
+
for name, data in components.items():
|
|
600
|
+
try:
|
|
601
|
+
self._index[name] = ComponentSignature.model_validate(data)
|
|
602
|
+
except Exception:
|
|
603
|
+
pass
|
|
604
|
+
return bool(self._index)
|
|
605
|
+
except Exception as exc:
|
|
606
|
+
logger.warning("Failed to load signature cache: %s", exc)
|
|
607
|
+
return False
|
|
608
|
+
|
|
609
|
+
def get_candidates(
|
|
610
|
+
self,
|
|
611
|
+
component_type: ComponentType,
|
|
612
|
+
normalized: NormalizedSegment,
|
|
613
|
+
fingerprint_hash: str,
|
|
614
|
+
) -> list[RankedCandidate]:
|
|
615
|
+
"""Get ranked candidates for a segment."""
|
|
616
|
+
cfg = self._settings.signature_index
|
|
617
|
+
|
|
618
|
+
# Get candidate names from type map
|
|
619
|
+
if component_type == ComponentType.UNKNOWN:
|
|
620
|
+
candidate_names = list(self._index.keys())
|
|
621
|
+
else:
|
|
622
|
+
candidate_names = list(TYPE_MAP.get(component_type, []))
|
|
623
|
+
# Fallback: if no type map entries, use all
|
|
624
|
+
if not candidate_names:
|
|
625
|
+
candidate_names = list(self._index.keys())
|
|
626
|
+
|
|
627
|
+
seg_skeleton = normalized.skeleton or ""
|
|
628
|
+
seg_class_tokens = set(normalized.class_tokens or [])
|
|
629
|
+
|
|
630
|
+
candidates: list[RankedCandidate] = []
|
|
631
|
+
for name in candidate_names:
|
|
632
|
+
sig = self._index.get(name)
|
|
633
|
+
if sig is None:
|
|
634
|
+
continue
|
|
635
|
+
|
|
636
|
+
struct_score = skeleton_similarity(seg_skeleton, sig.dom_skeleton)
|
|
637
|
+
class_score = jaccard_similarity(
|
|
638
|
+
seg_class_tokens, set(sig.structural_class_tokens)
|
|
639
|
+
)
|
|
640
|
+
type_score = (
|
|
641
|
+
1.0 if component_type in sig.compatible_component_types else 0.3
|
|
642
|
+
)
|
|
643
|
+
comp = composite_score(struct_score, class_score, type_score)
|
|
644
|
+
|
|
645
|
+
candidates.append(
|
|
646
|
+
RankedCandidate(
|
|
647
|
+
component_name=name,
|
|
648
|
+
registry_source=sig.registry_source,
|
|
649
|
+
signature=sig,
|
|
650
|
+
structural_score=round(struct_score, 4),
|
|
651
|
+
type_score=round(type_score, 4),
|
|
652
|
+
class_token_score=round(class_score, 4),
|
|
653
|
+
composite_score=round(comp, 4),
|
|
654
|
+
)
|
|
655
|
+
)
|
|
656
|
+
|
|
657
|
+
return rank_candidates(
|
|
658
|
+
candidates,
|
|
659
|
+
top_k=cfg.max_candidates_per_segment,
|
|
660
|
+
min_threshold=cfg.candidate_min_threshold,
|
|
661
|
+
)
|
|
662
|
+
|
|
663
|
+
def batch_get_candidates(
|
|
664
|
+
self,
|
|
665
|
+
items: list[tuple[ComponentType, NormalizedSegment, str]],
|
|
666
|
+
) -> dict[str, list[RankedCandidate]]:
|
|
667
|
+
"""fingerprint_hash → ranked candidates for all items."""
|
|
668
|
+
result: dict[str, list[RankedCandidate]] = {}
|
|
669
|
+
for component_type, normalized, fingerprint_hash in items:
|
|
670
|
+
result[fingerprint_hash] = self.get_candidates(
|
|
671
|
+
component_type, normalized, fingerprint_hash
|
|
672
|
+
)
|
|
673
|
+
return result
|
|
674
|
+
|
|
675
|
+
def get_signature(self, name: str) -> ComponentSignature | None:
|
|
676
|
+
return self._index.get(name)
|
|
677
|
+
|
|
678
|
+
def get_all_component_names(self) -> list[str]:
|
|
679
|
+
return list(self._index.keys())
|
|
680
|
+
|
|
681
|
+
def register_custom(self, defn: CustomComponentDefinition) -> None:
|
|
682
|
+
"""Add a custom component to the live index."""
|
|
683
|
+
from component_mapper.registry.custom_registry import CustomRegistry
|
|
684
|
+
|
|
685
|
+
cr = CustomRegistry()
|
|
686
|
+
sig = cr._to_signature(defn)
|
|
687
|
+
self._index[defn.name] = sig
|
|
688
|
+
logger.debug("Registered custom component in index: %s", defn.name)
|
|
689
|
+
|
|
690
|
+
def merge_custom(self, signatures: list[ComponentSignature]) -> None:
|
|
691
|
+
"""Merge custom registry signatures into the index (priority: custom first)."""
|
|
692
|
+
for sig in signatures:
|
|
693
|
+
self._index[sig.component_name] = sig
|
|
694
|
+
logger.debug("Merged %d custom signatures into index", len(signatures))
|
|
File without changes
|