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,390 @@
|
|
|
1
|
+
import re
|
|
2
|
+
import logging
|
|
3
|
+
from component_mapper.models import (
|
|
4
|
+
AstroComponent,
|
|
5
|
+
ContentCollectionSchema,
|
|
6
|
+
AstroImport,
|
|
7
|
+
PropMapping,
|
|
8
|
+
ComponentSignature,
|
|
9
|
+
RegistrySource,
|
|
10
|
+
InteractivityMode,
|
|
11
|
+
PropDefinition,
|
|
12
|
+
)
|
|
13
|
+
from segment_classifier.models import ClassifiedSegment, ComponentType
|
|
14
|
+
|
|
15
|
+
logger = logging.getLogger(__name__)
|
|
16
|
+
|
|
17
|
+
COLLECTION_TYPE_TO_NAME: dict[ComponentType, str] = {
|
|
18
|
+
ComponentType.COLLECTION_PRODUCT_CARD: "products",
|
|
19
|
+
ComponentType.COLLECTION_PRODUCT_LIST: "products",
|
|
20
|
+
ComponentType.COLLECTION_BLOG_CARD: "blog",
|
|
21
|
+
ComponentType.COLLECTION_BLOG_LIST: "blog",
|
|
22
|
+
ComponentType.COLLECTION_NEWS_ITEM: "news",
|
|
23
|
+
ComponentType.COLLECTION_NEWS_LIST: "news",
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
FILE_PATH_PREFIX: dict[str, str] = {
|
|
27
|
+
"layout": "src/components/layout",
|
|
28
|
+
"collection": "src/components/collection",
|
|
29
|
+
"section": "src/components/sections",
|
|
30
|
+
"ui": "src/components/ui",
|
|
31
|
+
"content": "src/components/content",
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
ZOD_TYPE_MAP: dict[str, str] = {
|
|
35
|
+
"string": "z.string()",
|
|
36
|
+
"number": "z.number()",
|
|
37
|
+
"boolean": "z.boolean()",
|
|
38
|
+
"ReactNode": "z.string()",
|
|
39
|
+
"any": "z.any()",
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def generate_astro_component(
|
|
44
|
+
segment: ClassifiedSegment,
|
|
45
|
+
signature: ComponentSignature,
|
|
46
|
+
prop_mapping: PropMapping,
|
|
47
|
+
component_name: str,
|
|
48
|
+
) -> AstroComponent:
|
|
49
|
+
"""Generate a complete .astro wrapper file."""
|
|
50
|
+
pascal_name = _to_pascal_case(component_name)
|
|
51
|
+
file_path = _compute_file_path(segment.component_type, pascal_name)
|
|
52
|
+
|
|
53
|
+
is_collection = segment.component_type.value.startswith("collection.")
|
|
54
|
+
|
|
55
|
+
# Client directive
|
|
56
|
+
client_directive: str | None = None
|
|
57
|
+
if signature.interactivity == InteractivityMode.INTERACTIVE:
|
|
58
|
+
client_directive = "load"
|
|
59
|
+
elif signature.interactivity == InteractivityMode.PARTIAL:
|
|
60
|
+
client_directive = "visible"
|
|
61
|
+
|
|
62
|
+
# Build imports
|
|
63
|
+
imports, import_stmts = _build_imports(signature, component_name)
|
|
64
|
+
|
|
65
|
+
# Build Props interface
|
|
66
|
+
props_interface = _build_props_interface(signature.props)
|
|
67
|
+
|
|
68
|
+
# Build props destructure
|
|
69
|
+
props_destructure = _build_props_destructure(signature.props)
|
|
70
|
+
|
|
71
|
+
# Build template
|
|
72
|
+
template = _build_template(
|
|
73
|
+
signature=signature,
|
|
74
|
+
prop_mapping=prop_mapping,
|
|
75
|
+
component_name=component_name,
|
|
76
|
+
client_directive=client_directive,
|
|
77
|
+
)
|
|
78
|
+
|
|
79
|
+
# Assemble frontmatter
|
|
80
|
+
frontmatter_parts = []
|
|
81
|
+
for stmt in import_stmts:
|
|
82
|
+
frontmatter_parts.append(stmt)
|
|
83
|
+
if import_stmts:
|
|
84
|
+
frontmatter_parts.append("")
|
|
85
|
+
if props_interface:
|
|
86
|
+
frontmatter_parts.append(props_interface)
|
|
87
|
+
frontmatter_parts.append("")
|
|
88
|
+
if props_destructure:
|
|
89
|
+
frontmatter_parts.append(props_destructure)
|
|
90
|
+
frontmatter = "\n".join(frontmatter_parts)
|
|
91
|
+
|
|
92
|
+
# Assemble full file
|
|
93
|
+
full_content = f"---\n{frontmatter}\n---\n\n{template}\n"
|
|
94
|
+
|
|
95
|
+
install_commands = [signature.install_command] if signature.install_command else []
|
|
96
|
+
|
|
97
|
+
return AstroComponent(
|
|
98
|
+
component_name=pascal_name,
|
|
99
|
+
file_path=file_path,
|
|
100
|
+
frontmatter=frontmatter,
|
|
101
|
+
template=template,
|
|
102
|
+
imports=imports,
|
|
103
|
+
full_file_content=full_content,
|
|
104
|
+
install_commands=install_commands,
|
|
105
|
+
client_directive=client_directive,
|
|
106
|
+
is_collection_item=is_collection,
|
|
107
|
+
)
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
def _compute_file_path(component_type: ComponentType, pascal_name: str) -> str:
|
|
111
|
+
prefix_key = component_type.value.split(".")[0]
|
|
112
|
+
prefix = FILE_PATH_PREFIX.get(prefix_key, "src/components")
|
|
113
|
+
return f"{prefix}/{pascal_name}.astro"
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
def _build_imports(
|
|
117
|
+
signature: ComponentSignature,
|
|
118
|
+
component_name: str,
|
|
119
|
+
) -> tuple[list[AstroImport], list[str]]:
|
|
120
|
+
imports: list[AstroImport] = []
|
|
121
|
+
stmts: list[str] = []
|
|
122
|
+
|
|
123
|
+
if signature.registry_source == RegistrySource.CUSTOM:
|
|
124
|
+
# Custom: single default import from .astro file
|
|
125
|
+
astro_import_path = signature.astro_import
|
|
126
|
+
pascal = _to_pascal_case(component_name)
|
|
127
|
+
imports.append(
|
|
128
|
+
AstroImport(identifier=pascal, source=astro_import_path, is_default=True)
|
|
129
|
+
)
|
|
130
|
+
stmts.append(f'import {pascal} from "{astro_import_path}";')
|
|
131
|
+
else:
|
|
132
|
+
# Shadcn: named imports of component + required children
|
|
133
|
+
source_path = f"@/components/ui/{component_name}"
|
|
134
|
+
all_names = [_to_pascal_case(component_name)] + [
|
|
135
|
+
_to_pascal_case(c) for c in signature.required_children
|
|
136
|
+
]
|
|
137
|
+
# Deduplicate, preserve order
|
|
138
|
+
seen: set[str] = set()
|
|
139
|
+
unique_names = []
|
|
140
|
+
for n in all_names:
|
|
141
|
+
if n not in seen:
|
|
142
|
+
seen.add(n)
|
|
143
|
+
unique_names.append(n)
|
|
144
|
+
|
|
145
|
+
for name in unique_names:
|
|
146
|
+
imports.append(AstroImport(identifier=name, source=source_path))
|
|
147
|
+
|
|
148
|
+
if unique_names:
|
|
149
|
+
names_str = ", ".join(unique_names)
|
|
150
|
+
stmts.append(f'import {{ {names_str} }} from "{source_path}";')
|
|
151
|
+
|
|
152
|
+
return imports, stmts
|
|
153
|
+
|
|
154
|
+
|
|
155
|
+
def _build_props_interface(props: list[PropDefinition]) -> str:
|
|
156
|
+
if not props:
|
|
157
|
+
return ""
|
|
158
|
+
lines = ["interface Props {"]
|
|
159
|
+
for prop in props:
|
|
160
|
+
optional = "" if prop.required else "?"
|
|
161
|
+
ts_type = _to_ts_type(prop.type)
|
|
162
|
+
lines.append(f" {prop.name}{optional}: {ts_type};")
|
|
163
|
+
lines.append("}")
|
|
164
|
+
return "\n".join(lines)
|
|
165
|
+
|
|
166
|
+
|
|
167
|
+
def _build_props_destructure(props: list[PropDefinition]) -> str:
|
|
168
|
+
if not props:
|
|
169
|
+
return ""
|
|
170
|
+
parts = []
|
|
171
|
+
for prop in props:
|
|
172
|
+
if prop.default_value is not None:
|
|
173
|
+
default = prop.default_value
|
|
174
|
+
# Quote strings
|
|
175
|
+
if prop.type == "string" and not default.startswith('"'):
|
|
176
|
+
default = f'"{default}"'
|
|
177
|
+
parts.append(f"{prop.name} = {default}")
|
|
178
|
+
else:
|
|
179
|
+
parts.append(prop.name)
|
|
180
|
+
return f"const {{ {', '.join(parts)} }} = Astro.props;"
|
|
181
|
+
|
|
182
|
+
|
|
183
|
+
def _build_template(
|
|
184
|
+
signature: ComponentSignature,
|
|
185
|
+
prop_mapping: PropMapping,
|
|
186
|
+
component_name: str,
|
|
187
|
+
client_directive: str | None,
|
|
188
|
+
) -> str:
|
|
189
|
+
pascal = _to_pascal_case(component_name)
|
|
190
|
+
|
|
191
|
+
# Build a prop → segment_field lookup for template rendering
|
|
192
|
+
prop_to_field: dict[str, dict] = {}
|
|
193
|
+
for mapping in prop_mapping.mappings:
|
|
194
|
+
prop_name = mapping.get("component_prop", "")
|
|
195
|
+
prop_to_field[prop_name] = mapping
|
|
196
|
+
|
|
197
|
+
directive_attr = f" client:{client_directive}" if client_directive else ""
|
|
198
|
+
|
|
199
|
+
if signature.registry_source == RegistrySource.CUSTOM:
|
|
200
|
+
return _build_custom_template(pascal, signature, prop_to_field, directive_attr)
|
|
201
|
+
|
|
202
|
+
return _build_shadcn_template(
|
|
203
|
+
pascal, signature, prop_to_field, directive_attr, prop_mapping
|
|
204
|
+
)
|
|
205
|
+
|
|
206
|
+
|
|
207
|
+
def _build_shadcn_template(
|
|
208
|
+
pascal: str,
|
|
209
|
+
signature: ComponentSignature,
|
|
210
|
+
prop_to_field: dict[str, dict],
|
|
211
|
+
directive_attr: str,
|
|
212
|
+
prop_mapping: PropMapping,
|
|
213
|
+
) -> str:
|
|
214
|
+
lines = [f"<{pascal}{directive_attr}>"]
|
|
215
|
+
|
|
216
|
+
# Use required children to structure the template
|
|
217
|
+
children = signature.required_children
|
|
218
|
+
if not children:
|
|
219
|
+
# Flat: render props directly
|
|
220
|
+
for prop_name, mapping in prop_to_field.items():
|
|
221
|
+
ambiguous_comment = (
|
|
222
|
+
" {/* TODO: verify mapping */}" if mapping.get("ambiguous") else ""
|
|
223
|
+
)
|
|
224
|
+
content_type = mapping.get("type", "text")
|
|
225
|
+
if content_type == "image_url":
|
|
226
|
+
lines.append(
|
|
227
|
+
f' <img src={{{prop_name}}} alt="" class="w-full object-cover" />'
|
|
228
|
+
)
|
|
229
|
+
else:
|
|
230
|
+
lines.append(f" <span>{{{prop_name}}}{ambiguous_comment}</span>")
|
|
231
|
+
for unmapped in prop_mapping.unmapped_props:
|
|
232
|
+
lines.append(f" {{/* {unmapped} */}}")
|
|
233
|
+
else:
|
|
234
|
+
# Structured: distribute props across sub-components
|
|
235
|
+
prop_names = list(prop_to_field.keys())
|
|
236
|
+
chunk_size = max(1, len(prop_names) // max(1, len(children)))
|
|
237
|
+
|
|
238
|
+
for i, child_name in enumerate(children):
|
|
239
|
+
child_pascal = _to_pascal_case(child_name)
|
|
240
|
+
chunk = prop_names[i * chunk_size : (i + 1) * chunk_size]
|
|
241
|
+
if i == len(children) - 1:
|
|
242
|
+
chunk = prop_names[i * chunk_size :]
|
|
243
|
+
|
|
244
|
+
lines.append(f" <{child_pascal}>")
|
|
245
|
+
for prop_name in chunk:
|
|
246
|
+
mapping = prop_to_field[prop_name]
|
|
247
|
+
ambiguous_comment = (
|
|
248
|
+
" {/* TODO: verify mapping */}" if mapping.get("ambiguous") else ""
|
|
249
|
+
)
|
|
250
|
+
content_type = mapping.get("type", "text")
|
|
251
|
+
if content_type == "image_url":
|
|
252
|
+
lines.append(
|
|
253
|
+
f' <img src={{{prop_name}}} alt="" class="w-full h-48 object-cover rounded-t-lg" />'
|
|
254
|
+
)
|
|
255
|
+
elif prop_name in ("footer", "action"):
|
|
256
|
+
lines.append(
|
|
257
|
+
f' <button class="w-full">{{{prop_name}}}{ambiguous_comment}</button>'
|
|
258
|
+
)
|
|
259
|
+
else:
|
|
260
|
+
lines.append(
|
|
261
|
+
f' <span class="block">{{{prop_name}}}{ambiguous_comment}</span>'
|
|
262
|
+
)
|
|
263
|
+
for unmapped in (
|
|
264
|
+
prop_mapping.unmapped_props if i == len(children) - 1 else []
|
|
265
|
+
):
|
|
266
|
+
lines.append(f" {{/* {unmapped} */}}")
|
|
267
|
+
lines.append(f" </{child_pascal}>")
|
|
268
|
+
|
|
269
|
+
lines.append(f"</{pascal}>")
|
|
270
|
+
return "\n".join(lines)
|
|
271
|
+
|
|
272
|
+
|
|
273
|
+
def _build_custom_template(
|
|
274
|
+
pascal: str,
|
|
275
|
+
signature: ComponentSignature,
|
|
276
|
+
prop_to_field: dict[str, dict],
|
|
277
|
+
directive_attr: str,
|
|
278
|
+
) -> str:
|
|
279
|
+
lines = [f"<{pascal}{directive_attr}>"]
|
|
280
|
+
for prop_name, mapping in prop_to_field.items():
|
|
281
|
+
content_type = mapping.get("type", "text")
|
|
282
|
+
if content_type == "image_url":
|
|
283
|
+
lines.append(f' <img slot="{prop_name}" src={{{prop_name}}} alt="" />')
|
|
284
|
+
else:
|
|
285
|
+
lines.append(f' <span slot="{prop_name}">{{{prop_name}}}</span>')
|
|
286
|
+
lines.append(f"</{pascal}>")
|
|
287
|
+
return "\n".join(lines)
|
|
288
|
+
|
|
289
|
+
|
|
290
|
+
def generate_content_collection_schema(
|
|
291
|
+
component_type: ComponentType,
|
|
292
|
+
prop_mapping: PropMapping,
|
|
293
|
+
signature: ComponentSignature,
|
|
294
|
+
) -> ContentCollectionSchema:
|
|
295
|
+
"""Generate Zod schema for Astro Content Collections."""
|
|
296
|
+
collection_name = COLLECTION_TYPE_TO_NAME.get(component_type, "items")
|
|
297
|
+
|
|
298
|
+
zod_fields = []
|
|
299
|
+
for prop in signature.props:
|
|
300
|
+
field_str = _prop_to_zod(prop)
|
|
301
|
+
zod_fields.append(f" {prop.name}: {field_str},")
|
|
302
|
+
|
|
303
|
+
fields_block = "\n".join(zod_fields) if zod_fields else " // no props defined"
|
|
304
|
+
|
|
305
|
+
zod_schema = f"""import {{ defineCollection, z }} from 'astro:content';
|
|
306
|
+
|
|
307
|
+
const {collection_name} = defineCollection({{
|
|
308
|
+
type: 'data',
|
|
309
|
+
schema: z.object({{
|
|
310
|
+
{fields_block}
|
|
311
|
+
}}),
|
|
312
|
+
}});
|
|
313
|
+
|
|
314
|
+
export const collections = {{ {collection_name} }};"""
|
|
315
|
+
|
|
316
|
+
# Build example entry
|
|
317
|
+
example_lines = ["{"]
|
|
318
|
+
for prop in signature.props:
|
|
319
|
+
example_val = _example_value(prop)
|
|
320
|
+
example_lines.append(f' "{prop.name}": {example_val},')
|
|
321
|
+
example_lines.append("}")
|
|
322
|
+
example_entry = "\n".join(example_lines)
|
|
323
|
+
|
|
324
|
+
return ContentCollectionSchema(
|
|
325
|
+
collection_name=collection_name,
|
|
326
|
+
zod_schema=zod_schema,
|
|
327
|
+
example_entry=example_entry,
|
|
328
|
+
)
|
|
329
|
+
|
|
330
|
+
|
|
331
|
+
def _prop_to_zod(prop: PropDefinition) -> str:
|
|
332
|
+
"""Convert PropDefinition to Zod schema string."""
|
|
333
|
+
base = ZOD_TYPE_MAP.get(prop.type, "z.string()")
|
|
334
|
+
|
|
335
|
+
# URL hint
|
|
336
|
+
if (
|
|
337
|
+
"image" in prop.name.lower()
|
|
338
|
+
or "src" in prop.name.lower()
|
|
339
|
+
or "url" in prop.name.lower()
|
|
340
|
+
):
|
|
341
|
+
if "string" in prop.type:
|
|
342
|
+
base = "z.string().url()"
|
|
343
|
+
|
|
344
|
+
if not prop.required:
|
|
345
|
+
if prop.default_value is not None:
|
|
346
|
+
dv = prop.default_value
|
|
347
|
+
if prop.type == "string":
|
|
348
|
+
dv = f'"{dv}"'
|
|
349
|
+
base = f"{base}.default({dv})"
|
|
350
|
+
else:
|
|
351
|
+
base = f"{base}.optional()"
|
|
352
|
+
|
|
353
|
+
return base
|
|
354
|
+
|
|
355
|
+
|
|
356
|
+
def _example_value(prop: PropDefinition) -> str:
|
|
357
|
+
if prop.default_value:
|
|
358
|
+
v = prop.default_value
|
|
359
|
+
if prop.type == "string":
|
|
360
|
+
return f'"{v}"'
|
|
361
|
+
return v
|
|
362
|
+
if "image" in prop.name.lower() or "src" in prop.name.lower():
|
|
363
|
+
return '"https://example.com/image.jpg"'
|
|
364
|
+
if "url" in prop.name.lower() or "href" in prop.name.lower():
|
|
365
|
+
return '"https://example.com"'
|
|
366
|
+
if prop.type == "number":
|
|
367
|
+
return "0"
|
|
368
|
+
if prop.type == "boolean":
|
|
369
|
+
return "false"
|
|
370
|
+
return f'"{prop.name} value"'
|
|
371
|
+
|
|
372
|
+
|
|
373
|
+
def _to_pascal_case(name: str) -> str:
|
|
374
|
+
"""'product-card' -> 'ProductCard'. 'CardHeader' -> 'CardHeader'. 'card' -> 'Card'."""
|
|
375
|
+
# Already PascalCase (starts uppercase and no delimiters) — preserve as-is
|
|
376
|
+
if name and name[0].isupper() and not re.search(r"[-_\s]", name):
|
|
377
|
+
return name
|
|
378
|
+
return "".join(part.capitalize() for part in re.split(r"[-_\s]+", name) if part)
|
|
379
|
+
|
|
380
|
+
|
|
381
|
+
def _to_ts_type(prop_type: str) -> str:
|
|
382
|
+
"""Map Pydantic type strings to TypeScript types."""
|
|
383
|
+
mapping = {
|
|
384
|
+
"string": "string",
|
|
385
|
+
"number": "number",
|
|
386
|
+
"boolean": "boolean",
|
|
387
|
+
"ReactNode": "astro.ComponentProps<any>",
|
|
388
|
+
"any": "unknown",
|
|
389
|
+
}
|
|
390
|
+
return mapping.get(prop_type.strip(), prop_type.strip())
|
|
@@ -0,0 +1,127 @@
|
|
|
1
|
+
import asyncio
|
|
2
|
+
import json
|
|
3
|
+
import logging
|
|
4
|
+
import re
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
from component_mapper.models import (
|
|
7
|
+
ComponentSignature,
|
|
8
|
+
CustomComponentDefinition,
|
|
9
|
+
RegistrySource,
|
|
10
|
+
InteractivityMode,
|
|
11
|
+
)
|
|
12
|
+
|
|
13
|
+
logger = logging.getLogger(__name__)
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
class CustomRegistry:
|
|
17
|
+
def __init__(self):
|
|
18
|
+
self._store: dict[str, ComponentSignature] = {}
|
|
19
|
+
self._lock = asyncio.Lock()
|
|
20
|
+
self._dirty = False
|
|
21
|
+
|
|
22
|
+
async def load(self, path: str) -> None:
|
|
23
|
+
"""Load custom registry from disk. Silent if file missing."""
|
|
24
|
+
p = Path(path)
|
|
25
|
+
if not p.exists():
|
|
26
|
+
logger.debug("No custom registry at %s", p)
|
|
27
|
+
return
|
|
28
|
+
try:
|
|
29
|
+
raw = json.loads(p.read_text())
|
|
30
|
+
async with self._lock:
|
|
31
|
+
for name, data in raw.items():
|
|
32
|
+
try:
|
|
33
|
+
sig = ComponentSignature.model_validate(data)
|
|
34
|
+
self._store[name] = sig
|
|
35
|
+
except Exception as exc:
|
|
36
|
+
logger.warning(
|
|
37
|
+
"Skipping malformed custom component %s: %s", name, exc
|
|
38
|
+
)
|
|
39
|
+
logger.info("Loaded %d custom components", len(self._store))
|
|
40
|
+
except Exception as exc:
|
|
41
|
+
logger.warning("Failed to load custom registry: %s", exc)
|
|
42
|
+
|
|
43
|
+
async def persist(self, path: str) -> None:
|
|
44
|
+
"""Write custom registry to disk."""
|
|
45
|
+
p = Path(path)
|
|
46
|
+
p.parent.mkdir(parents=True, exist_ok=True)
|
|
47
|
+
try:
|
|
48
|
+
async with self._lock:
|
|
49
|
+
data = {k: v.model_dump(mode="json") for k, v in self._store.items()}
|
|
50
|
+
p.write_text(json.dumps(data, indent=2))
|
|
51
|
+
self._dirty = False
|
|
52
|
+
logger.debug("Persisted %d custom components", len(data))
|
|
53
|
+
except Exception as exc:
|
|
54
|
+
logger.warning("Failed to persist custom registry: %s", exc)
|
|
55
|
+
|
|
56
|
+
def get_all(self) -> list[ComponentSignature]:
|
|
57
|
+
return list(self._store.values())
|
|
58
|
+
|
|
59
|
+
def get(self, name: str) -> ComponentSignature | None:
|
|
60
|
+
return self._store.get(name)
|
|
61
|
+
|
|
62
|
+
def register(self, defn: CustomComponentDefinition) -> ComponentSignature:
|
|
63
|
+
"""Convert CustomComponentDefinition -> ComponentSignature and add to store."""
|
|
64
|
+
sig = self._to_signature(defn)
|
|
65
|
+
self._store[defn.name] = sig
|
|
66
|
+
self._dirty = True
|
|
67
|
+
logger.debug("Registered custom component: %s", defn.name)
|
|
68
|
+
return sig
|
|
69
|
+
|
|
70
|
+
def _to_signature(self, defn: CustomComponentDefinition) -> ComponentSignature:
|
|
71
|
+
# Count child tags from skeleton
|
|
72
|
+
child_tag_counts: dict[str, int] = {}
|
|
73
|
+
html_tags = {
|
|
74
|
+
"div",
|
|
75
|
+
"span",
|
|
76
|
+
"p",
|
|
77
|
+
"h1",
|
|
78
|
+
"h2",
|
|
79
|
+
"h3",
|
|
80
|
+
"img",
|
|
81
|
+
"button",
|
|
82
|
+
"a",
|
|
83
|
+
"ul",
|
|
84
|
+
"li",
|
|
85
|
+
"section",
|
|
86
|
+
"article",
|
|
87
|
+
"nav",
|
|
88
|
+
"header",
|
|
89
|
+
"footer",
|
|
90
|
+
}
|
|
91
|
+
for m in re.finditer(r"<([a-z][a-z0-9]*)", defn.dom_skeleton):
|
|
92
|
+
tag = m.group(1)
|
|
93
|
+
if tag in html_tags:
|
|
94
|
+
child_tag_counts[tag] = child_tag_counts.get(tag, 0) + 1
|
|
95
|
+
for tag in re.findall(r"\b([a-z][a-z0-9]*)\b", defn.dom_skeleton):
|
|
96
|
+
if tag in html_tags:
|
|
97
|
+
child_tag_counts[tag] = child_tag_counts.get(tag, 0) + 1
|
|
98
|
+
|
|
99
|
+
# Measure nesting depth from skeleton brackets
|
|
100
|
+
depth = 0
|
|
101
|
+
max_depth = 0
|
|
102
|
+
for ch in defn.dom_skeleton:
|
|
103
|
+
if ch == "[":
|
|
104
|
+
depth += 1
|
|
105
|
+
max_depth = max(max_depth, depth)
|
|
106
|
+
elif ch == "]":
|
|
107
|
+
depth -= 1
|
|
108
|
+
|
|
109
|
+
return ComponentSignature(
|
|
110
|
+
component_name=defn.name,
|
|
111
|
+
registry_source=RegistrySource.CUSTOM,
|
|
112
|
+
dom_skeleton=defn.dom_skeleton,
|
|
113
|
+
root_element=defn.dom_skeleton.split(">")[0].split("[")[0].strip() or "div",
|
|
114
|
+
required_children=[],
|
|
115
|
+
optional_children=[],
|
|
116
|
+
structural_class_tokens=defn.structural_class_tokens,
|
|
117
|
+
typical_nesting_depth=max_depth,
|
|
118
|
+
child_tag_counts=child_tag_counts,
|
|
119
|
+
unique_tag_count=len(child_tag_counts),
|
|
120
|
+
compatible_component_types=defn.compatible_component_types,
|
|
121
|
+
interactivity=defn.interactivity,
|
|
122
|
+
description=defn.description,
|
|
123
|
+
props=defn.props,
|
|
124
|
+
astro_import=defn.astro_import or f"@/components/custom/{defn.name}.astro",
|
|
125
|
+
install_command=defn.install_command,
|
|
126
|
+
requires_client_directive=defn.interactivity != InteractivityMode.STATIC,
|
|
127
|
+
)
|