Add macros, declarative handlers (defhandler), and convert all fragment routes to sx

Phase 1 — Macros: defmacro + quasiquote syntax (`, ,, ,@) in parser,
evaluator, HTML renderer, and JS mirror. Macro type, expansion, and
round-trip serialization.

Phase 2 — Expanded primitives: app-url, url-for, asset-url, config,
format-date, parse-int (pure); service, request-arg, request-path,
nav-tree, get-children (I/O); jinja-global, relations-from (pure).
Updated _io_service to accept (service "registry-name" "method" :kwargs)
with auto kebab→snake conversion. DTO-to-dict now expands datetime fields
into year/month/day convenience keys. Tuple returns converted to lists.

Phase 3 — Declarative handlers: HandlerDef type, defhandler special form,
handler registry (service → name → HandlerDef), async evaluator+renderer
(async_eval.py) that awaits I/O primitives inline within control flow.
Handler loading from .sx files, execute_handler, blueprint factory.

Phase 4 — Convert all fragment routes: 13 Python fragment handlers across
8 services replaced with declarative .sx handler files. All routes.py
simplified to uniform sx dispatch pattern. Two Jinja HTML handlers
(events/container-cards, events/account-page) kept as Python.

New files: shared/sx/async_eval.py, shared/sx/handlers.py,
shared/sx/tests/test_handlers.py, plus 13 handler .sx files under
{service}/sx/handlers/. MarketService.product_by_slug() added.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-03-03 00:22:18 +00:00
parent 13bcf755f6
commit ab75e505a8
48 changed files with 2538 additions and 638 deletions

View File

@@ -25,7 +25,7 @@ import hashlib
import os
from typing import Any
from .types import NIL, Component, Keyword, Symbol
from .types import NIL, Component, Keyword, Macro, Symbol
from .parser import parse
from .html import render as html_render, _render_component
@@ -54,7 +54,7 @@ def get_component_hash() -> str:
def _compute_component_hash() -> None:
"""Recompute _COMPONENT_HASH from all registered Component definitions."""
"""Recompute _COMPONENT_HASH from all registered Component and Macro definitions."""
global _COMPONENT_HASH
from .parser import serialize
parts = []
@@ -67,6 +67,13 @@ def _compute_component_hash() -> None:
params_sx = "(" + " ".join(param_strs) + ")"
body_sx = serialize(val.body)
parts.append(f"(defcomp ~{val.name} {params_sx} {body_sx})")
elif isinstance(val, Macro):
param_strs = list(val.params)
if val.rest_param:
param_strs.extend(["&rest", val.rest_param])
params_sx = "(" + " ".join(param_strs) + ")"
body_sx = serialize(val.body)
parts.append(f"(defmacro {val.name} {params_sx} {body_sx})")
if parts:
digest = hashlib.sha256("\n".join(parts).encode()).hexdigest()[:12]
_COMPONENT_HASH = digest
@@ -118,13 +125,33 @@ def reload_if_changed() -> None:
load_sx_dir(directory)
def load_service_components(service_dir: str) -> None:
"""Load service-specific s-expression components from {service_dir}/sx/."""
def load_service_components(service_dir: str, service_name: str | None = None) -> None:
"""Load service-specific s-expression components and handlers.
Components from ``{service_dir}/sx/`` and handlers from
``{service_dir}/sx/handlers/`` or ``{service_dir}/sx/handlers.sx``.
"""
sx_dir = os.path.join(service_dir, "sx")
if os.path.isdir(sx_dir):
load_sx_dir(sx_dir)
watch_sx_dir(sx_dir)
# Load handler definitions if service_name is provided
if service_name:
load_handler_dir(os.path.join(sx_dir, "handlers"), service_name)
# Also check for a single handlers.sx file
handlers_file = os.path.join(sx_dir, "handlers.sx")
if os.path.isfile(handlers_file):
from .handlers import load_handler_file
load_handler_file(handlers_file, service_name)
def load_handler_dir(directory: str, service_name: str) -> None:
"""Load handler .sx files from a directory if it exists."""
if os.path.isdir(directory):
from .handlers import load_handler_dir as _load
_load(directory, service_name)
def register_components(sx_source: str) -> None:
"""Parse and evaluate s-expression component definitions into the
@@ -262,17 +289,25 @@ def client_components_tag(*names: str) -> str:
from .parser import serialize
parts = []
for key, val in _COMPONENT_ENV.items():
if not isinstance(val, Component):
continue
if names and val.name not in names and key.lstrip("~") not in names:
continue
# Reconstruct defcomp source from the Component object
param_strs = ["&key"] + list(val.params)
if val.has_children:
param_strs.extend(["&rest", "children"])
params_sx = "(" + " ".join(param_strs) + ")"
body_sx = serialize(val.body, pretty=True)
parts.append(f"(defcomp ~{val.name} {params_sx} {body_sx})")
if isinstance(val, Component):
if names and val.name not in names and key.lstrip("~") not in names:
continue
# Reconstruct defcomp source from the Component object
param_strs = ["&key"] + list(val.params)
if val.has_children:
param_strs.extend(["&rest", "children"])
params_sx = "(" + " ".join(param_strs) + ")"
body_sx = serialize(val.body, pretty=True)
parts.append(f"(defcomp ~{val.name} {params_sx} {body_sx})")
elif isinstance(val, Macro):
if names and val.name not in names:
continue
param_strs = list(val.params)
if val.rest_param:
param_strs.extend(["&rest", val.rest_param])
params_sx = "(" + " ".join(param_strs) + ")"
body_sx = serialize(val.body, pretty=True)
parts.append(f"(defmacro {val.name} {params_sx} {body_sx})")
if not parts:
return ""
source = "\n".join(parts)