Custom-element registry, builder, and TS codegen
This commit is contained in:
@@ -18,6 +18,7 @@ from common.components.core import (
|
||||
randomid,
|
||||
render,
|
||||
)
|
||||
from common.components.custom_elements import custom_element, register_element
|
||||
from common.components.date_range_picker import (
|
||||
DateRangeCalendar,
|
||||
DateRangeField,
|
||||
@@ -91,6 +92,8 @@ from common.utils import truncate
|
||||
__all__ = [
|
||||
"truncate",
|
||||
"BaseComponent",
|
||||
"custom_element",
|
||||
"register_element",
|
||||
"Element",
|
||||
"Fragment",
|
||||
"Media",
|
||||
|
||||
@@ -0,0 +1,104 @@
|
||||
"""Custom-element builder, registry, and TypeScript codegen.
|
||||
|
||||
A custom element is a light-DOM Web Component: the Python builder emits a
|
||||
semantic tag whose typed props become kebab-case attributes and whose behavior
|
||||
lives in a compiled TS module (loaded via Media). One ``TypedDict`` per element
|
||||
is the single source of truth for the server<->client contract;
|
||||
``gen_element_types`` turns each registered spec into a TS interface + attribute
|
||||
reader so drift fails ``tsc``.
|
||||
"""
|
||||
|
||||
from dataclasses import dataclass
|
||||
from typing import Mapping, TypedDict, get_type_hints
|
||||
|
||||
from common.components.core import Children, Element, HTMLAttribute, Media, Node
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class ElementSpec:
|
||||
tag: str # e.g. "game-status-selector"
|
||||
ts_name: str # e.g. "GameStatusSelector"
|
||||
props: type # a TypedDict subclass
|
||||
|
||||
|
||||
ELEMENT_REGISTRY: list[ElementSpec] = []
|
||||
|
||||
|
||||
def register_element(tag: str, ts_name: str, props: type) -> None:
|
||||
"""Register an element so codegen can emit its TS contract."""
|
||||
ELEMENT_REGISTRY.append(ElementSpec(tag, ts_name, props))
|
||||
|
||||
|
||||
def _kebab(name: str) -> str:
|
||||
return name.replace("_", "-")
|
||||
|
||||
|
||||
def custom_element(
|
||||
tag: str, props: Mapping[str, object], *, children: Children = None
|
||||
) -> Node:
|
||||
"""Emit ``<tag kebab-attrs>children</tag>`` and declare its compiled module.
|
||||
|
||||
The module path mirrors the source layout: ``ts/elements/<tag>.ts`` compiles
|
||||
to ``dist/elements/<tag>.js``, which ``Media`` loads via ``ModuleScript``."""
|
||||
attributes: list[HTMLAttribute] = [
|
||||
(_kebab(key), value) # type: ignore[misc]
|
||||
for key, value in props.items()
|
||||
]
|
||||
return Element(tag, attributes, children).with_media(
|
||||
Media(js=(f"dist/elements/{tag}.js",))
|
||||
)
|
||||
|
||||
|
||||
# ── Codegen ──────────────────────────────────────────────────────────────────
|
||||
|
||||
_TYPE_MAP = {int: "number", float: "number", str: "string", bool: "boolean"}
|
||||
|
||||
|
||||
def _camel(name: str) -> str:
|
||||
head, *tail = name.split("_")
|
||||
return head + "".join(part.title() for part in tail)
|
||||
|
||||
|
||||
def _reader_expr(name: str, python_type: type) -> str:
|
||||
attr = _kebab(name)
|
||||
if python_type in (int, float):
|
||||
return f'Number(el.getAttribute("{attr}"))'
|
||||
if python_type is bool:
|
||||
return f'el.getAttribute("{attr}") === "true"'
|
||||
return f'el.getAttribute("{attr}") ?? ""'
|
||||
|
||||
|
||||
def _ts_for_spec(spec: ElementSpec) -> str:
|
||||
hints = get_type_hints(spec.props)
|
||||
interface_lines = "\n".join(
|
||||
f" {_camel(name)}: {_TYPE_MAP[python_type]};"
|
||||
for name, python_type in hints.items()
|
||||
)
|
||||
reader_lines = "\n".join(
|
||||
f" {_camel(name)}: {_reader_expr(name, python_type)},"
|
||||
for name, python_type in hints.items()
|
||||
)
|
||||
return (
|
||||
f"export interface {spec.ts_name}Props {{\n{interface_lines}\n}}\n\n"
|
||||
f"export function read{spec.ts_name}Props(el: HTMLElement): "
|
||||
f"{spec.ts_name}Props {{\n return {{\n{reader_lines}\n }};\n}}"
|
||||
)
|
||||
|
||||
|
||||
def render_props_module() -> str:
|
||||
"""The full ``ts/generated/props.ts`` content for every registered element."""
|
||||
header = "// GENERATED by `manage.py gen_element_types` — do not edit.\n"
|
||||
blocks = [_ts_for_spec(spec) for spec in ELEMENT_REGISTRY]
|
||||
return header + "\n" + "\n\n".join(blocks) + "\n"
|
||||
|
||||
|
||||
# ── Element prop schemas (registered at import time) ─────────────────────────
|
||||
|
||||
|
||||
class GameStatusSelectorProps(TypedDict):
|
||||
game_id: int
|
||||
status: str
|
||||
csrf: str
|
||||
|
||||
|
||||
register_element("game-status-selector", "GameStatusSelector", GameStatusSelectorProps)
|
||||
@@ -0,0 +1,21 @@
|
||||
"""Write ts/generated/props.ts from the registered custom-element specs."""
|
||||
|
||||
from pathlib import Path
|
||||
|
||||
from django.conf import settings
|
||||
from django.core.management.base import BaseCommand
|
||||
|
||||
# Importing the components package triggers element registration at import time.
|
||||
import common.components # noqa: F401
|
||||
from common.components.custom_elements import render_props_module
|
||||
|
||||
|
||||
class Command(BaseCommand):
|
||||
help = "Generate ts/generated/props.ts from registered custom elements."
|
||||
|
||||
def handle(self, *args, **options) -> None:
|
||||
output_dir = Path(settings.BASE_DIR) / "ts" / "generated"
|
||||
output_dir.mkdir(parents=True, exist_ok=True)
|
||||
target = output_dir / "props.ts"
|
||||
target.write_text(render_props_module(), encoding="utf-8")
|
||||
self.stdout.write(self.style.SUCCESS(f"Wrote {target}"))
|
||||
@@ -0,0 +1,58 @@
|
||||
import unittest
|
||||
from typing import TypedDict
|
||||
|
||||
from common.components import custom_element, render
|
||||
from common.components.custom_elements import (
|
||||
ElementSpec,
|
||||
_ts_for_spec,
|
||||
register_element,
|
||||
)
|
||||
|
||||
|
||||
class SampleProps(TypedDict):
|
||||
game_id: int
|
||||
status: str
|
||||
is_on: bool
|
||||
|
||||
|
||||
class CustomElementBuilderTest(unittest.TestCase):
|
||||
def test_serializes_props_to_kebab_attributes(self):
|
||||
html = render(
|
||||
custom_element("x-sample", {"game_id": 3, "status": "f"}, children=["hi"])
|
||||
)
|
||||
self.assertIn("<x-sample", html)
|
||||
self.assertIn('game-id="3"', html)
|
||||
self.assertIn('status="f"', html)
|
||||
self.assertIn(">hi</x-sample>", html)
|
||||
|
||||
def test_declares_compiled_module_media(self):
|
||||
from common.components import collect_media
|
||||
|
||||
node = custom_element("x-sample", {"game_id": 3})
|
||||
self.assertEqual(collect_media(node).js, ("dist/elements/x-sample.js",))
|
||||
|
||||
|
||||
class CodegenTest(unittest.TestCase):
|
||||
def test_emits_interface_and_reader(self):
|
||||
spec = ElementSpec("x-sample", "XSample", SampleProps)
|
||||
ts = _ts_for_spec(spec)
|
||||
self.assertIn("export interface XSampleProps {", ts)
|
||||
self.assertIn("gameId: number;", ts)
|
||||
self.assertIn("status: string;", ts)
|
||||
self.assertIn("isOn: boolean;", ts)
|
||||
self.assertIn(
|
||||
"export function readXSampleProps(el: HTMLElement): XSampleProps", ts
|
||||
)
|
||||
self.assertIn('Number(el.getAttribute("game-id"))', ts)
|
||||
self.assertIn('el.getAttribute("status") ?? ""', ts)
|
||||
self.assertIn('el.getAttribute("is-on") === "true"', ts)
|
||||
|
||||
|
||||
class RegistryTest(unittest.TestCase):
|
||||
def test_register_adds_spec(self):
|
||||
from common.components.custom_elements import ELEMENT_REGISTRY
|
||||
|
||||
before = len(ELEMENT_REGISTRY)
|
||||
register_element("x-reg-test", "XRegTest", SampleProps)
|
||||
self.assertEqual(len(ELEMENT_REGISTRY), before + 1)
|
||||
self.assertEqual(ELEMENT_REGISTRY[-1].tag, "x-reg-test")
|
||||
Reference in New Issue
Block a user