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

@@ -33,7 +33,7 @@ from __future__ import annotations
from typing import Any
from .types import Component, Keyword, Lambda, NIL, RelationDef, Symbol
from .types import Component, HandlerDef, Keyword, Lambda, Macro, NIL, RelationDef, Symbol
from .primitives import _PRIMITIVES
@@ -117,6 +117,13 @@ def _eval(expr: Any, env: dict[str, Any]) -> Any:
if ho is not None:
return ho(expr, env)
# Macro expansion — if head resolves to a Macro, expand then eval
if name in env:
val = env[name]
if isinstance(val, Macro):
expanded = _expand_macro(val, expr[1:], env)
return _eval(expanded, env)
# --- function / lambda call -------------------------------------------
fn = _eval(head, env)
args = [_eval(a, env) for a in expr[1:]]
@@ -417,6 +424,135 @@ def _sf_thread_first(expr: list, env: dict) -> Any:
return result
def _sf_defmacro(expr: list, env: dict) -> Macro:
"""``(defmacro name (params... &rest rest) body)``"""
if len(expr) < 4:
raise EvalError("defmacro requires name, params, and body")
name_sym = expr[1]
if not isinstance(name_sym, Symbol):
raise EvalError(f"defmacro name must be symbol, got {type(name_sym).__name__}")
params_expr = expr[2]
if not isinstance(params_expr, list):
raise EvalError("defmacro params must be a list")
params: list[str] = []
rest_param: str | None = None
i = 0
while i < len(params_expr):
p = params_expr[i]
if isinstance(p, Symbol) and p.name == "&rest":
if i + 1 < len(params_expr):
rp = params_expr[i + 1]
rest_param = rp.name if isinstance(rp, Symbol) else str(rp)
break
if isinstance(p, Symbol):
params.append(p.name)
elif isinstance(p, str):
params.append(p)
i += 1
macro = Macro(
params=params,
rest_param=rest_param,
body=expr[3],
closure=dict(env),
name=name_sym.name,
)
env[name_sym.name] = macro
return macro
def _sf_quasiquote(expr: list, env: dict) -> Any:
"""``(quasiquote template)`` — process quasiquote template."""
if len(expr) < 2:
raise EvalError("quasiquote requires a template")
return _qq_expand(expr[1], env)
def _qq_expand(template: Any, env: dict) -> Any:
"""Walk a quasiquote template, replacing unquote/splice-unquote."""
if not isinstance(template, list):
return template
if not template:
return []
# Check for (unquote x) or (splice-unquote x)
head = template[0]
if isinstance(head, Symbol):
if head.name == "unquote":
if len(template) < 2:
raise EvalError("unquote requires an expression")
return _eval(template[1], env)
if head.name == "splice-unquote":
raise EvalError("splice-unquote not inside a list")
# Walk children, handling splice-unquote
result: list[Any] = []
for item in template:
if isinstance(item, list) and len(item) == 2 and isinstance(item[0], Symbol) and item[0].name == "splice-unquote":
spliced = _eval(item[1], env)
if isinstance(spliced, list):
result.extend(spliced)
elif spliced is not None and spliced is not NIL:
result.append(spliced)
else:
result.append(_qq_expand(item, env))
return result
def _expand_macro(macro: Macro, raw_args: list[Any], env: dict) -> Any:
"""Expand a macro: bind unevaluated args, evaluate body to get new AST."""
local = dict(macro.closure)
local.update(env)
# Bind positional params
for i, param in enumerate(macro.params):
if i < len(raw_args):
local[param] = raw_args[i]
else:
local[param] = NIL
# Bind &rest param
if macro.rest_param is not None:
rest_start = len(macro.params)
local[macro.rest_param] = list(raw_args[rest_start:])
return _eval(macro.body, local)
def _sf_defhandler(expr: list, env: dict) -> HandlerDef:
"""``(defhandler name (&key param...) body)``"""
if len(expr) < 4:
raise EvalError("defhandler requires name, params, and body")
name_sym = expr[1]
if not isinstance(name_sym, Symbol):
raise EvalError(f"defhandler name must be symbol, got {type(name_sym).__name__}")
params_expr = expr[2]
if not isinstance(params_expr, list):
raise EvalError("defhandler params must be a list")
params: list[str] = []
in_key = False
for p in params_expr:
if isinstance(p, Symbol):
if p.name == "&key":
in_key = True
continue
if in_key:
params.append(p.name)
elif isinstance(p, str):
params.append(p)
handler = HandlerDef(
name=name_sym.name,
params=params,
body=expr[3],
closure=dict(env),
)
env[f"handler:{name_sym.name}"] = handler
return handler
def _sf_set_bang(expr: list, env: dict) -> Any:
"""``(set! name value)`` — mutate existing binding."""
if len(expr) != 3:
@@ -518,6 +654,9 @@ _SPECIAL_FORMS: dict[str, Any] = {
"quote": _sf_quote,
"->": _sf_thread_first,
"set!": _sf_set_bang,
"defmacro": _sf_defmacro,
"quasiquote": _sf_quasiquote,
"defhandler": _sf_defhandler,
}