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.
@@ -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
+ )