diff --git a/common/components/__init__.py b/common/components/__init__.py index 5072ee1..6e8795e 100644 --- a/common/components/__init__.py +++ b/common/components/__init__.py @@ -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", diff --git a/common/components/custom_elements.py b/common/components/custom_elements.py new file mode 100644 index 0000000..61eec33 --- /dev/null +++ b/common/components/custom_elements.py @@ -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 ``children`` and declare its compiled module. + + The module path mirrors the source layout: ``ts/elements/.ts`` compiles + to ``dist/elements/.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) diff --git a/games/management/commands/gen_element_types.py b/games/management/commands/gen_element_types.py new file mode 100644 index 0000000..6e2402e --- /dev/null +++ b/games/management/commands/gen_element_types.py @@ -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}")) diff --git a/tests/test_custom_elements.py b/tests/test_custom_elements.py new file mode 100644 index 0000000..ff77e2b --- /dev/null +++ b/tests/test_custom_elements.py @@ -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("hi", 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")