Merge branch 'macros' into worktree-sx-meta-eval

This commit is contained in:
2026-03-05 10:03:15 +00:00
301 changed files with 22850 additions and 18171 deletions

View File

@@ -10,6 +10,28 @@ control flow (``if``, ``let``, ``map``, ``when``). The sync
collect-then-substitute resolver can't handle data dependencies between
I/O results and control flow, so handlers need inline async evaluation.
Evaluation modes
~~~~~~~~~~~~~~~~
The same component AST can be evaluated in different modes depending on
where the rendering boundary is drawn (server vs client). Five modes
exist across the codebase:
Function Expands components? Output Used for
-------------------- ------------------- -------------- ----------------------------
_eval (sync) Yes Python values register_components, Jinja sx()
_arender (async) Yes HTML render_to_html
_aser (async) No — serializes SX wire format render_to_sx
_aser_component Yes, one level SX wire format render_to_sx_with_env (layouts)
sx.js renderDOM Yes DOM nodes Client-side
_aser deliberately does NOT expand ~component calls — it serializes them
as SX wire format so the client can render them. But layout components
(used by render_to_sx_with_env) need server-side expansion because they
depend on Python context (auth state, fragments, etc.). That's what
_aser_component / async_eval_slot_to_sx provides: expand the top-level
component body server-side, then serialize its children for the client.
Usage::
from shared.sx.async_eval import async_render
@@ -19,25 +41,54 @@ Usage::
from __future__ import annotations
import inspect
from typing import Any
from .types import Component, Keyword, Lambda, Macro, NIL, Symbol
from .types import Component, Keyword, Lambda, Macro, NIL, StyleValue, Symbol
from .evaluator import _expand_macro, EvalError
from .primitives import _PRIMITIVES
from .primitives_io import IO_PRIMITIVES, RequestContext, execute_io
from .parser import SxExpr, serialize
from .html import (
HTML_TAGS, VOID_ELEMENTS, BOOLEAN_ATTRS,
escape_text, escape_attr, _RawHTML, css_class_collector,
escape_text, escape_attr, _RawHTML, css_class_collector, _svg_context,
)
# ---------------------------------------------------------------------------
# Async TCO — thunk + trampoline
# ---------------------------------------------------------------------------
class _AsyncThunk:
"""Deferred (expr, env, ctx) for tail-call optimization."""
__slots__ = ("expr", "env", "ctx")
def __init__(self, expr: Any, env: dict[str, Any], ctx: RequestContext) -> None:
self.expr = expr
self.env = env
self.ctx = ctx
async def _async_trampoline(val: Any) -> Any:
"""Iteratively resolve thunks from tail positions."""
while isinstance(val, _AsyncThunk):
val = await _async_eval(val.expr, val.env, val.ctx)
return val
# ---------------------------------------------------------------------------
# Async evaluate
# ---------------------------------------------------------------------------
async def async_eval(expr: Any, env: dict[str, Any], ctx: RequestContext) -> Any:
"""Evaluate *expr* in *env*, awaiting I/O primitives inline."""
"""Public entry — evaluates and trampolines thunks."""
result = await _async_eval(expr, env, ctx)
while isinstance(result, _AsyncThunk):
result = await _async_eval(result.expr, result.env, result.ctx)
return result
async def _async_eval(expr: Any, env: dict[str, Any], ctx: RequestContext) -> Any:
"""Internal evaluator — may return _AsyncThunk for tail positions."""
# --- literals ---
if isinstance(expr, (int, float, str, bool)):
return expr
@@ -65,7 +116,7 @@ async def async_eval(expr: Any, env: dict[str, Any], ctx: RequestContext) -> Any
# --- dict literal ---
if isinstance(expr, dict):
return {k: await async_eval(v, env, ctx) for k, v in expr.items()}
return {k: await _async_trampoline(await _async_eval(v, env, ctx)) for k, v in expr.items()}
# --- list ---
if not isinstance(expr, list):
@@ -76,7 +127,7 @@ async def async_eval(expr: Any, env: dict[str, Any], ctx: RequestContext) -> Any
head = expr[0]
if not isinstance(head, (Symbol, Lambda, list)):
return [await async_eval(x, env, ctx) for x in expr]
return [await _async_trampoline(await _async_eval(x, env, ctx)) for x in expr]
if isinstance(head, Symbol):
name = head.name
@@ -95,12 +146,12 @@ async def async_eval(expr: Any, env: dict[str, Any], ctx: RequestContext) -> Any
if ho is not None:
return await ho(expr, env, ctx)
# Macro expansion
# Macro expansion — tail position
if name in env:
val = env[name]
if isinstance(val, Macro):
expanded = _expand_macro(val, expr[1:], env)
return await async_eval(expanded, env, ctx)
return _AsyncThunk(expanded, env, ctx)
# Render forms in eval position — delegate to renderer and return
# as _RawHTML so it won't be double-escaped when used in render
@@ -110,11 +161,14 @@ async def async_eval(expr: Any, env: dict[str, Any], ctx: RequestContext) -> Any
return _RawHTML(html)
# --- function / lambda call ---
fn = await async_eval(head, env, ctx)
args = [await async_eval(a, env, ctx) for a in expr[1:]]
fn = await _async_trampoline(await _async_eval(head, env, ctx))
args = [await _async_trampoline(await _async_eval(a, env, ctx)) for a in expr[1:]]
if callable(fn) and not isinstance(fn, (Lambda, Component)):
return fn(*args)
result = fn(*args)
if inspect.iscoroutine(result):
return await result
return result
if isinstance(fn, Lambda):
return await _async_call_lambda(fn, args, env, ctx)
if isinstance(fn, Component):
@@ -149,7 +203,7 @@ async def _async_call_lambda(
local.update(caller_env)
for p, v in zip(fn.params, args):
local[p] = v
return await async_eval(fn.body, local, ctx)
return _AsyncThunk(fn.body, local, ctx)
async def _async_call_component(
@@ -172,7 +226,7 @@ async def _async_call_component(
local[p] = kwargs.get(p, NIL)
if comp.has_children:
local["children"] = children
return await async_eval(comp.body, local, ctx)
return _AsyncThunk(comp.body, local, ctx)
# ---------------------------------------------------------------------------
@@ -180,28 +234,28 @@ async def _async_call_component(
# ---------------------------------------------------------------------------
async def _asf_if(expr, env, ctx):
cond = await async_eval(expr[1], env, ctx)
cond = await _async_trampoline(await _async_eval(expr[1], env, ctx))
if cond and cond is not NIL:
return await async_eval(expr[2], env, ctx)
return _AsyncThunk(expr[2], env, ctx)
if len(expr) > 3:
return await async_eval(expr[3], env, ctx)
return _AsyncThunk(expr[3], env, ctx)
return NIL
async def _asf_when(expr, env, ctx):
cond = await async_eval(expr[1], env, ctx)
cond = await _async_trampoline(await _async_eval(expr[1], env, ctx))
if cond and cond is not NIL:
result = NIL
for body_expr in expr[2:]:
result = await async_eval(body_expr, env, ctx)
return result
for body_expr in expr[2:-1]:
await _async_trampoline(await _async_eval(body_expr, env, ctx))
if len(expr) > 2:
return _AsyncThunk(expr[-1], env, ctx)
return NIL
async def _asf_and(expr, env, ctx):
result: Any = True
for arg in expr[1:]:
result = await async_eval(arg, env, ctx)
result = await _async_trampoline(await _async_eval(arg, env, ctx))
if not result:
return result
return result
@@ -210,7 +264,7 @@ async def _asf_and(expr, env, ctx):
async def _asf_or(expr, env, ctx):
result: Any = False
for arg in expr[1:]:
result = await async_eval(arg, env, ctx)
result = await _async_trampoline(await _async_eval(arg, env, ctx))
if result:
return result
return result
@@ -224,16 +278,17 @@ async def _asf_let(expr, env, ctx):
for binding in bindings:
var = binding[0]
vname = var.name if isinstance(var, Symbol) else var
local[vname] = await async_eval(binding[1], local, ctx)
local[vname] = await _async_trampoline(await _async_eval(binding[1], local, ctx))
elif len(bindings) % 2 == 0:
for i in range(0, len(bindings), 2):
var = bindings[i]
vname = var.name if isinstance(var, Symbol) else var
local[vname] = await async_eval(bindings[i + 1], local, ctx)
result: Any = NIL
for body_expr in expr[2:]:
result = await async_eval(body_expr, local, ctx)
return result
local[vname] = await _async_trampoline(await _async_eval(bindings[i + 1], local, ctx))
for body_expr in expr[2:-1]:
await _async_trampoline(await _async_eval(body_expr, local, ctx))
if len(expr) > 2:
return _AsyncThunk(expr[-1], local, ctx)
return NIL
async def _asf_lambda(expr, env, ctx):
@@ -249,7 +304,7 @@ async def _asf_lambda(expr, env, ctx):
async def _asf_define(expr, env, ctx):
name_sym = expr[1]
value = await async_eval(expr[2], env, ctx)
value = await _async_trampoline(await _async_eval(expr[2], env, ctx))
if isinstance(value, Lambda) and value.name is None:
value.name = name_sym.name
env[name_sym.name] = value
@@ -261,6 +316,16 @@ async def _asf_defcomp(expr, env, ctx):
return _sf_defcomp(expr, env)
async def _asf_defstyle(expr, env, ctx):
from .evaluator import _sf_defstyle
return _sf_defstyle(expr, env)
async def _asf_defkeyframes(expr, env, ctx):
from .evaluator import _sf_defkeyframes
return _sf_defkeyframes(expr, env)
async def _asf_defmacro(expr, env, ctx):
from .evaluator import _sf_defmacro
return _sf_defmacro(expr, env)
@@ -272,10 +337,11 @@ async def _asf_defhandler(expr, env, ctx):
async def _asf_begin(expr, env, ctx):
result: Any = NIL
for sub in expr[1:]:
result = await async_eval(sub, env, ctx)
return result
for sub in expr[1:-1]:
await _async_trampoline(await _async_eval(sub, env, ctx))
if len(expr) > 1:
return _AsyncThunk(expr[-1], env, ctx)
return NIL
async def _asf_quote(expr, env, ctx):
@@ -321,63 +387,65 @@ async def _asf_cond(expr, env, ctx):
for clause in clauses:
test = clause[0]
if isinstance(test, Symbol) and test.name in ("else", ":else"):
return await async_eval(clause[1], env, ctx)
return _AsyncThunk(clause[1], env, ctx)
if isinstance(test, Keyword) and test.name == "else":
return await async_eval(clause[1], env, ctx)
if await async_eval(test, env, ctx):
return await async_eval(clause[1], env, ctx)
return _AsyncThunk(clause[1], env, ctx)
if await _async_trampoline(await _async_eval(test, env, ctx)):
return _AsyncThunk(clause[1], env, ctx)
else:
i = 0
while i < len(clauses) - 1:
test = clauses[i]
result = clauses[i + 1]
if isinstance(test, Keyword) and test.name == "else":
return await async_eval(result, env, ctx)
return _AsyncThunk(result, env, ctx)
if isinstance(test, Symbol) and test.name in (":else", "else"):
return await async_eval(result, env, ctx)
if await async_eval(test, env, ctx):
return await async_eval(result, env, ctx)
return _AsyncThunk(result, env, ctx)
if await _async_trampoline(await _async_eval(test, env, ctx)):
return _AsyncThunk(result, env, ctx)
i += 2
return NIL
async def _asf_case(expr, env, ctx):
match_val = await async_eval(expr[1], env, ctx)
match_val = await _async_trampoline(await _async_eval(expr[1], env, ctx))
clauses = expr[2:]
i = 0
while i < len(clauses) - 1:
test = clauses[i]
result = clauses[i + 1]
if isinstance(test, Keyword) and test.name == "else":
return await async_eval(result, env, ctx)
return _AsyncThunk(result, env, ctx)
if isinstance(test, Symbol) and test.name in (":else", "else"):
return await async_eval(result, env, ctx)
if match_val == await async_eval(test, env, ctx):
return await async_eval(result, env, ctx)
return _AsyncThunk(result, env, ctx)
if match_val == await _async_trampoline(await _async_eval(test, env, ctx)):
return _AsyncThunk(result, env, ctx)
i += 2
return NIL
async def _asf_thread_first(expr, env, ctx):
result = await async_eval(expr[1], env, ctx)
result = await _async_trampoline(await _async_eval(expr[1], env, ctx))
for form in expr[2:]:
if isinstance(form, list):
fn = await async_eval(form[0], env, ctx)
args = [result] + [await async_eval(a, env, ctx) for a in form[1:]]
fn = await _async_trampoline(await _async_eval(form[0], env, ctx))
args = [result] + [await _async_trampoline(await _async_eval(a, env, ctx)) for a in form[1:]]
else:
fn = await async_eval(form, env, ctx)
fn = await _async_trampoline(await _async_eval(form, env, ctx))
args = [result]
if callable(fn) and not isinstance(fn, (Lambda, Component)):
result = fn(*args)
if inspect.iscoroutine(result):
result = await result
elif isinstance(fn, Lambda):
result = await _async_call_lambda(fn, args, env, ctx)
result = await _async_trampoline(await _async_call_lambda(fn, args, env, ctx))
else:
raise EvalError(f"-> form not callable: {fn!r}")
return result
async def _asf_set_bang(expr, env, ctx):
value = await async_eval(expr[2], env, ctx)
value = await _async_trampoline(await _async_eval(expr[2], env, ctx))
env[expr[1].name] = value
return value
@@ -394,6 +462,8 @@ _ASYNC_SPECIAL_FORMS: dict[str, Any] = {
"lambda": _asf_lambda,
"fn": _asf_lambda,
"define": _asf_define,
"defstyle": _asf_defstyle,
"defkeyframes": _asf_defkeyframes,
"defcomp": _asf_defcomp,
"defmacro": _asf_defmacro,
"defhandler": _asf_defhandler,
@@ -416,9 +486,10 @@ async def _aho_map(expr, env, ctx):
results = []
for item in coll:
if isinstance(fn, Lambda):
results.append(await _async_call_lambda(fn, [item], env, ctx))
results.append(await _async_trampoline(await _async_call_lambda(fn, [item], env, ctx)))
elif callable(fn):
results.append(fn(item))
r = fn(item)
results.append(await r if inspect.iscoroutine(r) else r)
else:
raise EvalError(f"map requires callable, got {type(fn).__name__}")
return results
@@ -430,9 +501,10 @@ async def _aho_map_indexed(expr, env, ctx):
results = []
for i, item in enumerate(coll):
if isinstance(fn, Lambda):
results.append(await _async_call_lambda(fn, [i, item], env, ctx))
results.append(await _async_trampoline(await _async_call_lambda(fn, [i, item], env, ctx)))
elif callable(fn):
results.append(fn(i, item))
r = fn(i, item)
results.append(await r if inspect.iscoroutine(r) else r)
else:
raise EvalError(f"map-indexed requires callable, got {type(fn).__name__}")
return results
@@ -444,9 +516,11 @@ async def _aho_filter(expr, env, ctx):
results = []
for item in coll:
if isinstance(fn, Lambda):
val = await _async_call_lambda(fn, [item], env, ctx)
val = await _async_trampoline(await _async_call_lambda(fn, [item], env, ctx))
elif callable(fn):
val = fn(item)
if inspect.iscoroutine(val):
val = await val
else:
raise EvalError(f"filter requires callable, got {type(fn).__name__}")
if val:
@@ -459,7 +533,12 @@ async def _aho_reduce(expr, env, ctx):
acc = await async_eval(expr[2], env, ctx)
coll = await async_eval(expr[3], env, ctx)
for item in coll:
acc = await _async_call_lambda(fn, [acc, item], env, ctx) if isinstance(fn, Lambda) else fn(acc, item)
if isinstance(fn, Lambda):
acc = await _async_trampoline(await _async_call_lambda(fn, [acc, item], env, ctx))
else:
acc = fn(acc, item)
if inspect.iscoroutine(acc):
acc = await acc
return acc
@@ -467,7 +546,12 @@ async def _aho_some(expr, env, ctx):
fn = await async_eval(expr[1], env, ctx)
coll = await async_eval(expr[2], env, ctx)
for item in coll:
result = await _async_call_lambda(fn, [item], env, ctx) if isinstance(fn, Lambda) else fn(item)
if isinstance(fn, Lambda):
result = await _async_trampoline(await _async_call_lambda(fn, [item], env, ctx))
else:
result = fn(item)
if inspect.iscoroutine(result):
result = await result
if result:
return result
return NIL
@@ -477,7 +561,13 @@ async def _aho_every(expr, env, ctx):
fn = await async_eval(expr[1], env, ctx)
coll = await async_eval(expr[2], env, ctx)
for item in coll:
if not (await _async_call_lambda(fn, [item], env, ctx) if isinstance(fn, Lambda) else fn(item)):
if isinstance(fn, Lambda):
val = await _async_trampoline(await _async_call_lambda(fn, [item], env, ctx))
else:
val = fn(item)
if inspect.iscoroutine(val):
val = await val
if not val:
return False
return True
@@ -487,9 +577,11 @@ async def _aho_for_each(expr, env, ctx):
coll = await async_eval(expr[2], env, ctx)
for item in coll:
if isinstance(fn, Lambda):
await _async_call_lambda(fn, [item], env, ctx)
await _async_trampoline(await _async_call_lambda(fn, [item], env, ctx))
elif callable(fn):
fn(item)
r = fn(item)
if inspect.iscoroutine(r):
await r
return NIL
@@ -573,9 +665,19 @@ async def _arender_list(expr: list, env: dict[str, Any], ctx: RequestContext) ->
parts.append(await _arender(child, env, ctx))
return "".join(parts)
# html: prefix → force tag rendering
if name.startswith("html:"):
return await _arender_element(name[5:], expr[1:], env, ctx)
# Render-aware special forms
# If name is also an HTML tag and (keyword arg or SVG context) → tag call
arsf = _ASYNC_RENDER_FORMS.get(name)
if arsf is not None:
if name in HTML_TAGS and (
(len(expr) > 1 and isinstance(expr[1], Keyword))
or _svg_context.get(False)
):
return await _arender_element(name, expr[1:], env, ctx)
return await arsf(expr, env, ctx)
# Macro expansion
@@ -595,6 +697,14 @@ async def _arender_list(expr: list, env: dict[str, Any], ctx: RequestContext) ->
if isinstance(val, Component):
return await _arender_component(val, expr[1:], env, ctx)
# Custom element (hyphenated name with keyword attrs) → tag
if "-" in name and len(expr) > 1 and isinstance(expr[1], Keyword):
return await _arender_element(name, expr[1:], env, ctx)
# SVG/MathML context → unknown names are child elements
if _svg_context.get(False):
return await _arender_element(name, expr[1:], env, ctx)
# Fallback — evaluate then render
result = await async_eval(expr, env, ctx)
return await _arender(result, env, ctx)
@@ -626,6 +736,18 @@ async def _arender_element(
children.append(arg)
i += 1
# Handle :style StyleValue — convert to class and register CSS rule
style_val = attrs.get("style")
if isinstance(style_val, StyleValue):
from .css_registry import register_generated_rule
register_generated_rule(style_val)
existing_class = attrs.get("class")
if existing_class and existing_class is not NIL and existing_class is not False:
attrs["class"] = f"{existing_class} {style_val.class_name}"
else:
attrs["class"] = style_val.class_name
del attrs["style"]
class_val = attrs.get("class")
if class_val is not None and class_val is not NIL and class_val is not False:
collector = css_class_collector.get(None)
@@ -649,9 +771,19 @@ async def _arender_element(
if tag in VOID_ELEMENTS:
return opening
child_parts = []
for child in children:
child_parts.append(await _arender(child, env, ctx))
# SVG/MathML namespace auto-detection: set context for children
token = None
if tag in ("svg", "math"):
token = _svg_context.set(True)
try:
child_parts = []
for child in children:
child_parts.append(await _arender(child, env, ctx))
finally:
if token is not None:
_svg_context.reset(token)
return f"{opening}{''.join(child_parts)}</{tag}>"
@@ -782,7 +914,10 @@ async def _arsf_map(expr, env, ctx):
if isinstance(fn, Lambda):
parts.append(await _arender_lambda(fn, (item,), env, ctx))
elif callable(fn):
parts.append(await _arender(fn(item), env, ctx))
r = fn(item)
if inspect.iscoroutine(r):
r = await r
parts.append(await _arender(r, env, ctx))
else:
parts.append(await _arender(item, env, ctx))
return "".join(parts)
@@ -796,7 +931,10 @@ async def _arsf_map_indexed(expr, env, ctx):
if isinstance(fn, Lambda):
parts.append(await _arender_lambda(fn, (i, item), env, ctx))
elif callable(fn):
parts.append(await _arender(fn(i, item), env, ctx))
r = fn(i, item)
if inspect.iscoroutine(r):
r = await r
parts.append(await _arender(r, env, ctx))
else:
parts.append(await _arender(item, env, ctx))
return "".join(parts)
@@ -815,7 +953,10 @@ async def _arsf_for_each(expr, env, ctx):
if isinstance(fn, Lambda):
parts.append(await _arender_lambda(fn, (item,), env, ctx))
elif callable(fn):
parts.append(await _arender(fn(item), env, ctx))
r = fn(item)
if inspect.iscoroutine(r):
r = await r
parts.append(await _arender(r, env, ctx))
else:
parts.append(await _arender(item, env, ctx))
return "".join(parts)
@@ -830,6 +971,8 @@ _ASYNC_RENDER_FORMS: dict[str, Any] = {
"begin": _arsf_begin,
"do": _arsf_begin,
"define": _arsf_define,
"defstyle": _arsf_define,
"defkeyframes": _arsf_define,
"defcomp": _arsf_define,
"defmacro": _arsf_define,
"defhandler": _arsf_define,
@@ -867,10 +1010,92 @@ async def async_eval_to_sx(
ctx = RequestContext()
result = await _aser(expr, env, ctx)
if isinstance(result, SxExpr):
return result.source
return result
if result is None or result is NIL:
return ""
return serialize(result)
return SxExpr("")
if isinstance(result, str):
return SxExpr(result)
return SxExpr(serialize(result))
async def _maybe_expand_component_result(
result: Any,
env: dict[str, Any],
ctx: RequestContext,
) -> Any:
"""If *result* is a component call (SxExpr or string starting with
``(~``), re-parse and expand it server-side.
This ensures Python-only helpers (e.g. ``highlight``) inside the
component body are evaluated on the server rather than being
serialized for the client where they don't exist.
"""
raw = None
if isinstance(result, SxExpr):
raw = str(result).strip()
elif isinstance(result, str):
raw = result.strip()
if raw and raw.startswith("(~"):
from .parser import parse_all
parsed = parse_all(raw)
if parsed:
return await async_eval_slot_to_sx(parsed[0], env, ctx)
return result
async def async_eval_slot_to_sx(
expr: Any,
env: dict[str, Any],
ctx: RequestContext | None = None,
) -> str:
"""Like async_eval_to_sx but expands component calls.
Used by defpage slot evaluation where the content expression is
typically a component call like ``(~dashboard-content)``. Normal
``async_eval_to_sx`` serializes component calls without expanding;
this variant expands one level so IO primitives in the body execute,
then serializes the result as SX wire format.
"""
if ctx is None:
ctx = RequestContext()
# If expr is a component call, expand it through _aser
if isinstance(expr, list) and expr:
head = expr[0]
if isinstance(head, Symbol) and head.name.startswith("~"):
comp = env.get(head.name)
if isinstance(comp, Component):
result = await _aser_component(comp, expr[1:], env, ctx)
if isinstance(result, SxExpr):
return result
if result is None or result is NIL:
return SxExpr("")
if isinstance(result, str):
return SxExpr(result)
return SxExpr(serialize(result))
else:
import logging
logging.getLogger("sx.eval").error(
"async_eval_slot_to_sx: component %s not found in env "
"(will fall through to _aser and serialize unexpanded — "
"client will see 'Unknown component'). "
"Check that the .sx file is loaded and the service's sx/ "
"directory is bind-mounted in docker-compose.dev.yml.",
head.name,
)
# Fall back to normal async_eval_to_sx
result = await _aser(expr, env, ctx)
# If the result is a component call (from case/if/let branches or
# page helpers returning strings), re-parse and expand it server-side
# so that Python-only helpers like ``highlight`` in the component body
# get evaluated here, not on the client.
result = await _maybe_expand_component_result(result, env, ctx)
if isinstance(result, SxExpr):
return result
if result is None or result is NIL:
return SxExpr("")
if isinstance(result, str):
return SxExpr(result)
return SxExpr(serialize(result))
async def _aser(expr: Any, env: dict[str, Any], ctx: RequestContext) -> Any:
@@ -878,10 +1103,10 @@ async def _aser(expr: Any, env: dict[str, Any], ctx: RequestContext) -> Any:
for everything else."""
if isinstance(expr, (int, float, bool)):
return expr
if isinstance(expr, str):
return expr
if isinstance(expr, SxExpr):
return expr
if isinstance(expr, str):
return expr
if expr is None or expr is NIL:
return NIL
@@ -930,14 +1155,28 @@ async def _aser(expr: Any, env: dict[str, Any], ctx: RequestContext) -> Any:
if name == "raw!":
return await _aser_call("raw!", expr[1:], env, ctx)
# Component call — serialize (don't expand)
# html: prefix → force tag serialization
if name.startswith("html:"):
return await _aser_call(name[5:], expr[1:], env, ctx)
# Component call — expand macros, serialize regular components
if name.startswith("~"):
val = env.get(name)
if isinstance(val, Macro):
expanded = _expand_macro(val, expr[1:], env)
return await _aser(expanded, env, ctx)
return await _aser_call(name, expr[1:], env, ctx)
# Serialize-mode special/HO forms (checked BEFORE HTML_TAGS
# because some names like "map" are both HTML tags and sx forms)
# because some names like "map" are both HTML tags and sx forms).
# If name is also an HTML tag and (keyword arg or SVG context) → tag call.
sf = _ASER_FORMS.get(name)
if sf is not None:
if name in HTML_TAGS and (
(len(expr) > 1 and isinstance(expr[1], Keyword))
or _svg_context.get(False)
):
return await _aser_call(name, expr[1:], env, ctx)
return await sf(expr, env, ctx)
# HTML tag — serialize (don't render to HTML)
@@ -951,14 +1190,25 @@ async def _aser(expr: Any, env: dict[str, Any], ctx: RequestContext) -> Any:
expanded = _expand_macro(val, expr[1:], env)
return await _aser(expanded, env, ctx)
# Custom element (hyphenated name with keyword attrs) → serialize as tag
if "-" in name and len(expr) > 1 and isinstance(expr[1], Keyword):
return await _aser_call(name, expr[1:], env, ctx)
# SVG/MathML context → unknown names are child elements
if _svg_context.get(False):
return await _aser_call(name, expr[1:], env, ctx)
# Function / lambda call — evaluate (produces data, not rendering)
fn = await async_eval(head, env, ctx)
args = [await async_eval(a, env, ctx) for a in expr[1:]]
if callable(fn) and not isinstance(fn, (Lambda, Component)):
return fn(*args)
result = fn(*args)
if inspect.iscoroutine(result):
return await result
return result
if isinstance(fn, Lambda):
return await _async_call_lambda(fn, args, env, ctx)
return await _async_trampoline(await _async_call_lambda(fn, args, env, ctx))
if isinstance(fn, Component):
# Component invoked as function — serialize the call
return await _aser_call(f"~{fn.name}", expr[1:], env, ctx)
@@ -982,27 +1232,121 @@ async def _aser_fragment(children: list, env: dict, ctx: RequestContext) -> SxEx
return SxExpr("(<> " + " ".join(parts) + ")")
async def _aser_component(
comp: Component, args: list, env: dict, ctx: RequestContext,
) -> Any:
"""Expand a component body through _aser — produces SX, not HTML."""
kwargs: dict[str, Any] = {}
children: list[Any] = []
i = 0
while i < len(args):
arg = args[i]
if isinstance(arg, Keyword) and i + 1 < len(args):
kwargs[arg.name] = await _aser(args[i + 1], env, ctx)
i += 2
else:
children.append(arg)
i += 1
local = dict(comp.closure)
local.update(env)
for p in comp.params:
local[p] = kwargs.get(p, NIL)
if comp.has_children:
child_parts = []
for c in children:
child_parts.append(serialize(await _aser(c, env, ctx)))
local["children"] = SxExpr("(<> " + " ".join(child_parts) + ")")
return await _aser(comp.body, local, ctx)
async def _aser_call(
name: str, args: list, env: dict, ctx: RequestContext,
) -> SxExpr:
"""Serialize ``(name :key val child ...)`` — evaluate args but keep
as sx source instead of rendering to HTML."""
parts = [name]
i = 0
while i < len(args):
arg = args[i]
if isinstance(arg, Keyword) and i + 1 < len(args):
val = await _aser(args[i + 1], env, ctx)
if val is not NIL and val is not None:
parts.append(f":{arg.name}")
parts.append(serialize(val))
i += 2
else:
result = await _aser(arg, env, ctx)
if result is not NIL and result is not None:
parts.append(serialize(result))
i += 1
return SxExpr("(" + " ".join(parts) + ")")
# SVG/MathML namespace auto-detection for serializer
token = None
if name in ("svg", "math"):
token = _svg_context.set(True)
try:
parts = [name]
extra_class: str | None = None # from :style StyleValue conversion
i = 0
while i < len(args):
arg = args[i]
if isinstance(arg, Keyword) and i + 1 < len(args):
val = await _aser(args[i + 1], env, ctx)
if val is not NIL and val is not None:
# :style StyleValue → convert to :class and register CSS
if arg.name == "style" and isinstance(val, StyleValue):
from .css_registry import register_generated_rule
register_generated_rule(val)
extra_class = val.class_name
else:
parts.append(f":{arg.name}")
# Plain list → serialize for the client.
# Rendered items (SxExpr) → wrap in (<> ...) fragment.
# Data items (dicts, strings, numbers) → (list ...)
# so the client gets an iterable array, not a
# DocumentFragment that breaks map/filter.
if isinstance(val, list):
live = [v for v in val
if v is not NIL and v is not None]
items = [serialize(v) for v in live]
if not items:
parts.append("nil")
elif any(isinstance(v, SxExpr) for v in live):
parts.append(
"(<> " + " ".join(items) + ")"
)
else:
parts.append(
"(list " + " ".join(items) + ")"
)
else:
parts.append(serialize(val))
i += 2
else:
result = await _aser(arg, env, ctx)
if result is not NIL and result is not None:
# Flatten list results (e.g. from map) into individual
# children, matching _aser_fragment behaviour
if isinstance(result, list):
for item in result:
if item is not NIL and item is not None:
parts.append(serialize(item))
else:
parts.append(serialize(result))
i += 1
# If we converted a :style to a class, merge into existing :class or add it
if extra_class:
_merge_class_into_parts(parts, extra_class)
return SxExpr("(" + " ".join(parts) + ")")
finally:
if token is not None:
_svg_context.reset(token)
def _merge_class_into_parts(parts: list[str], class_name: str) -> None:
"""Merge an extra class name into the serialized parts list.
If :class already exists, append to it. Otherwise add :class.
"""
for i, p in enumerate(parts):
if p == ":class" and i + 1 < len(parts):
# Existing :class — append our class
existing = parts[i + 1]
if existing.startswith('"') and existing.endswith('"'):
# Quoted string — insert before closing quote
parts[i + 1] = existing[:-1] + " " + class_name + '"'
else:
# Expression — wrap in (str ...)
parts[i + 1] = f'(str {existing} " {class_name}")'
return
# No existing :class — add one
parts.insert(1, f'"{class_name}"')
parts.insert(1, ":class")
# ---------------------------------------------------------------------------
@@ -1151,7 +1495,8 @@ async def _asho_ser_map(expr, env, ctx):
local[p] = v
results.append(await _aser(fn.body, local, ctx))
elif callable(fn):
results.append(fn(item))
r = fn(item)
results.append(await r if inspect.iscoroutine(r) else r)
else:
raise EvalError(f"map requires callable, got {type(fn).__name__}")
return results
@@ -1169,7 +1514,8 @@ async def _asho_ser_map_indexed(expr, env, ctx):
local[fn.params[1]] = item
results.append(await _aser(fn.body, local, ctx))
elif callable(fn):
results.append(fn(i, item))
r = fn(i, item)
results.append(await r if inspect.iscoroutine(r) else r)
else:
raise EvalError(f"map-indexed requires callable, got {type(fn).__name__}")
return results
@@ -1191,7 +1537,8 @@ async def _asho_ser_for_each(expr, env, ctx):
local[fn.params[0]] = item
results.append(await _aser(fn.body, local, ctx))
elif callable(fn):
results.append(fn(item))
r = fn(item)
results.append(await r if inspect.iscoroutine(r) else r)
return results
@@ -1207,6 +1554,8 @@ _ASER_FORMS: dict[str, Any] = {
"lambda": _assf_lambda,
"fn": _assf_lambda,
"define": _assf_define,
"defstyle": _assf_define,
"defkeyframes": _assf_define,
"defcomp": _assf_define,
"defmacro": _assf_define,
"defhandler": _assf_define,

View File

@@ -147,6 +147,48 @@ def scan_classes_from_sx(source: str) -> set[str]:
return classes
def register_generated_rule(style_val: Any) -> None:
"""Register a generated StyleValue's CSS rules in the registry.
This allows generated class names (``sx-a3f2c1``) to flow through
the existing ``lookup_rules()`` → ``SX-Css`` delta pipeline.
"""
from .style_dict import CHILD_SELECTOR_ATOMS
cn = style_val.class_name
if cn in _REGISTRY:
return # already registered
parts: list[str] = []
# Base declarations
if style_val.declarations:
parts.append(f".{cn}{{{style_val.declarations}}}")
# Pseudo-class rules
for sel, decls in style_val.pseudo_rules:
if sel.startswith("::"):
parts.append(f".{cn}{sel}{{{decls}}}")
elif "&" in sel:
# group-hover pattern: ":is(.group:hover) &" → .group:hover .sx-abc
expanded = sel.replace("&", f".{cn}")
parts.append(f"{expanded}{{{decls}}}")
else:
parts.append(f".{cn}{sel}{{{decls}}}")
# Media-query rules
for query, decls in style_val.media_rules:
parts.append(f"@media {query}{{.{cn}{{{decls}}}}}")
# Keyframes
for _name, kf_rule in style_val.keyframes:
parts.append(kf_rule)
rule_text = "".join(parts)
order = len(_RULE_ORDER) + 10000 # after all tw.css rules
_REGISTRY[cn] = rule_text
_RULE_ORDER[cn] = order
def registry_loaded() -> bool:
"""True if the registry has been populated."""
return bool(_REGISTRY)
@@ -252,6 +294,8 @@ def _css_selector_to_class(selector: str) -> str:
i += 2
elif name[i] == ':':
break # pseudo-class — stop here
elif name[i] == '[':
break # attribute selector — stop here
else:
result.append(name[i])
i += 1

View File

@@ -42,6 +42,22 @@ class EvalError(Exception):
pass
class _Thunk:
"""Deferred evaluation — returned from tail positions for TCO."""
__slots__ = ("expr", "env")
def __init__(self, expr: Any, env: dict[str, Any]):
self.expr = expr
self.env = env
def _trampoline(val: Any) -> Any:
"""Unwrap thunks by re-entering the evaluator until we get an actual value."""
while isinstance(val, _Thunk):
val = _eval(val.expr, val.env)
return val
# ---------------------------------------------------------------------------
# Public API
# ---------------------------------------------------------------------------
@@ -50,7 +66,10 @@ def evaluate(expr: Any, env: dict[str, Any] | None = None) -> Any:
"""Evaluate *expr* in *env* and return the result."""
if env is None:
env = {}
return _eval(expr, env)
result = _eval(expr, env)
while isinstance(result, _Thunk):
result = _eval(result.expr, result.env)
return result
def make_env(**kwargs: Any) -> dict[str, Any]:
@@ -90,7 +109,7 @@ def _eval(expr: Any, env: dict[str, Any]) -> Any:
# --- dict literal -----------------------------------------------------
if isinstance(expr, dict):
return {k: _eval(v, env) for k, v in expr.items()}
return {k: _trampoline(_eval(v, env)) for k, v in expr.items()}
# --- list = call or special form --------------------------------------
if not isinstance(expr, list):
@@ -103,7 +122,7 @@ def _eval(expr: Any, env: dict[str, Any]) -> Any:
# If head is not a symbol/lambda/list, treat entire list as data
if not isinstance(head, (Symbol, Lambda, list)):
return [_eval(x, env) for x in expr]
return [_trampoline(_eval(x, env)) for x in expr]
# --- special forms ----------------------------------------------------
if isinstance(head, Symbol):
@@ -122,11 +141,11 @@ def _eval(expr: Any, env: dict[str, Any]) -> Any:
val = env[name]
if isinstance(val, Macro):
expanded = _expand_macro(val, expr[1:], env)
return _eval(expanded, env)
return _Thunk(expanded, env)
# --- function / lambda call -------------------------------------------
fn = _eval(head, env)
args = [_eval(a, env) for a in expr[1:]]
fn = _trampoline(_eval(head, env))
args = [_trampoline(_eval(a, env)) for a in expr[1:]]
if callable(fn) and not isinstance(fn, (Lambda, Component)):
return fn(*args)
@@ -151,7 +170,7 @@ def _call_lambda(fn: Lambda, args: list[Any], caller_env: dict[str, Any]) -> Any
local.update(caller_env)
for p, v in zip(fn.params, args):
local[p] = v
return _eval(fn.body, local)
return _Thunk(fn.body, local)
def _call_component(comp: Component, raw_args: list[Any], env: dict[str, Any]) -> Any:
@@ -166,10 +185,10 @@ def _call_component(comp: Component, raw_args: list[Any], env: dict[str, Any]) -
while i < len(raw_args):
arg = raw_args[i]
if isinstance(arg, Keyword) and i + 1 < len(raw_args):
kwargs[arg.name] = _eval(raw_args[i + 1], env)
kwargs[arg.name] = _trampoline(_eval(raw_args[i + 1], env))
i += 2
else:
children.append(_eval(arg, env))
children.append(_trampoline(_eval(arg, env)))
i += 1
local = dict(comp.closure)
@@ -181,7 +200,7 @@ def _call_component(comp: Component, raw_args: list[Any], env: dict[str, Any]) -
local[p] = NIL
if comp.has_children:
local["children"] = children
return _eval(comp.body, local)
return _Thunk(comp.body, local)
# ---------------------------------------------------------------------------
@@ -191,23 +210,22 @@ def _call_component(comp: Component, raw_args: list[Any], env: dict[str, Any]) -
def _sf_if(expr: list, env: dict) -> Any:
if len(expr) < 3:
raise EvalError("if requires condition and then-branch")
cond = _eval(expr[1], env)
cond = _trampoline(_eval(expr[1], env))
if cond and cond is not NIL:
return _eval(expr[2], env)
return _Thunk(expr[2], env)
if len(expr) > 3:
return _eval(expr[3], env)
return _Thunk(expr[3], env)
return NIL
def _sf_when(expr: list, env: dict) -> Any:
if len(expr) < 3:
raise EvalError("when requires condition and body")
cond = _eval(expr[1], env)
cond = _trampoline(_eval(expr[1], env))
if cond and cond is not NIL:
result = NIL
for body_expr in expr[2:]:
result = _eval(body_expr, env)
return result
for body_expr in expr[2:-1]:
_trampoline(_eval(body_expr, env))
return _Thunk(expr[-1], env)
return NIL
@@ -228,22 +246,22 @@ def _sf_cond(expr: list, env: dict) -> Any:
raise EvalError("cond clause must be (test result)")
test = clause[0]
if isinstance(test, Symbol) and test.name in ("else", ":else"):
return _eval(clause[1], env)
return _Thunk(clause[1], env)
if isinstance(test, Keyword) and test.name == "else":
return _eval(clause[1], env)
if _eval(test, env):
return _eval(clause[1], env)
return _Thunk(clause[1], env)
if _trampoline(_eval(test, env)):
return _Thunk(clause[1], env)
else:
i = 0
while i < len(clauses) - 1:
test = clauses[i]
result = clauses[i + 1]
if isinstance(test, Keyword) and test.name == "else":
return _eval(result, env)
return _Thunk(result, env)
if isinstance(test, Symbol) and test.name in (":else", "else"):
return _eval(result, env)
if _eval(test, env):
return _eval(result, env)
return _Thunk(result, env)
if _trampoline(_eval(test, env)):
return _Thunk(result, env)
i += 2
return NIL
@@ -251,18 +269,18 @@ def _sf_cond(expr: list, env: dict) -> Any:
def _sf_case(expr: list, env: dict) -> Any:
if len(expr) < 2:
raise EvalError("case requires expression to match")
match_val = _eval(expr[1], env)
match_val = _trampoline(_eval(expr[1], env))
clauses = expr[2:]
i = 0
while i < len(clauses) - 1:
test = clauses[i]
result = clauses[i + 1]
if isinstance(test, Keyword) and test.name == "else":
return _eval(result, env)
return _Thunk(result, env)
if isinstance(test, Symbol) and test.name in (":else", "else"):
return _eval(result, env)
if match_val == _eval(test, env):
return _eval(result, env)
return _Thunk(result, env)
if match_val == _trampoline(_eval(test, env)):
return _Thunk(result, env)
i += 2
return NIL
@@ -270,7 +288,7 @@ def _sf_case(expr: list, env: dict) -> Any:
def _sf_and(expr: list, env: dict) -> Any:
result: Any = True
for arg in expr[1:]:
result = _eval(arg, env)
result = _trampoline(_eval(arg, env))
if not result:
return result
return result
@@ -279,7 +297,7 @@ def _sf_and(expr: list, env: dict) -> Any:
def _sf_or(expr: list, env: dict) -> Any:
result: Any = False
for arg in expr[1:]:
result = _eval(arg, env)
result = _trampoline(_eval(arg, env))
if result:
return result
return result
@@ -299,23 +317,23 @@ def _sf_let(expr: list, env: dict) -> Any:
raise EvalError("let binding must be (name value)")
var = binding[0]
vname = var.name if isinstance(var, Symbol) else var
local[vname] = _eval(binding[1], local)
local[vname] = _trampoline(_eval(binding[1], local))
elif len(bindings) % 2 == 0:
# Clojure-style: (name val name val ...)
for i in range(0, len(bindings), 2):
var = bindings[i]
vname = var.name if isinstance(var, Symbol) else var
local[vname] = _eval(bindings[i + 1], local)
local[vname] = _trampoline(_eval(bindings[i + 1], local))
else:
raise EvalError("let bindings must be (name val ...) pairs")
else:
raise EvalError("let bindings must be a list")
# Evaluate body expressions, return last
result: Any = NIL
for body_expr in expr[2:]:
result = _eval(body_expr, local)
return result
# Evaluate body expressions — all but last non-tail, last is tail
body = expr[2:]
for body_expr in body[:-1]:
_trampoline(_eval(body_expr, local))
return _Thunk(body[-1], local)
def _sf_lambda(expr: list, env: dict) -> Lambda:
@@ -341,13 +359,85 @@ def _sf_define(expr: list, env: dict) -> Any:
name_sym = expr[1]
if not isinstance(name_sym, Symbol):
raise EvalError(f"define name must be symbol, got {type(name_sym).__name__}")
value = _eval(expr[2], env)
value = _trampoline(_eval(expr[2], env))
if isinstance(value, Lambda) and value.name is None:
value.name = name_sym.name
env[name_sym.name] = value
return value
def _sf_defstyle(expr: list, env: dict) -> Any:
"""``(defstyle card-base (css :rounded-xl :bg-white :shadow))``
Evaluates body → StyleValue, binds to name in env.
"""
if len(expr) < 3:
raise EvalError("defstyle requires name and body")
name_sym = expr[1]
if not isinstance(name_sym, Symbol):
raise EvalError(f"defstyle name must be symbol, got {type(name_sym).__name__}")
value = _trampoline(_eval(expr[2], env))
env[name_sym.name] = value
return value
def _sf_defkeyframes(expr: list, env: dict) -> Any:
"""``(defkeyframes fade-in (from (css :opacity-0)) (to (css :opacity-100)))``
Builds @keyframes rule from steps, registers it, and binds the animation.
"""
from .types import StyleValue
from .css_registry import register_generated_rule
from .style_dict import KEYFRAMES
if len(expr) < 3:
raise EvalError("defkeyframes requires name and at least one step")
name_sym = expr[1]
if not isinstance(name_sym, Symbol):
raise EvalError(f"defkeyframes name must be symbol, got {type(name_sym).__name__}")
kf_name = name_sym.name
# Build @keyframes rule from steps
steps: list[str] = []
for step_expr in expr[2:]:
if not isinstance(step_expr, list) or len(step_expr) < 2:
raise EvalError("defkeyframes step must be (selector (css ...))")
selector = step_expr[0]
if isinstance(selector, Symbol):
selector = selector.name
else:
selector = str(selector)
body = _trampoline(_eval(step_expr[1], env))
if isinstance(body, StyleValue):
decls = body.declarations
elif isinstance(body, str):
decls = body
else:
raise EvalError(f"defkeyframes step body must be css/string, got {type(body).__name__}")
steps.append(f"{selector}{{{decls}}}")
kf_rule = f"@keyframes {kf_name}{{{' '.join(steps)}}}"
# Register in KEYFRAMES so animate-{name} works
KEYFRAMES[kf_name] = kf_rule
# Clear resolver cache so new keyframes are picked up
from .style_resolver import _resolve_cached
_resolve_cached.cache_clear()
# Create a StyleValue for the animation property
import hashlib
h = hashlib.sha256(kf_rule.encode()).hexdigest()[:6]
sv = StyleValue(
class_name=f"sx-{h}",
declarations=f"animation-name:{kf_name}",
keyframes=((kf_name, kf_rule),),
)
register_generated_rule(sv)
env[kf_name] = sv
return sv
def _sf_defcomp(expr: list, env: dict) -> Component:
"""``(defcomp ~name (&key param1 param2 &rest children) body)``"""
if len(expr) < 4:
@@ -393,10 +483,11 @@ def _sf_defcomp(expr: list, env: dict) -> Component:
def _sf_begin(expr: list, env: dict) -> Any:
result: Any = NIL
for sub in expr[1:]:
result = _eval(sub, env)
return result
if len(expr) < 2:
return NIL
for sub in expr[1:-1]:
_trampoline(_eval(sub, env))
return _Thunk(expr[-1], env)
def _sf_quote(expr: list, _env: dict) -> Any:
@@ -407,18 +498,18 @@ def _sf_thread_first(expr: list, env: dict) -> Any:
"""``(-> val (f a) (g b))`` → ``(g (f val a) b)``"""
if len(expr) < 2:
raise EvalError("-> requires at least a value")
result = _eval(expr[1], env)
result = _trampoline(_eval(expr[1], env))
for form in expr[2:]:
if isinstance(form, list):
fn = _eval(form[0], env)
args = [result] + [_eval(a, env) for a in form[1:]]
fn = _trampoline(_eval(form[0], env))
args = [result] + [_trampoline(_eval(a, env)) for a in form[1:]]
else:
fn = _eval(form, env)
fn = _trampoline(_eval(form, env))
args = [result]
if callable(fn) and not isinstance(fn, (Lambda, Component)):
result = fn(*args)
elif isinstance(fn, Lambda):
result = _call_lambda(fn, args, env)
result = _trampoline(_call_lambda(fn, args, env))
else:
raise EvalError(f"-> form not callable: {fn!r}")
return result
@@ -482,14 +573,14 @@ def _qq_expand(template: Any, env: dict) -> Any:
if head.name == "unquote":
if len(template) < 2:
raise EvalError("unquote requires an expression")
return _eval(template[1], env)
return _trampoline(_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)
spliced = _trampoline(_eval(item[1], env))
if isinstance(spliced, list):
result.extend(spliced)
elif spliced is not None and spliced is not NIL:
@@ -516,7 +607,7 @@ def _expand_macro(macro: Macro, raw_args: list[Any], env: dict) -> Any:
rest_start = len(macro.params)
local[macro.rest_param] = list(raw_args[rest_start:])
return _eval(macro.body, local)
return _trampoline(_eval(macro.body, local))
def _sf_defhandler(expr: list, env: dict) -> HandlerDef:
@@ -553,6 +644,75 @@ def _sf_defhandler(expr: list, env: dict) -> HandlerDef:
return handler
def _parse_key_params(params_expr: list) -> list[str]:
"""Parse ``(&key param1 param2 ...)`` into a list of param name strings."""
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)
return params
def _sf_defquery(expr: list, env: dict):
"""``(defquery name (&key param...) "docstring" body)``"""
from .types import QueryDef
if len(expr) < 4:
raise EvalError("defquery requires name, params, and body")
name_sym = expr[1]
if not isinstance(name_sym, Symbol):
raise EvalError(f"defquery name must be symbol, got {type(name_sym).__name__}")
params_expr = expr[2]
if not isinstance(params_expr, list):
raise EvalError("defquery params must be a list")
params = _parse_key_params(params_expr)
# Optional docstring before body
if len(expr) >= 5 and isinstance(expr[3], str):
doc = expr[3]
body = expr[4]
else:
doc = ""
body = expr[3]
qdef = QueryDef(
name=name_sym.name, params=params, doc=doc,
body=body, closure=dict(env),
)
env[f"query:{name_sym.name}"] = qdef
return qdef
def _sf_defaction(expr: list, env: dict):
"""``(defaction name (&key param...) "docstring" body)``"""
from .types import ActionDef
if len(expr) < 4:
raise EvalError("defaction requires name, params, and body")
name_sym = expr[1]
if not isinstance(name_sym, Symbol):
raise EvalError(f"defaction name must be symbol, got {type(name_sym).__name__}")
params_expr = expr[2]
if not isinstance(params_expr, list):
raise EvalError("defaction params must be a list")
params = _parse_key_params(params_expr)
if len(expr) >= 5 and isinstance(expr[3], str):
doc = expr[3]
body = expr[4]
else:
doc = ""
body = expr[3]
adef = ActionDef(
name=name_sym.name, params=params, doc=doc,
body=body, closure=dict(env),
)
env[f"action:{name_sym.name}"] = adef
return adef
def _sf_set_bang(expr: list, env: dict) -> Any:
"""``(set! name value)`` — mutate existing binding."""
if len(expr) != 3:
@@ -560,7 +720,7 @@ def _sf_set_bang(expr: list, env: dict) -> Any:
name_sym = expr[1]
if not isinstance(name_sym, Symbol):
raise EvalError(f"set! name must be symbol, got {type(name_sym).__name__}")
value = _eval(expr[2], env)
value = _trampoline(_eval(expr[2], env))
# Walk up scope if using Env objects; for plain dicts just overwrite
env[name_sym.name] = value
return value
@@ -591,7 +751,7 @@ def _sf_defrelation(expr: list, env: dict) -> RelationDef:
if isinstance(val, Keyword):
kwargs[key.name] = val.name
else:
kwargs[key.name] = _eval(val, env) if not isinstance(val, str) else val
kwargs[key.name] = _trampoline(_eval(val, env)) if not isinstance(val, str) else val
i += 2
else:
kwargs[key.name] = None
@@ -677,7 +837,7 @@ def _sf_defpage(expr: list, env: dict) -> PageDef:
elif isinstance(item, str):
auth.append(item)
else:
auth.append(_eval(item, env))
auth.append(_trampoline(_eval(item, env)))
else:
auth = str(auth_val) if auth_val else "public"
@@ -693,7 +853,7 @@ def _sf_defpage(expr: list, env: dict) -> PageDef:
cache_val = slots.get("cache")
cache = None
if cache_val is not None:
cache_result = _eval(cache_val, env)
cache_result = _trampoline(_eval(cache_val, env))
if isinstance(cache_result, dict):
cache = cache_result
@@ -726,6 +886,8 @@ _SPECIAL_FORMS: dict[str, Any] = {
"lambda": _sf_lambda,
"fn": _sf_lambda,
"define": _sf_define,
"defstyle": _sf_defstyle,
"defkeyframes": _sf_defkeyframes,
"defcomp": _sf_defcomp,
"defrelation": _sf_defrelation,
"begin": _sf_begin,
@@ -737,6 +899,8 @@ _SPECIAL_FORMS: dict[str, Any] = {
"quasiquote": _sf_quasiquote,
"defhandler": _sf_defhandler,
"defpage": _sf_defpage,
"defquery": _sf_defquery,
"defaction": _sf_defaction,
}
@@ -747,57 +911,57 @@ _SPECIAL_FORMS: dict[str, Any] = {
def _ho_map(expr: list, env: dict) -> list:
if len(expr) != 3:
raise EvalError("map requires fn and collection")
fn = _eval(expr[1], env)
coll = _eval(expr[2], env)
fn = _trampoline(_eval(expr[1], env))
coll = _trampoline(_eval(expr[2], env))
if not isinstance(fn, Lambda):
raise EvalError(f"map requires lambda, got {type(fn).__name__}")
return [_call_lambda(fn, [item], env) for item in coll]
return [_trampoline(_call_lambda(fn, [item], env)) for item in coll]
def _ho_map_indexed(expr: list, env: dict) -> list:
if len(expr) != 3:
raise EvalError("map-indexed requires fn and collection")
fn = _eval(expr[1], env)
coll = _eval(expr[2], env)
fn = _trampoline(_eval(expr[1], env))
coll = _trampoline(_eval(expr[2], env))
if not isinstance(fn, Lambda):
raise EvalError(f"map-indexed requires lambda, got {type(fn).__name__}")
if len(fn.params) < 2:
raise EvalError("map-indexed lambda needs (i item) params")
return [_call_lambda(fn, [i, item], env) for i, item in enumerate(coll)]
return [_trampoline(_call_lambda(fn, [i, item], env)) for i, item in enumerate(coll)]
def _ho_filter(expr: list, env: dict) -> list:
if len(expr) != 3:
raise EvalError("filter requires fn and collection")
fn = _eval(expr[1], env)
coll = _eval(expr[2], env)
fn = _trampoline(_eval(expr[1], env))
coll = _trampoline(_eval(expr[2], env))
if not isinstance(fn, Lambda):
raise EvalError(f"filter requires lambda, got {type(fn).__name__}")
return [item for item in coll if _call_lambda(fn, [item], env)]
return [item for item in coll if _trampoline(_call_lambda(fn, [item], env))]
def _ho_reduce(expr: list, env: dict) -> Any:
if len(expr) != 4:
raise EvalError("reduce requires fn, init, and collection")
fn = _eval(expr[1], env)
acc = _eval(expr[2], env)
coll = _eval(expr[3], env)
fn = _trampoline(_eval(expr[1], env))
acc = _trampoline(_eval(expr[2], env))
coll = _trampoline(_eval(expr[3], env))
if not isinstance(fn, Lambda):
raise EvalError(f"reduce requires lambda, got {type(fn).__name__}")
for item in coll:
acc = _call_lambda(fn, [acc, item], env)
acc = _trampoline(_call_lambda(fn, [acc, item], env))
return acc
def _ho_some(expr: list, env: dict) -> Any:
if len(expr) != 3:
raise EvalError("some requires fn and collection")
fn = _eval(expr[1], env)
coll = _eval(expr[2], env)
fn = _trampoline(_eval(expr[1], env))
coll = _trampoline(_eval(expr[2], env))
if not isinstance(fn, Lambda):
raise EvalError(f"some requires lambda, got {type(fn).__name__}")
for item in coll:
result = _call_lambda(fn, [item], env)
result = _trampoline(_call_lambda(fn, [item], env))
if result:
return result
return NIL
@@ -806,12 +970,12 @@ def _ho_some(expr: list, env: dict) -> Any:
def _ho_every(expr: list, env: dict) -> bool:
if len(expr) != 3:
raise EvalError("every? requires fn and collection")
fn = _eval(expr[1], env)
coll = _eval(expr[2], env)
fn = _trampoline(_eval(expr[1], env))
coll = _trampoline(_eval(expr[2], env))
if not isinstance(fn, Lambda):
raise EvalError(f"every? requires lambda, got {type(fn).__name__}")
for item in coll:
if not _call_lambda(fn, [item], env):
if not _trampoline(_call_lambda(fn, [item], env)):
return False
return True
@@ -819,12 +983,12 @@ def _ho_every(expr: list, env: dict) -> bool:
def _ho_for_each(expr: list, env: dict) -> Any:
if len(expr) != 3:
raise EvalError("for-each requires fn and collection")
fn = _eval(expr[1], env)
coll = _eval(expr[2], env)
fn = _trampoline(_eval(expr[1], env))
coll = _trampoline(_eval(expr[2], env))
if not isinstance(fn, Lambda):
raise EvalError(f"for-each requires lambda, got {type(fn).__name__}")
for item in coll:
_call_lambda(fn, [item], env)
_trampoline(_call_lambda(fn, [item], env))
return NIL

View File

@@ -69,7 +69,8 @@ def clear_handlers(service: str | None = None) -> None:
def load_handler_file(filepath: str, service_name: str) -> list[HandlerDef]:
"""Parse an .sx file, evaluate it, and register any HandlerDef values."""
from .parser import parse_all
from .evaluator import _eval
from .evaluator import _eval as _raw_eval, _trampoline
_eval = lambda expr, env: _trampoline(_raw_eval(expr, env))
from .jinja_bridge import get_component_env
with open(filepath, encoding="utf-8") as f:
@@ -110,16 +111,19 @@ async def execute_handler(
service_name: str,
args: dict[str, str] | None = None,
) -> str:
"""Execute a declarative handler and return rendered sx/HTML string.
"""Execute a declarative handler and return SX wire format (``SxExpr``).
Uses the async evaluator+renderer so I/O primitives (``query``,
``service``, ``request-arg``, etc.) are awaited inline within
control flow — no collect-then-substitute limitations.
Uses the async evaluator so I/O primitives (``query``, ``service``,
``request-arg``, etc.) are awaited inline within control flow.
Returns ``SxExpr`` — pre-built sx source. Callers like
``fetch_fragment`` check ``content-type: text/sx`` and wrap the
response in ``SxExpr`` when consuming cross-service fragments.
1. Build env from component env + handler closure
2. Bind handler params from args (typically request.args)
3. Evaluate + render via async_render (handles I/O inline)
4. Return rendered string
3. Evaluate via ``async_eval_to_sx`` (I/O inline, components serialized)
4. Return ``SxExpr`` wire format
"""
from .jinja_bridge import get_component_env, _get_request_context
from .async_eval import async_eval_to_sx
@@ -204,3 +208,42 @@ def create_handler_blueprint(service_name: str) -> Any:
bp._python_handlers = _python_handlers # type: ignore[attr-defined]
return bp
# ---------------------------------------------------------------------------
# Direct app mount — replaces per-service fragment blueprint boilerplate
# ---------------------------------------------------------------------------
def auto_mount_fragment_handlers(app: Any, service_name: str) -> Callable:
"""Mount ``/internal/fragments/<type>`` directly on the app.
Returns an ``add_handler(name, fn, content_type)`` function for
registering Python handler overrides (checked before SX handlers).
"""
from quart import Response, request
from shared.infrastructure.fragments import FRAGMENT_HEADER
python_handlers: dict[str, Callable[[], Awaitable[str]]] = {}
html_types: set[str] = set()
@app.get("/internal/fragments/<fragment_type>")
async def _fragment_dispatch(fragment_type: str):
if not request.headers.get(FRAGMENT_HEADER):
return Response("", status=403)
py = python_handlers.get(fragment_type)
if py is not None:
result = await py()
ct = "text/html" if fragment_type in html_types else "text/sx"
return Response(result, status=200, content_type=ct)
hdef = get_handler(service_name, fragment_type)
if hdef is not None:
result = await execute_handler(hdef, service_name, args=dict(request.args))
return Response(result, status=200, content_type="text/sx")
return Response("", status=200, content_type="text/sx")
def add_handler(name: str, fn: Callable[[], Awaitable[str]], content_type: str = "text/sx") -> None:
python_handlers[name] = fn
if content_type == "text/html":
html_types.add(name)
return add_handler

View File

@@ -1,7 +1,7 @@
"""
Shared helper functions for s-expression page rendering.
These are used by per-service sx_components.py files to build common
These are used by per-service sxc/pages modules to build common
page elements (headers, search, etc.) from template context.
"""
from __future__ import annotations
@@ -16,34 +16,6 @@ from .page import SEARCH_HEADERS_MOBILE, SEARCH_HEADERS_DESKTOP
from .parser import SxExpr
# ---------------------------------------------------------------------------
# Pre-computed CSS classes for inline sx built by Python helpers
# ---------------------------------------------------------------------------
# These :class strings appear in post_header_sx / post_admin_header_sx etc.
# They're static — scan once at import time so they aren't re-scanned per request.
_HELPER_CLASS_SOURCES = [
':class "flex flex-col sm:flex-row sm:items-center gap-2 border-r border-stone-200 mr-2 sm:max-w-2xl"',
':class "relative nav-group"',
':class "justify-center cursor-pointer flex flex-row items-center gap-2 rounded bg-stone-200 text-black p-3"',
':class "!bg-stone-500 !text-white"',
':class "fa fa-cog"',
':class "fa fa-shield-halved"',
':class "text-white"',
':class "justify-center cursor-pointer flex flex-row items-center gap-2 rounded !bg-stone-500 !text-white p-3"',
]
def _scan_helper_classes() -> frozenset[str]:
"""Scan the static class strings from helper functions once."""
from .css_registry import scan_classes_from_sx
combined = " ".join(_HELPER_CLASS_SOURCES)
return frozenset(scan_classes_from_sx(combined))
HELPER_CSS_CLASSES: frozenset[str] = _scan_helper_classes()
def call_url(ctx: dict, key: str, path: str = "/") -> str:
"""Call a URL helper from context (e.g., blog_url, account_url)."""
fn = ctx.get(key)
@@ -77,18 +49,18 @@ def _as_sx(val: Any) -> SxExpr | None:
if not val:
return None
if isinstance(val, SxExpr):
return val
return val if val.source else None
html = str(val)
escaped = html.replace("\\", "\\\\").replace('"', '\\"')
return SxExpr(f'(~rich-text :html "{escaped}")')
def root_header_sx(ctx: dict, *, oob: bool = False) -> str:
"""Build the root header row as a sx call string."""
async def root_header_sx(ctx: dict, *, oob: bool = False) -> str:
"""Build the root header row as sx wire format."""
rights = ctx.get("rights") or {}
is_admin = rights.get("admin") if isinstance(rights, dict) else getattr(rights, "admin", False)
settings_url = call_url(ctx, "blog_url", "/settings/") if is_admin else ""
return sx_call("header-row-sx",
return await _render_to_sx("header-row-sx",
cart_mini=_as_sx(ctx.get("cart_mini")),
blog_url=call_url(ctx, "blog_url", ""),
site_title=ctx.get("base_title", ""),
@@ -102,19 +74,19 @@ def root_header_sx(ctx: dict, *, oob: bool = False) -> str:
)
def mobile_menu_sx(*sections: str) -> str:
def mobile_menu_sx(*sections: str) -> SxExpr:
"""Assemble mobile menu from pre-built sections (deepest first)."""
parts = [s for s in sections if s]
return "(<> " + " ".join(parts) + ")" if parts else ""
return SxExpr("(<> " + " ".join(parts) + ")") if parts else SxExpr("")
def mobile_root_nav_sx(ctx: dict) -> str:
async def mobile_root_nav_sx(ctx: dict) -> str:
"""Root-level mobile nav via ~mobile-root-nav component."""
nav_tree = ctx.get("nav_tree") or ""
auth_menu = ctx.get("auth_menu") or ""
if not nav_tree and not auth_menu:
return ""
return sx_call("mobile-root-nav",
return await _render_to_sx("mobile-root-nav",
nav_tree=_as_sx(nav_tree),
auth_menu=_as_sx(auth_menu),
)
@@ -124,29 +96,25 @@ def mobile_root_nav_sx(ctx: dict) -> str:
# Shared nav-item builders — used by BOTH desktop headers and mobile menus
# ---------------------------------------------------------------------------
def _post_nav_items_sx(ctx: dict) -> str:
async def _post_nav_items_sx(ctx: dict) -> SxExpr:
"""Build post-level nav items (container_nav + admin cog). Shared by
``post_header_sx`` (desktop) and ``post_mobile_nav_sx`` (mobile)."""
post = ctx.get("post") or {}
slug = post.get("slug", "")
if not slug:
return ""
return SxExpr("")
parts: list[str] = []
page_cart_count = ctx.get("page_cart_count", 0)
if page_cart_count and page_cart_count > 0:
cart_href = call_url(ctx, "cart_url", f"/{slug}/")
parts.append(sx_call("page-cart-badge", href=cart_href,
parts.append(await _render_to_sx("page-cart-badge", href=cart_href,
count=str(page_cart_count)))
container_nav = str(ctx.get("container_nav") or "").strip()
# Skip empty fragment wrappers like "(<> )"
if container_nav and container_nav.replace("(<>", "").replace(")", "").strip():
parts.append(
f'(div :id "entries-calendars-nav-wrapper"'
f' :class "flex flex-col sm:flex-row sm:items-center gap-2'
f' border-r border-stone-200 mr-2 sm:max-w-2xl"'
f' {container_nav})'
)
parts.append(await _render_to_sx("container-nav-wrapper",
content=SxExpr(container_nav)))
# Admin cog
admin_nav = ctx.get("post_admin_nav")
@@ -157,22 +125,16 @@ def _post_nav_items_sx(ctx: dict) -> str:
from quart import request
admin_href = call_url(ctx, "blog_url", f"/{slug}/admin/")
is_admin_page = ctx.get("is_admin_section") or "/admin" in request.path
sel_cls = "!bg-stone-500 !text-white" if is_admin_page else ""
base_cls = ("justify-center cursor-pointer flex flex-row"
" items-center gap-2 rounded bg-stone-200 text-black p-3")
admin_nav = (
f'(div :class "relative nav-group"'
f' (a :href "{admin_href}"'
f' :class "{base_cls} {sel_cls}"'
f' (i :class "fa fa-cog" :aria-hidden "true")))'
)
admin_nav = await _render_to_sx("admin-cog-button",
href=admin_href,
is_admin_page=is_admin_page or None)
if admin_nav:
parts.append(admin_nav)
return "(<> " + " ".join(parts) + ")" if parts else ""
return SxExpr("(<> " + " ".join(parts) + ")") if parts else SxExpr("")
def _post_admin_nav_items_sx(ctx: dict, slug: str,
selected: str = "") -> str:
async def _post_admin_nav_items_sx(ctx: dict, slug: str,
selected: str = "") -> SxExpr:
"""Build post-admin nav items (calendars, markets, etc.). Shared by
``post_admin_header_sx`` (desktop) and mobile menu."""
select_colours = ctx.get("select_colours", "")
@@ -193,48 +155,36 @@ def _post_admin_nav_items_sx(ctx: dict, slug: str,
continue
href = url_fn(path)
is_sel = label == selected
parts.append(sx_call("nav-link", href=href, label=label,
parts.append(await _render_to_sx("nav-link", href=href, label=label,
select_colours=select_colours,
is_selected=is_sel or None))
return "(<> " + " ".join(parts) + ")" if parts else ""
return SxExpr("(<> " + " ".join(parts) + ")") if parts else SxExpr("")
# ---------------------------------------------------------------------------
# Mobile menu section builders — wrap shared nav items for hamburger panel
# ---------------------------------------------------------------------------
def post_mobile_nav_sx(ctx: dict) -> str:
async def post_mobile_nav_sx(ctx: dict) -> str:
"""Post-level mobile menu section."""
nav = _post_nav_items_sx(ctx)
nav = await _post_nav_items_sx(ctx)
if not nav:
return ""
post = ctx.get("post") or {}
slug = post.get("slug", "")
title = (post.get("title") or slug)[:40]
return sx_call("mobile-menu-section",
return await _render_to_sx("mobile-menu-section",
label=title,
href=call_url(ctx, "blog_url", f"/{slug}/"),
level=1,
items=SxExpr(nav),
items=nav,
)
def post_admin_mobile_nav_sx(ctx: dict, slug: str,
selected: str = "") -> str:
"""Post-admin mobile menu section."""
nav = _post_admin_nav_items_sx(ctx, slug, selected)
if not nav:
return ""
admin_href = call_url(ctx, "blog_url", f"/{slug}/admin/")
return sx_call("mobile-menu-section",
label="admin", href=admin_href, level=2,
items=SxExpr(nav),
)
def search_mobile_sx(ctx: dict) -> str:
"""Build mobile search input as sx call string."""
return sx_call("search-mobile",
async def search_mobile_sx(ctx: dict) -> str:
"""Build mobile search input as sx wire format."""
return await _render_to_sx("search-mobile",
current_local_href=ctx.get("current_local_href", "/"),
search=ctx.get("search", ""),
search_count=ctx.get("search_count", ""),
@@ -243,9 +193,9 @@ def search_mobile_sx(ctx: dict) -> str:
)
def search_desktop_sx(ctx: dict) -> str:
"""Build desktop search input as sx call string."""
return sx_call("search-desktop",
async def search_desktop_sx(ctx: dict) -> str:
"""Build desktop search input as sx wire format."""
return await _render_to_sx("search-desktop",
current_local_href=ctx.get("current_local_href", "/"),
search=ctx.get("search", ""),
search_count=ctx.get("search_count", ""),
@@ -254,8 +204,8 @@ def search_desktop_sx(ctx: dict) -> str:
)
def post_header_sx(ctx: dict, *, oob: bool = False, child: str = "") -> str:
"""Build the post-level header row as sx call string."""
async def post_header_sx(ctx: dict, *, oob: bool = False, child: str = "") -> str:
"""Build the post-level header row as sx wire format."""
post = ctx.get("post") or {}
slug = post.get("slug", "")
if not slug:
@@ -263,68 +213,66 @@ def post_header_sx(ctx: dict, *, oob: bool = False, child: str = "") -> str:
title = (post.get("title") or "")[:160]
feature_image = post.get("feature_image")
label_sx = sx_call("post-label", feature_image=feature_image, title=title)
nav_sx = _post_nav_items_sx(ctx) or None
label_sx = await _render_to_sx("post-label", feature_image=feature_image, title=title)
nav_sx = await _post_nav_items_sx(ctx) or None
link_href = call_url(ctx, "blog_url", f"/{slug}/")
return sx_call("menu-row-sx",
return await _render_to_sx("menu-row-sx",
id="post-row", level=1,
link_href=link_href,
link_label_content=SxExpr(label_sx),
nav=SxExpr(nav_sx) if nav_sx else None,
link_label_content=label_sx,
nav=nav_sx,
child_id="post-header-child",
child=SxExpr(child) if child else None,
oob=oob, external=True,
)
def post_admin_header_sx(ctx: dict, slug: str, *, oob: bool = False,
async def post_admin_header_sx(ctx: dict, slug: str, *, oob: bool = False,
selected: str = "", admin_href: str = "") -> str:
"""Post admin header row as sx call string."""
"""Post admin header row as sx wire format."""
# Label
label_parts = ['(i :class "fa fa-shield-halved" :aria-hidden "true")', '" admin"']
if selected:
label_parts.append(f'(span :class "text-white" "{escape(selected)}")')
label_sx = "(<> " + " ".join(label_parts) + ")"
label_sx = await _render_to_sx("post-admin-label",
selected=str(escape(selected)) if selected else None)
nav_sx = _post_admin_nav_items_sx(ctx, slug, selected) or None
nav_sx = await _post_admin_nav_items_sx(ctx, slug, selected) or None
if not admin_href:
blog_fn = ctx.get("blog_url")
admin_href = blog_fn(f"/{slug}/admin/") if callable(blog_fn) else f"/{slug}/admin/"
return sx_call("menu-row-sx",
return await _render_to_sx("menu-row-sx",
id="post-admin-row", level=2,
link_href=admin_href,
link_label_content=SxExpr(label_sx),
nav=SxExpr(nav_sx) if nav_sx else None,
link_label_content=label_sx,
nav=nav_sx,
child_id="post-admin-header-child", oob=oob,
)
def oob_header_sx(parent_id: str, child_id: str, row_sx: str) -> str:
async def oob_header_sx(parent_id: str, child_id: str, row_sx: str) -> str:
"""Wrap a header row sx in an OOB swap.
child_id is accepted for call-site compatibility but no longer used —
the child placeholder is created by ~menu-row-sx itself.
"""
return sx_call("oob-header-sx",
return await _render_to_sx("oob-header-sx",
parent_id=parent_id,
row=SxExpr(row_sx),
)
def header_child_sx(inner_sx: str, *, id: str = "root-header-child") -> str:
async def header_child_sx(inner_sx: str, *, id: str = "root-header-child") -> str:
"""Wrap inner sx in a header-child div."""
return sx_call("header-child-sx",
return await _render_to_sx("header-child-sx",
id=id, inner=SxExpr(f"(<> {inner_sx})"),
)
def oob_page_sx(*, oobs: str = "", filter: str = "", aside: str = "",
async def oob_page_sx(*, oobs: str = "", filter: str = "", aside: str = "",
content: str = "", menu: str = "") -> str:
"""Build OOB response as sx call string."""
return sx_call("oob-sx",
"""Build OOB response as sx wire format."""
return await _render_to_sx("oob-sx",
oobs=SxExpr(f"(<> {oobs})") if oobs else None,
filter=SxExpr(filter) if filter else None,
aside=SxExpr(aside) if aside else None,
@@ -333,7 +281,7 @@ def oob_page_sx(*, oobs: str = "", filter: str = "", aside: str = "",
)
def full_page_sx(ctx: dict, *, header_rows: str,
async def full_page_sx(ctx: dict, *, header_rows: str,
filter: str = "", aside: str = "",
content: str = "", menu: str = "",
meta_html: str = "", meta: str = "") -> str:
@@ -344,8 +292,8 @@ def full_page_sx(ctx: dict, *, header_rows: str,
"""
# Auto-generate mobile nav from context when no menu provided
if not menu:
menu = mobile_root_nav_sx(ctx)
body_sx = sx_call("app-body",
menu = await mobile_root_nav_sx(ctx)
body_sx = await _render_to_sx("app-body",
header_rows=SxExpr(f"(<> {header_rows})") if header_rows else None,
filter=SxExpr(filter) if filter else None,
aside=SxExpr(aside) if aside else None,
@@ -359,6 +307,104 @@ def full_page_sx(ctx: dict, *, header_rows: str,
return sx_page(ctx, body_sx, meta_html=meta_html)
def _build_component_ast(__name: str, **kwargs: Any) -> list:
"""Build an AST list for a component call from Python kwargs.
Returns e.g. [Symbol("~card"), Keyword("title"), "hello", Keyword("count"), 3]
No SX string generation — values stay as native Python objects.
"""
from .types import Symbol, Keyword, NIL
comp_sym = Symbol(__name if __name.startswith("~") else f"~{__name}")
ast: list = [comp_sym]
for key, val in kwargs.items():
kebab = key.replace("_", "-")
ast.append(Keyword(kebab))
if val is None:
ast.append(NIL)
elif isinstance(val, SxExpr):
# SxExpr values need to be parsed into AST
from .parser import parse
if not val.source:
ast.append(NIL)
else:
ast.append(parse(val.source))
else:
ast.append(val)
return ast
async def _render_to_sx_with_env(__name: str, extra_env: dict, **kwargs: Any) -> str:
"""Like ``_render_to_sx`` but merges *extra_env* into the evaluation
environment before eval. Used by ``register_sx_layout`` so .sx
defcomps can read ctx values as free variables.
Uses ``async_eval_slot_to_sx`` (not ``async_eval_to_sx``) so the
top-level component body is expanded server-side — free variables
from *extra_env* are resolved during expansion rather than being
serialized as unresolved symbols for the client.
**Private** — service code should use ``sx_call()`` or defmacros instead.
"""
from .jinja_bridge import get_component_env, _get_request_context
from .async_eval import async_eval_slot_to_sx
from .types import Symbol, Keyword, NIL as _NIL
# Build AST with extra_env entries as keyword args so _aser_component
# binds them as params (otherwise it defaults all params to NIL).
comp_sym = Symbol(__name if __name.startswith("~") else f"~{__name}")
ast: list = [comp_sym]
for k, v in extra_env.items():
ast.append(Keyword(k))
ast.append(v if v is not None else _NIL)
for k, v in kwargs.items():
ast.append(Keyword(k.replace("_", "-")))
ast.append(v if v is not None else _NIL)
env = dict(get_component_env())
env.update(extra_env)
ctx = _get_request_context()
return SxExpr(await async_eval_slot_to_sx(ast, env, ctx))
async def _render_to_sx(__name: str, **kwargs: Any) -> str:
"""Call a defcomp and get SX wire format back. No SX string literals.
Builds an AST from Python values and evaluates it through the SX
evaluator, which resolves IO primitives and serializes component/tag
calls as SX wire format.
**Private** — service code should use ``sx_call()`` or defmacros instead.
Only infrastructure code (helpers.py, layouts.py) should call this.
"""
from .jinja_bridge import get_component_env, _get_request_context
from .async_eval import async_eval_to_sx
ast = _build_component_ast(__name, **kwargs)
env = dict(get_component_env())
ctx = _get_request_context()
return SxExpr(await async_eval_to_sx(ast, env, ctx))
# Backwards-compat alias — layout infrastructure still imports this.
# Will be removed once all layouts use register_sx_layout().
render_to_sx_with_env = _render_to_sx_with_env
async def render_to_html(__name: str, **kwargs: Any) -> str:
"""Call a defcomp and get HTML back. No SX string literals.
Same as render_to_sx() but produces HTML output instead of SX wire
format. Used by route renders that need HTML (full pages, fragments).
"""
from .jinja_bridge import get_component_env, _get_request_context
from .async_eval import async_render
ast = _build_component_ast(__name, **kwargs)
env = dict(get_component_env())
ctx = _get_request_context()
return await async_render(ast, env, ctx)
def sx_call(component_name: str, **kwargs: Any) -> str:
"""Build an s-expression component call string from Python kwargs.
@@ -369,14 +415,23 @@ def sx_call(component_name: str, **kwargs: Any) -> str:
Values are serialized: strings are quoted, None becomes nil,
bools become true/false, numbers stay as-is.
List values use ``(list ...)`` so the client gets an iterable array
rather than a rendered fragment.
"""
from .parser import serialize
from .parser import serialize, SxExpr
name = component_name if component_name.startswith("~") else f"~{component_name}"
parts = [name]
for key, val in kwargs.items():
parts.append(f":{key.replace('_', '-')}")
parts.append(serialize(val))
return "(" + " ".join(parts) + ")"
if isinstance(val, list):
items = [serialize(v) for v in val if v is not None]
if not items:
parts.append("nil")
else:
parts.append("(list " + " ".join(items) + ")")
else:
parts.append(serialize(val))
return SxExpr("(" + " ".join(parts) + ")")
@@ -428,27 +483,19 @@ def components_for_request() -> str:
return "\n".join(parts)
def sx_response(source_or_component: str, status: int = 200,
headers: dict | None = None, **kwargs: Any):
def sx_response(source: str, status: int = 200,
headers: dict | None = None):
"""Return an s-expression wire-format response.
Can be called with a raw sx string::
Takes a raw sx string::
return sx_response('(~test-row :nodeid "foo")')
Or with a component name + kwargs (builds the sx call)::
return sx_response("test-row", nodeid="foo", outcome="passed")
For SX requests, missing component definitions are prepended as a
``<script type="text/sx" data-components>`` block so the client
can process them before rendering OOB content.
"""
from quart import request, Response
if kwargs:
source = sx_call(source_or_component, **kwargs)
else:
source = source_or_component
body = source
# Validate the sx source parses as a single expression
@@ -473,8 +520,6 @@ def sx_response(source_or_component: str, status: int = 200,
cumulative_classes: set[str] = set()
if registry_loaded():
new_classes = scan_classes_from_sx(source)
# Include pre-computed helper classes (menu bars, admin nav, etc.)
new_classes.update(HELPER_CSS_CLASSES)
if comp_defs:
# Scan only the component definitions actually being sent
new_classes.update(scan_classes_from_sx(comp_defs))
@@ -558,6 +603,7 @@ details.group{{overflow:hidden}}details.group>summary{{list-style:none}}details.
</style>
</head>
<body class="bg-stone-50 text-stone-900">
<script type="text/sx-styles" data-hash="{styles_hash}">{styles_json}</script>
<script type="text/sx" data-components data-hash="{component_hash}">{component_defs}</script>
<script type="text/sx" data-mount="body">{page_sx}</script>
<script src="{asset_url}/scripts/sx.js?v={sx_js_hash}"></script>
@@ -605,8 +651,6 @@ def sx_page(ctx: dict, page_sx: str, *,
for val in _COMPONENT_ENV.values():
if isinstance(val, Component) and val.css_classes:
classes.update(val.css_classes)
# Include pre-computed helper classes (menu bars, admin nav, etc.)
classes.update(HELPER_CSS_CLASSES)
# Page sx is unique per request — scan it
classes.update(scan_classes_from_sx(page_sx))
# Always include body classes
@@ -628,6 +672,14 @@ def sx_page(ctx: dict, page_sx: str, *,
except Exception:
pass
# Style dictionary for client-side css primitive
styles_hash = _get_style_dict_hash()
client_styles_hash = _get_sx_styles_cookie()
if client_styles_hash and client_styles_hash == styles_hash:
styles_json = "" # Client has cached version
else:
styles_json = _build_style_dict_json()
return _SX_PAGE_TEMPLATE.format(
title=_html_escape(title),
asset_url=asset_url,
@@ -635,6 +687,8 @@ def sx_page(ctx: dict, page_sx: str, *,
csrf=_html_escape(csrf),
component_hash=component_hash,
component_defs=component_defs,
styles_hash=styles_hash,
styles_json=styles_json,
page_sx=page_sx,
sx_css=sx_css,
sx_css_classes=sx_css_classes,
@@ -644,6 +698,58 @@ def sx_page(ctx: dict, page_sx: str, *,
_SCRIPT_HASH_CACHE: dict[str, str] = {}
_STYLE_DICT_JSON: str = ""
_STYLE_DICT_HASH: str = ""
def _build_style_dict_json() -> str:
"""Build compact JSON style dictionary for client-side css primitive."""
global _STYLE_DICT_JSON, _STYLE_DICT_HASH
if _STYLE_DICT_JSON:
return _STYLE_DICT_JSON
import json
from .style_dict import (
STYLE_ATOMS, PSEUDO_VARIANTS, RESPONSIVE_BREAKPOINTS,
KEYFRAMES, ARBITRARY_PATTERNS, CHILD_SELECTOR_ATOMS,
)
# Derive child selector prefixes from CHILD_SELECTOR_ATOMS
prefixes = set()
for atom in CHILD_SELECTOR_ATOMS:
# "space-y-4" → "space-y-", "divide-y" → "divide-"
for sep in ("space-x-", "space-y-", "divide-x", "divide-y"):
if atom.startswith(sep):
prefixes.add(sep)
break
data = {
"a": STYLE_ATOMS,
"v": PSEUDO_VARIANTS,
"b": RESPONSIVE_BREAKPOINTS,
"k": KEYFRAMES,
"p": ARBITRARY_PATTERNS,
"c": sorted(prefixes),
}
_STYLE_DICT_JSON = json.dumps(data, separators=(",", ":"))
_STYLE_DICT_HASH = hashlib.md5(_STYLE_DICT_JSON.encode()).hexdigest()[:8]
return _STYLE_DICT_JSON
def _get_style_dict_hash() -> str:
"""Get the hash of the style dictionary JSON."""
if not _STYLE_DICT_HASH:
_build_style_dict_json()
return _STYLE_DICT_HASH
def _get_sx_styles_cookie() -> str:
"""Read the sx-styles-hash cookie from the current request."""
try:
from quart import request
return request.cookies.get("sx-styles-hash", "")
except Exception:
return ""
def _script_hash(filename: str) -> str:

View File

@@ -27,8 +27,16 @@ from __future__ import annotations
import contextvars
from typing import Any
from .types import Component, Keyword, Lambda, Macro, NIL, Symbol
from .evaluator import _eval, _call_component, _expand_macro
from .types import Component, Keyword, Lambda, Macro, NIL, StyleValue, Symbol
from .evaluator import _eval as _raw_eval, _call_component as _raw_call_component, _expand_macro, _trampoline
def _eval(expr, env):
"""Evaluate and unwrap thunks — all html.py _eval calls are non-tail."""
return _trampoline(_raw_eval(expr, env))
def _call_component(comp, raw_args, env):
"""Call component and unwrap thunks — non-tail in html.py."""
return _trampoline(_raw_call_component(comp, raw_args, env))
# ContextVar for collecting CSS class names during render.
# Set to a set[str] to collect; None to skip.
@@ -36,6 +44,12 @@ css_class_collector: contextvars.ContextVar[set[str] | None] = contextvars.Conte
"css_class_collector", default=None
)
# ContextVar for SVG/MathML namespace auto-detection.
# When True, unknown tag names inside (svg ...) or (math ...) are treated as elements.
_svg_context: contextvars.ContextVar[bool] = contextvars.ContextVar(
"_svg_context", default=False
)
class _RawHTML:
"""Marker for pre-rendered HTML that should not be escaped."""
@@ -86,6 +100,11 @@ HTML_TAGS = frozenset({
"g", "defs", "use", "text", "tspan", "clipPath", "mask",
"linearGradient", "radialGradient", "stop", "filter",
"feGaussianBlur", "feOffset", "feMerge", "feMergeNode",
"feTurbulence", "feColorMatrix", "feBlend",
"feComponentTransfer", "feFuncR", "feFuncG", "feFuncB", "feFuncA",
"feDisplacementMap", "feComposite", "feFlood", "feImage",
"feMorphology", "feSpecularLighting", "feDiffuseLighting",
"fePointLight", "feSpotLight", "feDistantLight",
"animate", "animateTransform",
# Table
"table", "thead", "tbody", "tfoot", "tr", "th", "td",
@@ -187,7 +206,7 @@ def _render(expr: Any, env: dict[str, Any]) -> str:
return ""
return _render_list(expr, env)
# --- dict → skip (data, not renderable) -------------------------------
# --- dict → skip (data, not renderable as HTML content) -----------------
if isinstance(expr, dict):
return ""
@@ -417,10 +436,22 @@ def _render_list(expr: list, env: dict[str, Any]) -> str:
if name == "<>":
return "".join(_render(child, env) for child in expr[1:])
# --- html: prefix → force tag rendering --------------------------
if name.startswith("html:"):
return _render_element(name[5:], expr[1:], env)
# --- Render-aware special forms --------------------------------------
# Check BEFORE HTML_TAGS because some names overlap (e.g. `map`).
if name in _RENDER_FORMS:
return _RENDER_FORMS[name](expr, env)
# But if the name is ALSO an HTML tag and (a) first arg is a Keyword
# or (b) we're inside SVG/MathML context, it's a tag call.
rsf = _RENDER_FORMS.get(name)
if rsf is not None:
if name in HTML_TAGS and (
(len(expr) > 1 and isinstance(expr[1], Keyword))
or _svg_context.get(False)
):
return _render_element(name, expr[1:], env)
return rsf(expr, env)
# --- Macro expansion → expand then render --------------------------
if name in env:
@@ -440,6 +471,14 @@ def _render_list(expr: list, env: dict[str, Any]) -> str:
return _render_component(val, expr[1:], env)
# Fall through to evaluation
# --- Custom element (hyphenated name with keyword attrs) → tag ----
if "-" in name and len(expr) > 1 and isinstance(expr[1], Keyword):
return _render_element(name, expr[1:], env)
# --- SVG/MathML context → unknown names are child elements --------
if _svg_context.get(False):
return _render_element(name, expr[1:], env)
# --- Other special forms / function calls → evaluate then render ---
result = _eval(expr, env)
return _render(result, env)
@@ -471,6 +510,19 @@ def _render_element(tag: str, args: list, env: dict[str, Any]) -> str:
children.append(arg)
i += 1
# Handle :style StyleValue — convert to class and register CSS rule
style_val = attrs.get("style")
if isinstance(style_val, StyleValue):
from .css_registry import register_generated_rule
register_generated_rule(style_val)
# Merge into :class
existing_class = attrs.get("class")
if existing_class and existing_class is not NIL and existing_class is not False:
attrs["class"] = f"{existing_class} {style_val.class_name}"
else:
attrs["class"] = style_val.class_name
del attrs["style"]
# Collect CSS classes if collector is active
class_val = attrs.get("class")
if class_val is not None and class_val is not NIL and class_val is not False:
@@ -488,6 +540,9 @@ def _render_element(tag: str, args: list, env: dict[str, Any]) -> str:
parts.append(f" {attr_name}")
elif attr_val is True:
parts.append(f" {attr_name}")
elif isinstance(attr_val, dict):
from .parser import serialize as _sx_serialize
parts.append(f' {attr_name}="{escape_attr(_sx_serialize(attr_val))}"')
else:
parts.append(f' {attr_name}="{escape_attr(str(attr_val))}"')
parts.append(">")
@@ -498,7 +553,15 @@ def _render_element(tag: str, args: list, env: dict[str, Any]) -> str:
if tag in VOID_ELEMENTS:
return opening
# Render children
child_html = "".join(_render(child, env) for child in children)
# SVG/MathML namespace auto-detection: set context for children
token = None
if tag in ("svg", "math"):
token = _svg_context.set(True)
try:
child_html = "".join(_render(child, env) for child in children)
finally:
if token is not None:
_svg_context.reset(token)
return f"{opening}{child_html}</{tag}>"

View File

@@ -169,7 +169,8 @@ def register_components(sx_source: str) -> None:
(div :class "..." (div :class "..." title)))))
''')
"""
from .evaluator import _eval
from .evaluator import _eval as _raw_eval, _trampoline
_eval = lambda expr, env: _trampoline(_raw_eval(expr, env))
from .parser import parse_all
from .css_registry import scan_classes_from_sx

View File

@@ -2,8 +2,8 @@
Named layout presets for defpage.
Each layout generates header rows for full-page and OOB rendering.
Layouts wrap existing helper functions from ``shared.sx.helpers`` so
defpage can reference them by name (e.g. ``:layout :root``).
Built-in layouts delegate to .sx defcomps via ``register_sx_layout``.
Services register custom layouts via ``register_custom_layout``.
Layouts are registered in ``_LAYOUT_REGISTRY`` and looked up by
``get_layout()`` at request time.
@@ -13,12 +13,6 @@ from __future__ import annotations
from typing import Any, Callable, Awaitable
from .helpers import (
root_header_sx, post_header_sx, post_admin_header_sx,
oob_header_sx, header_child_sx,
mobile_menu_sx, mobile_root_nav_sx,
post_mobile_nav_sx, post_admin_mobile_nav_sx,
)
# ---------------------------------------------------------------------------
@@ -83,71 +77,50 @@ def get_layout(name: str) -> Layout | None:
return _LAYOUT_REGISTRY.get(name)
# Built-in post/post-admin layouts are registered below via register_sx_layout,
# after that function is defined.
# ---------------------------------------------------------------------------
# Built-in layouts
# register_sx_layout — declarative layout from .sx defcomp names
# ---------------------------------------------------------------------------
# (defined below, used immediately after for built-in "root" layout)
# ---------------------------------------------------------------------------
def _root_full(ctx: dict, **kw: Any) -> str:
return root_header_sx(ctx)
def register_sx_layout(name: str, full_defcomp: str, oob_defcomp: str,
mobile_defcomp: str | None = None) -> None:
"""Register a layout that delegates entirely to .sx defcomps.
Layout defcomps use IO primitives (via auto-fetching macros) to
self-populate — no Python env injection needed. Any extra kwargs
from the caller are passed as kebab-case env entries::
register_sx_layout("account", "account-layout-full",
"account-layout-oob", "account-layout-mobile")
"""
from .helpers import _render_to_sx_with_env
async def full_fn(ctx: dict, **kw: Any) -> str:
env = {k.replace("_", "-"): v for k, v in kw.items()}
return await _render_to_sx_with_env(full_defcomp, env)
async def oob_fn(ctx: dict, **kw: Any) -> str:
env = {k.replace("_", "-"): v for k, v in kw.items()}
return await _render_to_sx_with_env(oob_defcomp, env)
mobile_fn = None
if mobile_defcomp:
async def mobile_fn(ctx: dict, **kw: Any) -> str:
env = {k.replace("_", "-"): v for k, v in kw.items()}
return await _render_to_sx_with_env(mobile_defcomp, env)
register_layout(Layout(name, full_fn, oob_fn, mobile_fn))
def _root_oob(ctx: dict, **kw: Any) -> str:
root_hdr = root_header_sx(ctx)
return oob_header_sx("root-header-child", "root-header-child", root_hdr)
def _post_full(ctx: dict, **kw: Any) -> str:
root_hdr = root_header_sx(ctx)
post_hdr = post_header_sx(ctx)
return "(<> " + root_hdr + " " + post_hdr + ")"
def _post_oob(ctx: dict, **kw: Any) -> str:
post_hdr = post_header_sx(ctx, oob=True)
# Also replace #post-header-child (empty — clears any nested admin rows)
child_oob = oob_header_sx("post-header-child", "", "")
return "(<> " + post_hdr + " " + child_oob + ")"
def _post_admin_full(ctx: dict, **kw: Any) -> str:
slug = ctx.get("post", {}).get("slug", "")
selected = kw.get("selected", "")
root_hdr = root_header_sx(ctx)
admin_hdr = post_admin_header_sx(ctx, slug, selected=selected)
post_hdr = post_header_sx(ctx, child=admin_hdr)
return "(<> " + root_hdr + " " + post_hdr + ")"
def _post_admin_oob(ctx: dict, **kw: Any) -> str:
slug = ctx.get("post", {}).get("slug", "")
selected = kw.get("selected", "")
post_hdr = post_header_sx(ctx, oob=True)
admin_hdr = post_admin_header_sx(ctx, slug, selected=selected)
admin_oob = oob_header_sx("post-header-child", "post-admin-header-child", admin_hdr)
return "(<> " + post_hdr + " " + admin_oob + ")"
def _root_mobile(ctx: dict, **kw: Any) -> str:
return mobile_root_nav_sx(ctx)
def _post_mobile(ctx: dict, **kw: Any) -> str:
return mobile_menu_sx(post_mobile_nav_sx(ctx), mobile_root_nav_sx(ctx))
def _post_admin_mobile(ctx: dict, **kw: Any) -> str:
slug = ctx.get("post", {}).get("slug", "")
selected = kw.get("selected", "")
return mobile_menu_sx(
post_admin_mobile_nav_sx(ctx, slug, selected),
post_mobile_nav_sx(ctx),
mobile_root_nav_sx(ctx),
)
register_layout(Layout("root", _root_full, _root_oob, _root_mobile))
register_layout(Layout("post", _post_full, _post_oob, _post_mobile))
register_layout(Layout("post-admin", _post_admin_full, _post_admin_oob, _post_admin_mobile))
# Register built-in layouts via .sx defcomps
register_sx_layout("root", "layout-root-full", "layout-root-oob", "layout-root-mobile")
register_sx_layout("post", "layout-post-full", "layout-post-oob", "layout-post-mobile")
register_sx_layout("post-admin", "layout-post-admin-full", "layout-post-admin-oob", "layout-post-admin-mobile")
# ---------------------------------------------------------------------------

View File

@@ -30,8 +30,8 @@ from typing import Any
from .jinja_bridge import sx
SEARCH_HEADERS_MOBILE = '{"X-Origin":"search-mobile","X-Search":"true"}'
SEARCH_HEADERS_DESKTOP = '{"X-Origin":"search-desktop","X-Search":"true"}'
SEARCH_HEADERS_MOBILE = {"X-Origin": "search-mobile", "X-Search": "true"}
SEARCH_HEADERS_DESKTOP = {"X-Origin": "search-desktop", "X-Search": "true"}
def render_page(source: str, **kwargs: Any) -> str:

View File

@@ -96,7 +96,8 @@ def get_page_helpers(service: str) -> dict[str, Any]:
def load_page_file(filepath: str, service_name: str) -> list[PageDef]:
"""Parse an .sx file, evaluate it, and register any PageDef values."""
from .parser import parse_all
from .evaluator import _eval
from .evaluator import _eval as _raw_eval, _trampoline
_eval = lambda expr, env: _trampoline(_raw_eval(expr, env))
from .jinja_bridge import get_component_env
with open(filepath, encoding="utf-8") as f:
@@ -132,31 +133,14 @@ def load_page_dir(directory: str, service_name: str) -> list[PageDef]:
# Page execution
# ---------------------------------------------------------------------------
async def _eval_slot(expr: Any, env: dict, ctx: Any,
async_eval_fn: Any, async_eval_to_sx_fn: Any) -> str:
async def _eval_slot(expr: Any, env: dict, ctx: Any) -> str:
"""Evaluate a page slot expression and return an sx source string.
If the expression evaluates to a plain string (e.g. from a Python content
builder), use it directly as sx source. If it evaluates to an AST/list,
serialize it to sx wire format via async_eval_to_sx.
Expands component calls (so IO in the body executes) but serializes
the result as SX wire format, not HTML.
"""
from .html import _RawHTML
from .parser import SxExpr
# First try async_eval to get the raw value
result = await async_eval_fn(expr, env, ctx)
# If it's already an sx source string, use as-is
if isinstance(result, str):
return result
if isinstance(result, _RawHTML):
return result.html
if isinstance(result, SxExpr):
return result.source
if result is None:
return ""
# For other types (lists, components rendered to HTML via _RawHTML, etc.),
# serialize to sx wire format
from .parser import serialize
return serialize(result)
from .async_eval import async_eval_slot_to_sx
return await async_eval_slot_to_sx(expr, env, ctx)
async def execute_page(
@@ -174,7 +158,7 @@ async def execute_page(
6. Branch: full_page_sx() vs oob_page_sx() based on is_htmx_request()
"""
from .jinja_bridge import get_component_env, _get_request_context
from .async_eval import async_eval, async_eval_to_sx
from .async_eval import async_eval
from .page import get_template_context
from .helpers import full_page_sx, oob_page_sx, sx_response
from .layouts import get_layout
@@ -201,23 +185,25 @@ async def execute_page(
if page_def.data_expr is not None:
data_result = await async_eval(page_def.data_expr, env, ctx)
if isinstance(data_result, dict):
env.update(data_result)
# Merge with kebab-case keys so SX symbols can reference them
for k, v in data_result.items():
env[k.replace("_", "-")] = v
# Render content slot (required)
content_sx = await _eval_slot(page_def.content_expr, env, ctx, async_eval, async_eval_to_sx)
content_sx = await _eval_slot(page_def.content_expr, env, ctx)
# Render optional slots
filter_sx = ""
if page_def.filter_expr is not None:
filter_sx = await _eval_slot(page_def.filter_expr, env, ctx, async_eval, async_eval_to_sx)
filter_sx = await _eval_slot(page_def.filter_expr, env, ctx)
aside_sx = ""
if page_def.aside_expr is not None:
aside_sx = await _eval_slot(page_def.aside_expr, env, ctx, async_eval, async_eval_to_sx)
aside_sx = await _eval_slot(page_def.aside_expr, env, ctx)
menu_sx = ""
if page_def.menu_expr is not None:
menu_sx = await _eval_slot(page_def.menu_expr, env, ctx, async_eval, async_eval_to_sx)
menu_sx = await _eval_slot(page_def.menu_expr, env, ctx)
# Resolve layout → header rows + mobile menu fallback
tctx = await get_template_context()
@@ -268,7 +254,7 @@ async def execute_page(
is_htmx = is_htmx_request()
if is_htmx:
return sx_response(oob_page_sx(
return sx_response(await oob_page_sx(
oobs=oob_headers if oob_headers else "",
filter=filter_sx,
aside=aside_sx,
@@ -276,7 +262,7 @@ async def execute_page(
menu=menu_sx,
))
else:
return full_page_sx(
return await full_page_sx(
tctx,
header_rows=header_rows,
filter=filter_sx,
@@ -290,6 +276,18 @@ async def execute_page(
# Blueprint mounting
# ---------------------------------------------------------------------------
def auto_mount_pages(app: Any, service_name: str) -> None:
"""Auto-mount all registered defpages for a service directly on the app.
Pages must have absolute paths (from the service URL root).
Called once per service in app.py after setup_*_pages().
"""
pages = get_all_pages(service_name)
for page_def in pages.values():
_mount_one_page(app, service_name, page_def)
logger.info("Auto-mounted %d defpages for %s", len(pages), service_name)
def mount_pages(bp: Any, service_name: str,
names: set[str] | list[str] | None = None) -> None:
"""Mount registered PageDef routes onto a Quart Blueprint.

View File

@@ -25,31 +25,37 @@ from .types import Keyword, Symbol, NIL
# SxExpr — pre-built sx source marker
# ---------------------------------------------------------------------------
class SxExpr:
class SxExpr(str):
"""Pre-built sx source that serialize() outputs unquoted.
``SxExpr`` is a ``str`` subclass, so it works everywhere a plain
string does (join, startswith, f-strings, isinstance checks). The
only difference: ``serialize()`` emits it unquoted instead of
wrapping it in double-quotes.
Use this to nest sx call strings inside other sx_call() invocations
without them being quoted as strings::
sx_call("parent", child=SxExpr(sx_call("child", x=1)))
sx_call("parent", child=sx_call("child", x=1))
# => (~parent :child (~child :x 1))
"""
__slots__ = ("source",)
def __init__(self, source: str):
self.source = source
def __new__(cls, source: str = "") -> "SxExpr":
return str.__new__(cls, source)
@property
def source(self) -> str:
"""The raw SX source string (backward compat)."""
return str.__str__(self)
def __repr__(self) -> str:
return f"SxExpr({self.source!r})"
def __str__(self) -> str:
return self.source
return f"SxExpr({str.__repr__(self)})"
def __add__(self, other: object) -> "SxExpr":
return SxExpr(self.source + str(other))
return SxExpr(str.__add__(self, str(other)))
def __radd__(self, other: object) -> "SxExpr":
return SxExpr(str(other) + self.source)
return SxExpr(str.__add__(str(other), self))
# ---------------------------------------------------------------------------
@@ -283,7 +289,26 @@ def _parse_map(tok: Tokenizer) -> dict[str, Any]:
# ---------------------------------------------------------------------------
def serialize(expr: Any, indent: int = 0, pretty: bool = False) -> str:
"""Serialize a value back to s-expression text."""
"""Serialize a value back to s-expression text.
Type dispatch order (first match wins):
- ``SxExpr`` → emitted unquoted (pre-built sx source)
- ``list`` → ``(head ...)`` (s-expression list)
- ``Symbol`` → bare name
- ``Keyword`` → ``:name``
- ``str`` → ``"quoted"`` (with escapes)
- ``bool`` → ``true`` / ``false``
- ``int/float`` → numeric literal
- ``None/NIL`` → ``nil``
- ``dict`` → ``{:key val ...}``
List serialization conventions (for ``sx_call`` kwargs):
- ``(list ...)`` — data array: client gets iterable for map/filter
- ``(<> ...)`` — rendered content: client treats as DocumentFragment
- ``(head ...)`` — AST: head is called as function (never use for data)
"""
if isinstance(expr, SxExpr):
return expr.source
@@ -336,6 +361,22 @@ def serialize(expr: Any, indent: int = 0, pretty: bool = False) -> str:
items.append(serialize(v, indent, pretty))
return "{" + " ".join(items) + "}"
# StyleValue — serialize as class name string
from .types import StyleValue
if isinstance(expr, StyleValue):
return f'"{expr.class_name}"'
# _RawHTML — pre-rendered HTML; wrap as (raw! "...") for SX wire format
from .html import _RawHTML
if isinstance(expr, _RawHTML):
escaped = (
expr.html.replace("\\", "\\\\")
.replace('"', '\\"')
.replace("\n", "\\n")
.replace("</script", "<\\/script")
)
return f'(raw! "{escaped}")'
# Catch callables (Python functions leaked into sx data)
if callable(expr):
import logging

View File

@@ -196,6 +196,8 @@ def prim_is_empty(coll: Any) -> bool:
@register_primitive("contains?")
def prim_contains(coll: Any, key: Any) -> bool:
if isinstance(coll, str):
return str(key) in coll
if isinstance(coll, dict):
k = key.name if isinstance(key, Keyword) else key
return k in coll
@@ -257,6 +259,24 @@ def prim_split(s: str, sep: str = " ") -> list[str]:
def prim_join(sep: str, coll: list) -> str:
return sep.join(str(x) for x in coll)
@register_primitive("replace")
def prim_replace(s: str, old: str, new: str) -> str:
return s.replace(old, new)
@register_primitive("strip-tags")
def prim_strip_tags(s: str) -> str:
"""Strip HTML tags from a string."""
import re
return re.sub(r"<[^>]+>", "", s)
@register_primitive("slice")
def prim_slice(coll: Any, start: int, end: Any = None) -> Any:
"""Slice a string or list: (slice coll start end?)."""
start = int(start)
if end is None or end is NIL:
return coll[start:]
return coll[start:int(end)]
@register_primitive("starts-with?")
def prim_starts_with(s, prefix: str) -> bool:
if not isinstance(s, str):
@@ -480,6 +500,15 @@ def prim_format_date(date_str: Any, fmt: str) -> str:
return str(date_str) if date_str else ""
@register_primitive("format-decimal")
def prim_format_decimal(val: Any, places: Any = 2) -> str:
"""``(format-decimal val places)`` → formatted decimal string."""
try:
return f"{float(val):.{int(places)}f}"
except (ValueError, TypeError):
return "0." + "0" * int(places)
@register_primitive("parse-int")
def prim_parse_int(val: Any, default: Any = 0) -> int | Any:
"""``(parse-int val default?)`` → int(val) with fallback."""
@@ -489,6 +518,23 @@ def prim_parse_int(val: Any, default: Any = 0) -> int | Any:
return default
@register_primitive("parse-datetime")
def prim_parse_datetime(val: Any) -> Any:
"""``(parse-datetime "2024-01-15T10:00:00")`` → datetime object."""
from datetime import datetime
if not val or val is NIL:
return NIL
return datetime.fromisoformat(str(val))
@register_primitive("split-ids")
def prim_split_ids(val: Any) -> list[int]:
"""``(split-ids "1,2,3")`` → [1, 2, 3]. Parse comma-separated int IDs."""
if not val or val is NIL:
return []
return [int(x.strip()) for x in str(val).split(",") if x.strip()]
# ---------------------------------------------------------------------------
# Assertions
# ---------------------------------------------------------------------------
@@ -498,3 +544,72 @@ def prim_assert(condition: Any, message: str = "Assertion failed") -> bool:
if not condition:
raise RuntimeError(f"Assertion error: {message}")
return True
# ---------------------------------------------------------------------------
# Text helpers
# ---------------------------------------------------------------------------
@register_primitive("pluralize")
def prim_pluralize(count: Any, singular: str = "", plural: str = "s") -> str:
"""``(pluralize count)`` → "s" if count != 1, else "".
``(pluralize count "item" "items")`` → "item" or "items"."""
try:
n = int(count)
except (ValueError, TypeError):
n = 0
if singular or plural != "s":
return singular if n == 1 else plural
return "" if n == 1 else "s"
@register_primitive("escape")
def prim_escape(s: Any) -> str:
"""``(escape val)`` → HTML-escaped string."""
from markupsafe import escape as _escape
return str(_escape(str(s) if s is not None and s is not NIL else ""))
@register_primitive("route-prefix")
def prim_route_prefix() -> str:
"""``(route-prefix)`` → service URL prefix for dev/prod routing."""
from shared.utils import route_prefix
return route_prefix()
# ---------------------------------------------------------------------------
# Style primitives
# ---------------------------------------------------------------------------
@register_primitive("css")
def prim_css(*args: Any) -> Any:
"""``(css :flex :gap-4 :hover:bg-sky-200)`` → StyleValue.
Accepts keyword atoms (strings without colon prefix) and runtime
strings. Returns a StyleValue with a content-addressed class name
and all resolved CSS declarations.
"""
from .style_resolver import resolve_style
atoms = tuple(
(a.name if isinstance(a, Keyword) else str(a))
for a in args if a is not None and a is not NIL and a is not False
)
if not atoms:
return NIL
return resolve_style(atoms)
@register_primitive("merge-styles")
def prim_merge_styles(*styles: Any) -> Any:
"""``(merge-styles style1 style2)`` → merged StyleValue.
Merges multiple StyleValues; later declarations win.
"""
from .types import StyleValue
from .style_resolver import merge_styles
valid = [s for s in styles if isinstance(s, StyleValue)]
if not valid:
return NIL
if len(valid) == 1:
return valid[0]
return merge_styles(valid)

View File

@@ -41,6 +41,24 @@ IO_PRIMITIVES: frozenset[str] = frozenset({
"nav-tree",
"get-children",
"g",
"csrf-token",
"abort",
"url-for",
"route-prefix",
"root-header-ctx",
"post-header-ctx",
"select-colours",
"account-nav-ctx",
"app-rights",
"federation-actor-ctx",
"request-view-args",
"cart-page-ctx",
"events-calendar-ctx",
"events-day-ctx",
"events-entry-ctx",
"events-slot-ctx",
"events-ticket-type-ctx",
"market-header-ctx",
})
@@ -221,7 +239,10 @@ def _dto_to_dict(obj: Any) -> dict[str, Any]:
keys for any datetime-valued field so sx handlers can build URL paths
without parsing date strings.
"""
if hasattr(obj, "_asdict"):
if hasattr(obj, "__dataclass_fields__"):
from shared.contracts.dtos import dto_to_dict
return dto_to_dict(obj)
elif hasattr(obj, "_asdict"):
d = dict(obj._asdict())
elif hasattr(obj, "__dict__"):
d = {k: v for k, v in obj.__dict__.items() if not k.startswith("_")}
@@ -241,6 +262,8 @@ def _convert_result(result: Any) -> Any:
if result is None:
from .types import NIL
return NIL
if isinstance(result, dict):
return {k: _convert_result(v) for k, v in result.items()}
if isinstance(result, tuple):
# Tuple returns (e.g. (entries, has_more)) → list for sx access
return [_convert_result(item) for item in result]
@@ -314,6 +337,605 @@ async def _io_g(
return getattr(g, key, None)
async def _io_csrf_token(
args: list[Any], kwargs: dict[str, Any], ctx: RequestContext
) -> str:
"""``(csrf-token)`` → current CSRF token string."""
from quart import current_app
csrf = current_app.jinja_env.globals.get("csrf_token")
if callable(csrf):
return csrf()
return ""
async def _io_abort(
args: list[Any], kwargs: dict[str, Any], ctx: RequestContext
) -> Any:
"""``(abort 403 "message")`` — raise HTTP error from SX.
Allows defpages to abort with HTTP error codes for auth/ownership
checks without needing a Python page helper.
"""
if not args:
raise ValueError("abort requires a status code")
from quart import abort
status = int(args[0])
message = str(args[1]) if len(args) > 1 else ""
abort(status, message)
async def _io_url_for(
args: list[Any], kwargs: dict[str, Any], ctx: RequestContext
) -> str:
"""``(url-for "endpoint" :key val ...)`` → url_for(endpoint, **kwargs).
Generates a URL for the given endpoint. Keyword args become URL
parameters (kebab-case converted to snake_case).
"""
if not args:
raise ValueError("url-for requires an endpoint name")
from quart import url_for
endpoint = str(args[0])
clean = {k.replace("-", "_"): v for k, v in _clean_kwargs(kwargs).items()}
# Convert numeric values for int URL params
for k, v in clean.items():
if isinstance(v, str) and v.isdigit():
clean[k] = int(v)
return url_for(endpoint, **clean)
async def _io_route_prefix(
args: list[Any], kwargs: dict[str, Any], ctx: RequestContext
) -> str:
"""``(route-prefix)`` → current route prefix string."""
from shared.utils import route_prefix
return route_prefix()
async def _io_root_header_ctx(
args: list[Any], kwargs: dict[str, Any], ctx: RequestContext
) -> dict[str, Any]:
"""``(root-header-ctx)`` → dict with all root header values.
Fetches cart-mini, auth-menu, nav-tree fragments and computes
settings-url / is-admin from rights. Result is cached on ``g``
per request so multiple calls (e.g. header + mobile) are free.
"""
from quart import g, current_app, request
cached = getattr(g, "_root_header_ctx", None)
if cached is not None:
return cached
from shared.infrastructure.fragments import fetch_fragments
from shared.infrastructure.cart_identity import current_cart_identity
from shared.infrastructure.urls import app_url
from shared.config import config
from .types import NIL
user = getattr(g, "user", None)
ident = current_cart_identity()
cart_params: dict[str, Any] = {}
if ident["user_id"] is not None:
cart_params["user_id"] = ident["user_id"]
if ident["session_id"] is not None:
cart_params["session_id"] = ident["session_id"]
auth_params: dict[str, Any] = {}
if user and getattr(user, "email", None):
auth_params["email"] = user.email
nav_params = {"app_name": current_app.name, "path": request.path}
cart_mini, auth_menu, nav_tree = await fetch_fragments([
("cart", "cart-mini", cart_params or None),
("account", "auth-menu", auth_params or None),
("blog", "nav-tree", nav_params),
])
rights = getattr(g, "rights", None) or {}
is_admin = (
rights.get("admin", False)
if isinstance(rights, dict)
else getattr(rights, "admin", False)
)
result = {
"cart-mini": cart_mini or NIL,
"blog-url": app_url("blog", ""),
"site-title": config()["title"],
"app-label": current_app.name,
"nav-tree": nav_tree or NIL,
"auth-menu": auth_menu or NIL,
"nav-panel": NIL,
"settings-url": app_url("blog", "/settings/") if is_admin else "",
"is-admin": is_admin,
}
g._root_header_ctx = result
return result
async def _io_select_colours(
args: list[Any], kwargs: dict[str, Any], ctx: RequestContext
) -> str:
"""``(select-colours)`` → the shared select/hover CSS class string."""
from quart import current_app
return current_app.jinja_env.globals.get("select_colours", "")
async def _io_account_nav_ctx(
args: list[Any], kwargs: dict[str, Any], ctx: RequestContext
) -> Any:
"""``(account-nav-ctx)`` → account nav fragments as SxExpr, or NIL.
Reads ``g.account_nav`` (set by account service's before_request hook),
wrapping HTML strings in ``~rich-text`` for SX rendering.
"""
from quart import g
from .types import NIL
from .parser import SxExpr
val = getattr(g, "account_nav", None)
if not val:
return NIL
if isinstance(val, SxExpr):
return val
# HTML string → wrap for SX rendering
escaped = str(val).replace("\\", "\\\\").replace('"', '\\"')
return SxExpr(f'(~rich-text :html "{escaped}")')
async def _io_app_rights(
args: list[Any], kwargs: dict[str, Any], ctx: RequestContext
) -> dict[str, Any]:
"""``(app-rights)`` → user rights dict from ``g.rights``."""
from quart import g
return getattr(g, "rights", None) or {}
async def _io_post_header_ctx(
args: list[Any], kwargs: dict[str, Any], ctx: RequestContext
) -> dict[str, Any]:
"""``(post-header-ctx)`` → dict with post-level header values.
Reads post data from ``g._defpage_ctx`` (set by per-service page
helpers), fetches container-nav and page cart count. Result is
cached on ``g`` per request.
Returns dict with keys: slug, title, feature-image, link-href,
container-nav, page-cart-count, cart-href, admin-href, is-admin,
is-admin-page, select-colours.
"""
from quart import g, request
cached = getattr(g, "_post_header_ctx", None)
if cached is not None:
return cached
from shared.infrastructure.urls import app_url
from .types import NIL
from .parser import SxExpr
dctx = getattr(g, "_defpage_ctx", None) or {}
post = dctx.get("post") or {}
slug = post.get("slug", "")
if not slug:
result: dict[str, Any] = {"slug": ""}
g._post_header_ctx = result
return result
title = (post.get("title") or "")[:160]
feature_image = post.get("feature_image") or NIL
# Container nav (pre-fetched by page helper into defpage ctx)
raw_nav = dctx.get("container_nav") or ""
container_nav: Any = NIL
nav_str = str(raw_nav).strip()
if nav_str and nav_str.replace("(<>", "").replace(")", "").strip():
if isinstance(raw_nav, SxExpr):
container_nav = raw_nav
else:
container_nav = SxExpr(nav_str)
page_cart_count = dctx.get("page_cart_count", 0) or 0
rights = getattr(g, "rights", None) or {}
is_admin = (
rights.get("admin", False)
if isinstance(rights, dict)
else getattr(rights, "admin", False)
)
is_admin_page = dctx.get("is_admin_section") or "/admin" in request.path
from quart import current_app
select_colours = current_app.jinja_env.globals.get("select_colours", "")
result = {
"slug": slug,
"title": title,
"feature-image": feature_image,
"link-href": app_url("blog", f"/{slug}/"),
"container-nav": container_nav,
"page-cart-count": page_cart_count,
"cart-href": app_url("cart", f"/{slug}/") if page_cart_count else "",
"admin-href": app_url("blog", f"/{slug}/admin/"),
"is-admin": is_admin,
"is-admin-page": is_admin_page or NIL,
"select-colours": select_colours,
}
g._post_header_ctx = result
return result
async def _io_cart_page_ctx(
args: list[Any], kwargs: dict[str, Any], ctx: RequestContext
) -> dict[str, Any]:
"""``(cart-page-ctx)`` → dict with cart page header values.
Reads ``g.page_post`` (set by cart's before_request) and returns
slug, title, feature-image, and cart-url for the page cart header.
"""
from quart import g
from .types import NIL
from shared.infrastructure.urls import app_url
page_post = getattr(g, "page_post", None)
if not page_post:
return {"slug": "", "title": "", "feature-image": NIL, "cart-url": "/"}
slug = getattr(page_post, "slug", "") or ""
title = (getattr(page_post, "title", "") or "")[:160]
feature_image = getattr(page_post, "feature_image", None) or NIL
return {
"slug": slug,
"title": title,
"feature-image": feature_image,
"page-cart-url": app_url("cart", f"/{slug}/"),
"cart-url": app_url("cart", "/"),
}
async def _io_federation_actor_ctx(
args: list[Any], kwargs: dict[str, Any], ctx: RequestContext
) -> dict[str, Any] | None:
"""``(federation-actor-ctx)`` → serialized actor dict or None.
Reads ``g._social_actor`` (set by federation social blueprint's
before_request hook) and serializes to a dict for .sx components.
"""
from quart import g
actor = getattr(g, "_social_actor", None)
if not actor:
return None
return {
"id": actor.id,
"preferred_username": actor.preferred_username,
"display_name": getattr(actor, "display_name", None),
"icon_url": getattr(actor, "icon_url", None),
"actor_url": getattr(actor, "actor_url", ""),
}
async def _io_request_view_args(
args: list[Any], kwargs: dict[str, Any], ctx: RequestContext
) -> Any:
"""``(request-view-args "key")`` → request.view_args[key]."""
if not args:
raise ValueError("request-view-args requires a key")
from quart import request
key = str(args[0])
return (request.view_args or {}).get(key)
async def _io_events_calendar_ctx(
args: list[Any], kwargs: dict[str, Any], ctx: RequestContext
) -> dict[str, Any]:
"""``(events-calendar-ctx)`` → dict with events calendar header values.
Reads ``g.calendar`` or ``g._defpage_ctx["calendar"]`` and returns
slug, name, description for the calendar header row.
"""
from quart import g
cal = getattr(g, "calendar", None)
if not cal:
dctx = getattr(g, "_defpage_ctx", None) or {}
cal = dctx.get("calendar")
if not cal:
return {"slug": ""}
return {
"slug": getattr(cal, "slug", "") or "",
"name": getattr(cal, "name", "") or "",
"description": getattr(cal, "description", "") or "",
}
async def _io_events_day_ctx(
args: list[Any], kwargs: dict[str, Any], ctx: RequestContext
) -> dict[str, Any]:
"""``(events-day-ctx)`` → dict with events day header values.
Reads ``g.day_date``, ``g.calendar``, confirmed entries from
``g._defpage_ctx``. Pre-builds the confirmed entries nav as SxExpr.
"""
from quart import g, url_for
from .types import NIL
from .parser import SxExpr
dctx = getattr(g, "_defpage_ctx", None) or {}
cal = getattr(g, "calendar", None) or dctx.get("calendar")
day_date = dctx.get("day_date") or getattr(g, "day_date", None)
if not cal or not day_date:
return {"date-str": ""}
cal_slug = getattr(cal, "slug", "") or ""
# Build confirmed entries nav
confirmed = dctx.get("confirmed_entries") or []
rights = getattr(g, "rights", None) or {}
is_admin = (
rights.get("admin", False)
if isinstance(rights, dict)
else getattr(rights, "admin", False)
)
from .helpers import sx_call
nav_parts: list[str] = []
if confirmed:
entry_links = []
for entry in confirmed:
href = url_for(
"calendar.day.calendar_entries.calendar_entry.get",
calendar_slug=cal_slug,
year=day_date.year, month=day_date.month, day=day_date.day,
entry_id=entry.id,
)
start = entry.start_at.strftime("%H:%M") if entry.start_at else ""
end = (
f" \u2013 {entry.end_at.strftime('%H:%M')}"
if entry.end_at else ""
)
entry_links.append(sx_call(
"events-day-entry-link",
href=href, name=entry.name, time_str=f"{start}{end}",
))
inner = "".join(entry_links)
nav_parts.append(sx_call(
"events-day-entries-nav", inner=SxExpr(inner),
))
if is_admin and day_date:
admin_href = url_for(
"defpage_day_admin", calendar_slug=cal_slug,
year=day_date.year, month=day_date.month, day=day_date.day,
)
nav_parts.append(sx_call("nav-link", href=admin_href, icon="fa fa-cog"))
return {
"date-str": day_date.strftime("%A %d %B %Y"),
"year": day_date.year,
"month": day_date.month,
"day": day_date.day,
"nav": SxExpr("".join(nav_parts)) if nav_parts else NIL,
}
async def _io_events_entry_ctx(
args: list[Any], kwargs: dict[str, Any], ctx: RequestContext
) -> dict[str, Any]:
"""``(events-entry-ctx)`` → dict with events entry header values.
Reads ``g.entry``, ``g.calendar``, and entry_posts from
``g._defpage_ctx``. Pre-builds entry nav (posts + admin link) as SxExpr.
"""
from quart import g, url_for
from .types import NIL
from .parser import SxExpr
dctx = getattr(g, "_defpage_ctx", None) or {}
cal = getattr(g, "calendar", None) or dctx.get("calendar")
entry = getattr(g, "entry", None) or dctx.get("entry")
if not cal or not entry:
return {"id": ""}
cal_slug = getattr(cal, "slug", "") or ""
day = dctx.get("day")
month = dctx.get("month")
year = dctx.get("year")
# Times
start = entry.start_at
end = entry.end_at
time_str = ""
if start:
time_str = start.strftime("%H:%M")
if end:
time_str += f" \u2192 {end.strftime('%H:%M')}"
link_href = url_for(
"calendar.day.calendar_entries.calendar_entry.get",
calendar_slug=cal_slug,
year=year, month=month, day=day, entry_id=entry.id,
)
# Build nav: associated posts + admin link
entry_posts = dctx.get("entry_posts") or []
rights = getattr(g, "rights", None) or {}
is_admin = (
rights.get("admin", False)
if isinstance(rights, dict)
else getattr(rights, "admin", False)
)
from .helpers import sx_call
from shared.infrastructure.urls import app_url
nav_parts: list[str] = []
if entry_posts:
post_links = ""
for ep in entry_posts:
ep_slug = getattr(ep, "slug", "")
ep_title = getattr(ep, "title", "")
feat = getattr(ep, "feature_image", None)
href = app_url("blog", f"/{ep_slug}/")
if feat:
img_html = sx_call("events-post-img", src=feat, alt=ep_title)
else:
img_html = sx_call("events-post-img-placeholder")
post_links += sx_call(
"events-entry-nav-post-link",
href=href, img=SxExpr(img_html), title=ep_title,
)
nav_parts.append(
sx_call("events-entry-posts-nav-oob", items=SxExpr(post_links))
.replace(' :hx-swap-oob "true"', '')
)
if is_admin:
admin_url = url_for(
"calendar.day.calendar_entries.calendar_entry.admin.admin",
calendar_slug=cal_slug,
day=day, month=month, year=year, entry_id=entry.id,
)
nav_parts.append(sx_call("events-entry-admin-link", href=admin_url))
# Entry admin nav (ticket_types link)
admin_href = url_for(
"calendar.day.calendar_entries.calendar_entry.admin.admin",
calendar_slug=cal_slug,
day=day, month=month, year=year, entry_id=entry.id,
) if is_admin else ""
ticket_types_href = url_for(
"calendar.day.calendar_entries.calendar_entry.ticket_types.get",
calendar_slug=cal_slug, entry_id=entry.id,
year=year, month=month, day=day,
)
from quart import current_app
select_colours = current_app.jinja_env.globals.get("select_colours", "")
return {
"id": str(entry.id),
"name": entry.name or "",
"time-str": time_str,
"link-href": link_href,
"nav": SxExpr("".join(nav_parts)) if nav_parts else NIL,
"admin-href": admin_href,
"ticket-types-href": ticket_types_href,
"is-admin": is_admin,
"select-colours": select_colours,
}
async def _io_events_slot_ctx(
args: list[Any], kwargs: dict[str, Any], ctx: RequestContext
) -> dict[str, Any]:
"""``(events-slot-ctx)`` → dict with events slot header values."""
from quart import g
dctx = getattr(g, "_defpage_ctx", None) or {}
slot = getattr(g, "slot", None) or dctx.get("slot")
if not slot:
return {"name": ""}
return {
"name": getattr(slot, "name", "") or "",
"description": getattr(slot, "description", "") or "",
}
async def _io_events_ticket_type_ctx(
args: list[Any], kwargs: dict[str, Any], ctx: RequestContext
) -> dict[str, Any]:
"""``(events-ticket-type-ctx)`` → dict with ticket type header values."""
from quart import g, url_for
dctx = getattr(g, "_defpage_ctx", None) or {}
cal = getattr(g, "calendar", None) or dctx.get("calendar")
entry = getattr(g, "entry", None) or dctx.get("entry")
ticket_type = getattr(g, "ticket_type", None) or dctx.get("ticket_type")
if not cal or not entry or not ticket_type:
return {"id": ""}
cal_slug = getattr(cal, "slug", "") or ""
day = dctx.get("day")
month = dctx.get("month")
year = dctx.get("year")
link_href = url_for(
"calendar.day.calendar_entries.calendar_entry.ticket_types.ticket_type.get",
calendar_slug=cal_slug, year=year, month=month, day=day,
entry_id=entry.id, ticket_type_id=ticket_type.id,
)
return {
"id": str(ticket_type.id),
"name": getattr(ticket_type, "name", "") or "",
"link-href": link_href,
}
async def _io_market_header_ctx(
args: list[Any], kwargs: dict[str, Any], ctx: RequestContext
) -> dict[str, Any]:
"""``(market-header-ctx)`` → dict with market header data.
Returns plain data (categories list, hrefs, flags) for the
~market-header-auto macro. Mobile nav is pre-built as SxExpr.
"""
from quart import g, url_for
from shared.config import config as get_config
from .parser import SxExpr
cfg = get_config()
market_title = cfg.get("market_title", "")
link_href = url_for("defpage_market_home")
# Get categories if market is loaded
market = getattr(g, "market", None)
categories = {}
if market:
from bp.browse.services.nav import get_nav
nav_data = await get_nav(g.s, market_id=market.id)
categories = nav_data.get("cats", {})
# Build minimal ctx for existing helper functions
select_colours = getattr(g, "select_colours", "")
if not select_colours:
from quart import current_app
select_colours = current_app.jinja_env.globals.get("select_colours", "")
rights = getattr(g, "rights", None) or {}
mini_ctx: dict[str, Any] = {
"market_title": market_title,
"top_slug": "",
"sub_slug": "",
"categories": categories,
"qs": "",
"hx_select_search": "#main-panel",
"select_colours": select_colours,
"rights": rights,
"category_label": "",
}
# Build header + mobile nav data via new data-driven helpers
from sxc.pages.layouts import _market_header_data, _mobile_nav_panel_sx
header_data = _market_header_data(mini_ctx)
mobile_nav = _mobile_nav_panel_sx(mini_ctx)
return {
"market-title": market_title,
"link-href": link_href,
"top-slug": "",
"sub-slug": "",
"categories": header_data.get("categories", []),
"hx-select": header_data.get("hx-select", "#main-panel"),
"select-colours": header_data.get("select-colours", ""),
"all-href": header_data.get("all-href", ""),
"all-active": header_data.get("all-active", False),
"admin-href": header_data.get("admin-href", ""),
"mobile-nav": SxExpr(mobile_nav) if mobile_nav else "",
}
_IO_HANDLERS: dict[str, Any] = {
"frag": _io_frag,
"query": _io_query,
@@ -326,4 +948,22 @@ _IO_HANDLERS: dict[str, Any] = {
"nav-tree": _io_nav_tree,
"get-children": _io_get_children,
"g": _io_g,
"csrf-token": _io_csrf_token,
"abort": _io_abort,
"url-for": _io_url_for,
"route-prefix": _io_route_prefix,
"root-header-ctx": _io_root_header_ctx,
"post-header-ctx": _io_post_header_ctx,
"select-colours": _io_select_colours,
"account-nav-ctx": _io_account_nav_ctx,
"app-rights": _io_app_rights,
"federation-actor-ctx": _io_federation_actor_ctx,
"request-view-args": _io_request_view_args,
"cart-page-ctx": _io_cart_page_ctx,
"events-calendar-ctx": _io_events_calendar_ctx,
"events-day-ctx": _io_events_day_ctx,
"events-entry-ctx": _io_events_entry_ctx,
"events-slot-ctx": _io_events_slot_ctx,
"events-ticket-type-ctx": _io_events_ticket_type_ctx,
"market-header-ctx": _io_market_header_ctx,
}

View File

@@ -0,0 +1,70 @@
"""
Execute defquery / defaction definitions.
Unlike fragment handlers (which produce SX markup via ``async_eval_to_sx``),
query/action defs produce **data** (dicts, lists, scalars) that get
JSON-serialized by the calling blueprint. Uses ``async_eval()`` with
the I/O primitive pipeline so ``(service ...)`` calls are awaited inline.
"""
from __future__ import annotations
from typing import Any
from .types import QueryDef, ActionDef, NIL
async def execute_query(query_def: QueryDef, params: dict[str, str]) -> Any:
"""Execute a defquery and return a JSON-serializable result.
Parameters are bound from request query string args.
"""
from .jinja_bridge import get_component_env, _get_request_context
from .async_eval import async_eval
env = dict(get_component_env())
env.update(query_def.closure)
# Bind params from request args (try kebab-case and snake_case)
for param in query_def.params:
snake = param.replace("-", "_")
val = params.get(param, params.get(snake, NIL))
# Coerce type=int for common patterns
if isinstance(val, str) and val.lstrip("-").isdigit():
val = int(val)
env[param] = val
ctx = _get_request_context()
result = await async_eval(query_def.body, env, ctx)
return _normalize(result)
async def execute_action(action_def: ActionDef, payload: dict[str, Any]) -> Any:
"""Execute a defaction and return a JSON-serializable result.
Parameters are bound from the JSON request body.
"""
from .jinja_bridge import get_component_env, _get_request_context
from .async_eval import async_eval
env = dict(get_component_env())
env.update(action_def.closure)
# Bind params from JSON payload (try kebab-case and snake_case)
for param in action_def.params:
snake = param.replace("-", "_")
val = payload.get(param, payload.get(snake, NIL))
env[param] = val
ctx = _get_request_context()
result = await async_eval(action_def.body, env, ctx)
return _normalize(result)
def _normalize(value: Any) -> Any:
"""Ensure result is JSON-serializable (strip NIL, convert sets, etc)."""
if value is NIL or value is None:
return None
if isinstance(value, set):
return list(value)
return value

182
shared/sx/query_registry.py Normal file
View File

@@ -0,0 +1,182 @@
"""
Registry for defquery / defaction definitions.
Mirrors the pattern in ``handlers.py`` but for inter-service data queries
and action endpoints. Each service loads its ``.sx`` files at startup,
and the registry makes them available for dispatch by the query blueprint.
Usage::
from shared.sx.query_registry import load_query_file, get_query
load_query_file("events/queries.sx", "events")
qdef = get_query("events", "pending-entries")
"""
from __future__ import annotations
import logging
import os
from typing import Any
from .types import QueryDef, ActionDef
logger = logging.getLogger("sx.query_registry")
# ---------------------------------------------------------------------------
# Registry — service → name → QueryDef / ActionDef
# ---------------------------------------------------------------------------
_QUERY_REGISTRY: dict[str, dict[str, QueryDef]] = {}
_ACTION_REGISTRY: dict[str, dict[str, ActionDef]] = {}
def register_query(service: str, qdef: QueryDef) -> None:
if service not in _QUERY_REGISTRY:
_QUERY_REGISTRY[service] = {}
_QUERY_REGISTRY[service][qdef.name] = qdef
logger.debug("Registered query %s:%s", service, qdef.name)
def register_action(service: str, adef: ActionDef) -> None:
if service not in _ACTION_REGISTRY:
_ACTION_REGISTRY[service] = {}
_ACTION_REGISTRY[service][adef.name] = adef
logger.debug("Registered action %s:%s", service, adef.name)
def get_query(service: str, name: str) -> QueryDef | None:
return _QUERY_REGISTRY.get(service, {}).get(name)
def get_action(service: str, name: str) -> ActionDef | None:
return _ACTION_REGISTRY.get(service, {}).get(name)
def get_all_queries(service: str) -> dict[str, QueryDef]:
return dict(_QUERY_REGISTRY.get(service, {}))
def get_all_actions(service: str) -> dict[str, ActionDef]:
return dict(_ACTION_REGISTRY.get(service, {}))
def clear(service: str | None = None) -> None:
if service is None:
_QUERY_REGISTRY.clear()
_ACTION_REGISTRY.clear()
else:
_QUERY_REGISTRY.pop(service, None)
_ACTION_REGISTRY.pop(service, None)
# ---------------------------------------------------------------------------
# Loading — parse .sx files and collect QueryDef / ActionDef instances
# ---------------------------------------------------------------------------
def load_query_file(filepath: str, service_name: str) -> list[QueryDef]:
"""Parse an .sx file and register any defquery definitions."""
from .parser import parse_all
from .evaluator import _eval as _raw_eval, _trampoline
_eval = lambda expr, env: _trampoline(_raw_eval(expr, env))
from .jinja_bridge import get_component_env
with open(filepath, encoding="utf-8") as f:
source = f.read()
env = dict(get_component_env())
exprs = parse_all(source)
queries: list[QueryDef] = []
for expr in exprs:
_eval(expr, env)
for val in env.values():
if isinstance(val, QueryDef):
register_query(service_name, val)
queries.append(val)
return queries
def load_action_file(filepath: str, service_name: str) -> list[ActionDef]:
"""Parse an .sx file and register any defaction definitions."""
from .parser import parse_all
from .evaluator import _eval as _raw_eval, _trampoline
_eval = lambda expr, env: _trampoline(_raw_eval(expr, env))
from .jinja_bridge import get_component_env
with open(filepath, encoding="utf-8") as f:
source = f.read()
env = dict(get_component_env())
exprs = parse_all(source)
actions: list[ActionDef] = []
for expr in exprs:
_eval(expr, env)
for val in env.values():
if isinstance(val, ActionDef):
register_action(service_name, val)
actions.append(val)
return actions
def load_query_dir(directory: str, service_name: str) -> list[QueryDef]:
"""Load all .sx files from a directory and register queries."""
import glob as glob_mod
queries: list[QueryDef] = []
for filepath in sorted(glob_mod.glob(os.path.join(directory, "*.sx"))):
queries.extend(load_query_file(filepath, service_name))
return queries
def load_action_dir(directory: str, service_name: str) -> list[ActionDef]:
"""Load all .sx files from a directory and register actions."""
import glob as glob_mod
actions: list[ActionDef] = []
for filepath in sorted(glob_mod.glob(os.path.join(directory, "*.sx"))):
actions.extend(load_action_file(filepath, service_name))
return actions
def load_service_protocols(service_name: str, base_dir: str) -> None:
"""Load queries.sx and actions.sx from a service's base directory."""
queries_path = os.path.join(base_dir, "queries.sx")
actions_path = os.path.join(base_dir, "actions.sx")
if os.path.exists(queries_path):
load_query_file(queries_path, service_name)
logger.info("Loaded queries for %s from %s", service_name, queries_path)
if os.path.exists(actions_path):
load_action_file(actions_path, service_name)
logger.info("Loaded actions for %s from %s", service_name, actions_path)
# ---------------------------------------------------------------------------
# Schema — introspection for /internal/schema
# ---------------------------------------------------------------------------
def schema_for_service(service: str) -> dict[str, Any]:
"""Return a JSON-serializable schema of all queries and actions."""
queries = []
for qdef in _QUERY_REGISTRY.get(service, {}).values():
queries.append({
"name": qdef.name,
"params": list(qdef.params),
"doc": qdef.doc,
})
actions = []
for adef in _ACTION_REGISTRY.get(service, {}).values():
actions.append({
"name": adef.name,
"params": list(adef.params),
"doc": adef.doc,
})
return {
"service": service,
"queries": sorted(queries, key=lambda q: q["name"]),
"actions": sorted(actions, key=lambda a: a["name"]),
}

View File

@@ -31,7 +31,11 @@ import asyncio
from typing import Any
from .types import Component, Keyword, Lambda, NIL, Symbol
from .evaluator import _eval
from .evaluator import _eval as _raw_eval, _trampoline
def _eval(expr, env):
"""Evaluate and unwrap thunks — all resolver.py _eval calls are non-tail."""
return _trampoline(_raw_eval(expr, env))
from .html import render as html_render, _RawHTML
from .primitives_io import (
IO_PRIMITIVES,

735
shared/sx/style_dict.py Normal file
View File

@@ -0,0 +1,735 @@
"""
Style dictionary — maps keyword atoms to CSS declarations.
Pure data. Each key is a Tailwind-compatible class name (used as an sx keyword
atom in ``(css :flex :gap-4 :p-2)``), and each value is the CSS declaration(s)
that class produces. Declarations are self-contained — no ``--tw-*`` custom
properties needed.
Generated from the codebase's tw.css via ``css_registry.py`` then simplified
to remove Tailwind v3 variable indirection.
Used by:
- ``style_resolver.py`` (server) — resolves ``(css ...)`` to StyleValue
- ``sx.js`` (client) — same resolution, cached in localStorage
"""
from __future__ import annotations
# ═══════════════════════════════════════════════════════════════════════════
# Base atoms — keyword → CSS declarations
# ═══════════════════════════════════════════════════════════════════════════
#
# ~466 atoms covering all utilities used across the codebase.
# Variants (hover:*, sm:*, focus:*, etc.) are NOT stored here — the
# resolver splits "hover:bg-sky-200" into variant="hover" + atom="bg-sky-200"
# and wraps the declaration in the appropriate pseudo/media rule.
STYLE_ATOMS: dict[str, str] = {
# ── Display ──────────────────────────────────────────────────────────
"block": "display:block",
"inline-block": "display:inline-block",
"inline": "display:inline",
"flex": "display:flex",
"inline-flex": "display:inline-flex",
"table": "display:table",
"grid": "display:grid",
"contents": "display:contents",
"hidden": "display:none",
# ── Position ─────────────────────────────────────────────────────────
"static": "position:static",
"fixed": "position:fixed",
"absolute": "position:absolute",
"relative": "position:relative",
"inset-0": "inset:0",
"top-0": "top:0",
"top-1/2": "top:50%",
"top-2": "top:.5rem",
"top-20": "top:5rem",
"top-[8px]": "top:8px",
"top-full": "top:100%",
"right-2": "right:.5rem",
"right-[8px]": "right:8px",
"bottom-full": "bottom:100%",
"left-1/2": "left:50%",
"left-2": "left:.5rem",
"-right-2": "right:-.5rem",
"-right-3": "right:-.75rem",
"-top-1.5": "top:-.375rem",
"-top-2": "top:-.5rem",
# ── Z-Index ──────────────────────────────────────────────────────────
"z-10": "z-index:10",
"z-40": "z-index:40",
"z-50": "z-index:50",
# ── Grid ─────────────────────────────────────────────────────────────
"grid-cols-1": "grid-template-columns:repeat(1,minmax(0,1fr))",
"grid-cols-2": "grid-template-columns:repeat(2,minmax(0,1fr))",
"grid-cols-3": "grid-template-columns:repeat(3,minmax(0,1fr))",
"grid-cols-4": "grid-template-columns:repeat(4,minmax(0,1fr))",
"grid-cols-5": "grid-template-columns:repeat(5,minmax(0,1fr))",
"grid-cols-6": "grid-template-columns:repeat(6,minmax(0,1fr))",
"grid-cols-7": "grid-template-columns:repeat(7,minmax(0,1fr))",
"grid-cols-12": "grid-template-columns:repeat(12,minmax(0,1fr))",
"col-span-2": "grid-column:span 2/span 2",
"col-span-3": "grid-column:span 3/span 3",
"col-span-4": "grid-column:span 4/span 4",
"col-span-5": "grid-column:span 5/span 5",
"col-span-12": "grid-column:span 12/span 12",
"col-span-full": "grid-column:1/-1",
# ── Flexbox ──────────────────────────────────────────────────────────
"flex-row": "flex-direction:row",
"flex-col": "flex-direction:column",
"flex-wrap": "flex-wrap:wrap",
"flex-1": "flex:1 1 0%",
"flex-shrink-0": "flex-shrink:0",
"shrink-0": "flex-shrink:0",
"flex-shrink": "flex-shrink:1",
# ── Alignment ────────────────────────────────────────────────────────
"items-start": "align-items:flex-start",
"items-end": "align-items:flex-end",
"items-center": "align-items:center",
"items-baseline": "align-items:baseline",
"justify-start": "justify-content:flex-start",
"justify-end": "justify-content:flex-end",
"justify-center": "justify-content:center",
"justify-between": "justify-content:space-between",
"self-start": "align-self:flex-start",
"self-center": "align-self:center",
"place-items-center": "place-items:center",
# ── Gap ───────────────────────────────────────────────────────────────
"gap-px": "gap:1px",
"gap-0.5": "gap:.125rem",
"gap-1": "gap:.25rem",
"gap-1.5": "gap:.375rem",
"gap-2": "gap:.5rem",
"gap-3": "gap:.75rem",
"gap-4": "gap:1rem",
"gap-5": "gap:1.25rem",
"gap-6": "gap:1.5rem",
"gap-8": "gap:2rem",
"gap-[4px]": "gap:4px",
"gap-[8px]": "gap:8px",
"gap-[16px]": "gap:16px",
"gap-x-3": "column-gap:.75rem",
"gap-y-1": "row-gap:.25rem",
# ── Margin ───────────────────────────────────────────────────────────
"m-0": "margin:0",
"m-2": "margin:.5rem",
"mx-1": "margin-left:.25rem;margin-right:.25rem",
"mx-2": "margin-left:.5rem;margin-right:.5rem",
"mx-4": "margin-left:1rem;margin-right:1rem",
"mx-auto": "margin-left:auto;margin-right:auto",
"my-3": "margin-top:.75rem;margin-bottom:.75rem",
"-mb-px": "margin-bottom:-1px",
"mb-1": "margin-bottom:.25rem",
"mb-2": "margin-bottom:.5rem",
"mb-3": "margin-bottom:.75rem",
"mb-4": "margin-bottom:1rem",
"mb-6": "margin-bottom:1.5rem",
"mb-8": "margin-bottom:2rem",
"mb-12": "margin-bottom:3rem",
"mb-[8px]": "margin-bottom:8px",
"mb-[24px]": "margin-bottom:24px",
"ml-1": "margin-left:.25rem",
"ml-2": "margin-left:.5rem",
"ml-4": "margin-left:1rem",
"ml-auto": "margin-left:auto",
"mr-1": "margin-right:.25rem",
"mr-2": "margin-right:.5rem",
"mr-3": "margin-right:.75rem",
"mt-0.5": "margin-top:.125rem",
"mt-1": "margin-top:.25rem",
"mt-2": "margin-top:.5rem",
"mt-3": "margin-top:.75rem",
"mt-4": "margin-top:1rem",
"mt-6": "margin-top:1.5rem",
"mt-8": "margin-top:2rem",
"mt-[8px]": "margin-top:8px",
"mt-[16px]": "margin-top:16px",
"mt-[32px]": "margin-top:32px",
# ── Padding ──────────────────────────────────────────────────────────
"p-0": "padding:0",
"p-1": "padding:.25rem",
"p-1.5": "padding:.375rem",
"p-2": "padding:.5rem",
"p-3": "padding:.75rem",
"p-4": "padding:1rem",
"p-5": "padding:1.25rem",
"p-6": "padding:1.5rem",
"p-8": "padding:2rem",
"px-1": "padding-left:.25rem;padding-right:.25rem",
"px-1.5": "padding-left:.375rem;padding-right:.375rem",
"px-2": "padding-left:.5rem;padding-right:.5rem",
"px-2.5": "padding-left:.625rem;padding-right:.625rem",
"px-3": "padding-left:.75rem;padding-right:.75rem",
"px-4": "padding-left:1rem;padding-right:1rem",
"px-6": "padding-left:1.5rem;padding-right:1.5rem",
"px-[8px]": "padding-left:8px;padding-right:8px",
"px-[12px]": "padding-left:12px;padding-right:12px",
"px-[16px]": "padding-left:16px;padding-right:16px",
"px-[20px]": "padding-left:20px;padding-right:20px",
"py-0.5": "padding-top:.125rem;padding-bottom:.125rem",
"py-1": "padding-top:.25rem;padding-bottom:.25rem",
"py-1.5": "padding-top:.375rem;padding-bottom:.375rem",
"py-2": "padding-top:.5rem;padding-bottom:.5rem",
"py-3": "padding-top:.75rem;padding-bottom:.75rem",
"py-4": "padding-top:1rem;padding-bottom:1rem",
"py-6": "padding-top:1.5rem;padding-bottom:1.5rem",
"py-8": "padding-top:2rem;padding-bottom:2rem",
"py-12": "padding-top:3rem;padding-bottom:3rem",
"py-16": "padding-top:4rem;padding-bottom:4rem",
"py-[6px]": "padding-top:6px;padding-bottom:6px",
"py-[12px]": "padding-top:12px;padding-bottom:12px",
"pb-1": "padding-bottom:.25rem",
"pb-2": "padding-bottom:.5rem",
"pb-3": "padding-bottom:.75rem",
"pb-4": "padding-bottom:1rem",
"pb-6": "padding-bottom:1.5rem",
"pb-8": "padding-bottom:2rem",
"pb-[48px]": "padding-bottom:48px",
"pl-2": "padding-left:.5rem",
"pl-5": "padding-left:1.25rem",
"pl-6": "padding-left:1.5rem",
"pr-1": "padding-right:.25rem",
"pr-2": "padding-right:.5rem",
"pr-4": "padding-right:1rem",
"pt-2": "padding-top:.5rem",
"pt-3": "padding-top:.75rem",
"pt-4": "padding-top:1rem",
"pt-[16px]": "padding-top:16px",
# ── Width ────────────────────────────────────────────────────────────
"w-1": "width:.25rem",
"w-2": "width:.5rem",
"w-4": "width:1rem",
"w-5": "width:1.25rem",
"w-6": "width:1.5rem",
"w-8": "width:2rem",
"w-10": "width:2.5rem",
"w-11": "width:2.75rem",
"w-12": "width:3rem",
"w-16": "width:4rem",
"w-20": "width:5rem",
"w-24": "width:6rem",
"w-28": "width:7rem",
"w-48": "width:12rem",
"w-1/2": "width:50%",
"w-1/3": "width:33.333333%",
"w-1/4": "width:25%",
"w-1/6": "width:16.666667%",
"w-2/6": "width:33.333333%",
"w-3/4": "width:75%",
"w-full": "width:100%",
"w-auto": "width:auto",
"w-[1em]": "width:1em",
"w-[32px]": "width:32px",
# ── Height ───────────────────────────────────────────────────────────
"h-2": "height:.5rem",
"h-4": "height:1rem",
"h-5": "height:1.25rem",
"h-6": "height:1.5rem",
"h-8": "height:2rem",
"h-10": "height:2.5rem",
"h-12": "height:3rem",
"h-14": "height:3.5rem",
"h-16": "height:4rem",
"h-24": "height:6rem",
"h-28": "height:7rem",
"h-48": "height:12rem",
"h-64": "height:16rem",
"h-full": "height:100%",
"h-[1em]": "height:1em",
"h-[30vh]": "height:30vh",
"h-[32px]": "height:32px",
"h-[60vh]": "height:60vh",
# ── Min/Max Dimensions ───────────────────────────────────────────────
"min-w-0": "min-width:0",
"min-w-full": "min-width:100%",
"min-w-[1.25rem]": "min-width:1.25rem",
"min-w-[180px]": "min-width:180px",
"min-h-0": "min-height:0",
"min-h-20": "min-height:5rem",
"min-h-[3rem]": "min-height:3rem",
"min-h-[50vh]": "min-height:50vh",
"max-w-xs": "max-width:20rem",
"max-w-md": "max-width:28rem",
"max-w-lg": "max-width:32rem",
"max-w-2xl": "max-width:42rem",
"max-w-3xl": "max-width:48rem",
"max-w-4xl": "max-width:56rem",
"max-w-full": "max-width:100%",
"max-w-none": "max-width:none",
"max-w-screen-2xl": "max-width:1536px",
"max-w-[360px]": "max-width:360px",
"max-w-[768px]": "max-width:768px",
"max-h-64": "max-height:16rem",
"max-h-96": "max-height:24rem",
"max-h-none": "max-height:none",
"max-h-[448px]": "max-height:448px",
"max-h-[50vh]": "max-height:50vh",
# ── Typography ───────────────────────────────────────────────────────
"text-xs": "font-size:.75rem;line-height:1rem",
"text-sm": "font-size:.875rem;line-height:1.25rem",
"text-base": "font-size:1rem;line-height:1.5rem",
"text-lg": "font-size:1.125rem;line-height:1.75rem",
"text-xl": "font-size:1.25rem;line-height:1.75rem",
"text-2xl": "font-size:1.5rem;line-height:2rem",
"text-3xl": "font-size:1.875rem;line-height:2.25rem",
"text-4xl": "font-size:2.25rem;line-height:2.5rem",
"text-5xl": "font-size:3rem;line-height:1",
"text-6xl": "font-size:3.75rem;line-height:1",
"text-8xl": "font-size:6rem;line-height:1",
"text-[8px]": "font-size:8px",
"text-[9px]": "font-size:9px",
"text-[10px]": "font-size:10px",
"text-[11px]": "font-size:11px",
"text-[13px]": "font-size:13px",
"text-[14px]": "font-size:14px",
"text-[16px]": "font-size:16px",
"text-[18px]": "font-size:18px",
"text-[36px]": "font-size:36px",
"text-[40px]": "font-size:40px",
"text-[0.6rem]": "font-size:.6rem",
"text-[0.65rem]": "font-size:.65rem",
"text-[0.7rem]": "font-size:.7rem",
"font-normal": "font-weight:400",
"font-medium": "font-weight:500",
"font-semibold": "font-weight:600",
"font-bold": "font-weight:700",
"font-mono": "font-family:ui-monospace,SFMono-Regular,Menlo,Monaco,Consolas,Liberation Mono,Courier New,monospace",
"italic": "font-style:italic",
"uppercase": "text-transform:uppercase",
"capitalize": "text-transform:capitalize",
"tabular-nums": "font-variant-numeric:tabular-nums",
"leading-none": "line-height:1",
"leading-tight": "line-height:1.25",
"leading-snug": "line-height:1.375",
"leading-relaxed": "line-height:1.625",
"tracking-tight": "letter-spacing:-.025em",
"tracking-wide": "letter-spacing:.025em",
"tracking-widest": "letter-spacing:.1em",
"text-left": "text-align:left",
"text-center": "text-align:center",
"text-right": "text-align:right",
"align-top": "vertical-align:top",
# ── Text Colors ──────────────────────────────────────────────────────
"text-white": "color:rgb(255 255 255)",
"text-white/80": "color:rgba(255,255,255,.8)",
"text-black": "color:rgb(0 0 0)",
"text-stone-300": "color:rgb(214 211 209)",
"text-stone-400": "color:rgb(168 162 158)",
"text-stone-500": "color:rgb(120 113 108)",
"text-stone-600": "color:rgb(87 83 78)",
"text-stone-700": "color:rgb(68 64 60)",
"text-stone-800": "color:rgb(41 37 36)",
"text-stone-900": "color:rgb(28 25 23)",
"text-slate-400": "color:rgb(148 163 184)",
"text-gray-500": "color:rgb(107 114 128)",
"text-gray-600": "color:rgb(75 85 99)",
"text-red-500": "color:rgb(239 68 68)",
"text-red-600": "color:rgb(220 38 38)",
"text-red-700": "color:rgb(185 28 28)",
"text-red-800": "color:rgb(153 27 27)",
"text-rose-500": "color:rgb(244 63 94)",
"text-rose-600": "color:rgb(225 29 72)",
"text-rose-700": "color:rgb(190 18 60)",
"text-rose-800/80": "color:rgba(159,18,57,.8)",
"text-rose-900": "color:rgb(136 19 55)",
"text-orange-600": "color:rgb(234 88 12)",
"text-amber-500": "color:rgb(245 158 11)",
"text-amber-600": "color:rgb(217 119 6)",
"text-amber-700": "color:rgb(180 83 9)",
"text-amber-800": "color:rgb(146 64 14)",
"text-yellow-700": "color:rgb(161 98 7)",
"text-green-600": "color:rgb(22 163 74)",
"text-green-800": "color:rgb(22 101 52)",
"text-emerald-500": "color:rgb(16 185 129)",
"text-emerald-600": "color:rgb(5 150 105)",
"text-emerald-700": "color:rgb(4 120 87)",
"text-emerald-800": "color:rgb(6 95 70)",
"text-emerald-900": "color:rgb(6 78 59)",
"text-sky-600": "color:rgb(2 132 199)",
"text-sky-700": "color:rgb(3 105 161)",
"text-sky-800": "color:rgb(7 89 133)",
"text-blue-500": "color:rgb(59 130 246)",
"text-blue-600": "color:rgb(37 99 235)",
"text-blue-700": "color:rgb(29 78 216)",
"text-blue-800": "color:rgb(30 64 175)",
"text-purple-600": "color:rgb(147 51 234)",
"text-violet-600": "color:rgb(124 58 237)",
"text-violet-700": "color:rgb(109 40 217)",
"text-violet-800": "color:rgb(91 33 182)",
# ── Background Colors ────────────────────────────────────────────────
"bg-transparent": "background-color:transparent",
"bg-white": "background-color:rgb(255 255 255)",
"bg-white/60": "background-color:rgba(255,255,255,.6)",
"bg-white/70": "background-color:rgba(255,255,255,.7)",
"bg-white/80": "background-color:rgba(255,255,255,.8)",
"bg-white/90": "background-color:rgba(255,255,255,.9)",
"bg-black": "background-color:rgb(0 0 0)",
"bg-black/50": "background-color:rgba(0,0,0,.5)",
"bg-stone-50": "background-color:rgb(250 250 249)",
"bg-stone-100": "background-color:rgb(245 245 244)",
"bg-stone-200": "background-color:rgb(231 229 228)",
"bg-stone-300": "background-color:rgb(214 211 209)",
"bg-stone-400": "background-color:rgb(168 162 158)",
"bg-stone-500": "background-color:rgb(120 113 108)",
"bg-stone-600": "background-color:rgb(87 83 78)",
"bg-stone-700": "background-color:rgb(68 64 60)",
"bg-stone-800": "background-color:rgb(41 37 36)",
"bg-stone-900": "background-color:rgb(28 25 23)",
"bg-slate-100": "background-color:rgb(241 245 249)",
"bg-slate-200": "background-color:rgb(226 232 240)",
"bg-gray-100": "background-color:rgb(243 244 246)",
"bg-red-50": "background-color:rgb(254 242 242)",
"bg-red-100": "background-color:rgb(254 226 226)",
"bg-red-200": "background-color:rgb(254 202 202)",
"bg-red-500": "background-color:rgb(239 68 68)",
"bg-red-600": "background-color:rgb(220 38 38)",
"bg-rose-50": "background-color:rgb(255 241 242)",
"bg-rose-50/80": "background-color:rgba(255,241,242,.8)",
"bg-orange-100": "background-color:rgb(255 237 213)",
"bg-amber-50": "background-color:rgb(255 251 235)",
"bg-amber-50/60": "background-color:rgba(255,251,235,.6)",
"bg-amber-100": "background-color:rgb(254 243 199)",
"bg-amber-500": "background-color:rgb(245 158 11)",
"bg-amber-600": "background-color:rgb(217 119 6)",
"bg-yellow-50": "background-color:rgb(254 252 232)",
"bg-yellow-100": "background-color:rgb(254 249 195)",
"bg-yellow-200": "background-color:rgb(254 240 138)",
"bg-yellow-300": "background-color:rgb(253 224 71)",
"bg-green-50": "background-color:rgb(240 253 244)",
"bg-green-100": "background-color:rgb(220 252 231)",
"bg-emerald-50": "background-color:rgb(236 253 245)",
"bg-emerald-50/80": "background-color:rgba(236,253,245,.8)",
"bg-emerald-100": "background-color:rgb(209 250 229)",
"bg-emerald-200": "background-color:rgb(167 243 208)",
"bg-emerald-500": "background-color:rgb(16 185 129)",
"bg-emerald-600": "background-color:rgb(5 150 105)",
"bg-sky-100": "background-color:rgb(224 242 254)",
"bg-sky-200": "background-color:rgb(186 230 253)",
"bg-sky-300": "background-color:rgb(125 211 252)",
"bg-sky-400": "background-color:rgb(56 189 248)",
"bg-sky-500": "background-color:rgb(14 165 233)",
"bg-blue-50": "background-color:rgb(239 246 255)",
"bg-blue-100": "background-color:rgb(219 234 254)",
"bg-blue-600": "background-color:rgb(37 99 235)",
"bg-purple-600": "background-color:rgb(147 51 234)",
"bg-violet-50": "background-color:rgb(245 243 255)",
"bg-violet-100": "background-color:rgb(237 233 254)",
"bg-violet-200": "background-color:rgb(221 214 254)",
"bg-violet-300": "background-color:rgb(196 181 253)",
"bg-violet-400": "background-color:rgb(167 139 250)",
"bg-violet-500": "background-color:rgb(139 92 246)",
"bg-violet-600": "background-color:rgb(124 58 237)",
# ── Border ───────────────────────────────────────────────────────────
"border": "border-width:1px",
"border-2": "border-width:2px",
"border-4": "border-width:4px",
"border-t": "border-top-width:1px",
"border-t-0": "border-top-width:0",
"border-b": "border-bottom-width:1px",
"border-b-2": "border-bottom-width:2px",
"border-r": "border-right-width:1px",
"border-l-4": "border-left-width:4px",
"border-dashed": "border-style:dashed",
"border-none": "border-style:none",
"border-transparent": "border-color:transparent",
"border-white": "border-color:rgb(255 255 255)",
"border-white/30": "border-color:rgba(255,255,255,.3)",
"border-stone-100": "border-color:rgb(245 245 244)",
"border-stone-200": "border-color:rgb(231 229 228)",
"border-stone-300": "border-color:rgb(214 211 209)",
"border-stone-700": "border-color:rgb(68 64 60)",
"border-red-200": "border-color:rgb(254 202 202)",
"border-red-300": "border-color:rgb(252 165 165)",
"border-rose-200": "border-color:rgb(254 205 211)",
"border-rose-300": "border-color:rgb(253 164 175)",
"border-amber-200": "border-color:rgb(253 230 138)",
"border-amber-300": "border-color:rgb(252 211 77)",
"border-yellow-200": "border-color:rgb(254 240 138)",
"border-green-300": "border-color:rgb(134 239 172)",
"border-emerald-100": "border-color:rgb(209 250 229)",
"border-emerald-200": "border-color:rgb(167 243 208)",
"border-emerald-300": "border-color:rgb(110 231 183)",
"border-emerald-600": "border-color:rgb(5 150 105)",
"border-blue-200": "border-color:rgb(191 219 254)",
"border-blue-300": "border-color:rgb(147 197 253)",
"border-violet-200": "border-color:rgb(221 214 254)",
"border-violet-300": "border-color:rgb(196 181 253)",
"border-violet-400": "border-color:rgb(167 139 250)",
"border-t-white": "border-top-color:rgb(255 255 255)",
"border-t-stone-600": "border-top-color:rgb(87 83 78)",
"border-l-stone-400": "border-left-color:rgb(168 162 158)",
# ── Border Radius ────────────────────────────────────────────────────
"rounded": "border-radius:.25rem",
"rounded-md": "border-radius:.375rem",
"rounded-lg": "border-radius:.5rem",
"rounded-xl": "border-radius:.75rem",
"rounded-2xl": "border-radius:1rem",
"rounded-full": "border-radius:9999px",
"rounded-t": "border-top-left-radius:.25rem;border-top-right-radius:.25rem",
"rounded-b": "border-bottom-left-radius:.25rem;border-bottom-right-radius:.25rem",
"rounded-[4px]": "border-radius:4px",
"rounded-[8px]": "border-radius:8px",
# ── Shadow ───────────────────────────────────────────────────────────
"shadow-sm": "box-shadow:0 1px 2px 0 rgba(0,0,0,.05)",
"shadow": "box-shadow:0 1px 3px 0 rgba(0,0,0,.1),0 1px 2px -1px rgba(0,0,0,.1)",
"shadow-md": "box-shadow:0 4px 6px -1px rgba(0,0,0,.1),0 2px 4px -2px rgba(0,0,0,.1)",
"shadow-lg": "box-shadow:0 10px 15px -3px rgba(0,0,0,.1),0 4px 6px -4px rgba(0,0,0,.1)",
"shadow-xl": "box-shadow:0 20px 25px -5px rgba(0,0,0,.1),0 8px 10px -6px rgba(0,0,0,.1)",
# ── Opacity ──────────────────────────────────────────────────────────
"opacity-0": "opacity:0",
"opacity-40": "opacity:.4",
"opacity-50": "opacity:.5",
"opacity-100": "opacity:1",
# ── Ring / Outline ───────────────────────────────────────────────────
"outline-none": "outline:2px solid transparent;outline-offset:2px",
"ring-2": "box-shadow:0 0 0 2px var(--tw-ring-color,rgb(59 130 246))",
"ring-offset-2": "box-shadow:0 0 0 2px rgb(255 255 255),0 0 0 4px var(--tw-ring-color,rgb(59 130 246))",
# ── Overflow ─────────────────────────────────────────────────────────
"overflow-hidden": "overflow:hidden",
"overflow-x-auto": "overflow-x:auto",
"overflow-y-auto": "overflow-y:auto",
"overscroll-contain": "overscroll-behavior:contain",
# ── Text Decoration ──────────────────────────────────────────────────
"underline": "text-decoration-line:underline",
"line-through": "text-decoration-line:line-through",
"no-underline": "text-decoration-line:none",
# ── Text Overflow ────────────────────────────────────────────────────
"truncate": "overflow:hidden;text-overflow:ellipsis;white-space:nowrap",
"line-clamp-2": "display:-webkit-box;-webkit-line-clamp:2;-webkit-box-orient:vertical;overflow:hidden",
"line-clamp-3": "display:-webkit-box;-webkit-line-clamp:3;-webkit-box-orient:vertical;overflow:hidden",
# ── Whitespace / Word Break ──────────────────────────────────────────
"whitespace-normal": "white-space:normal",
"whitespace-nowrap": "white-space:nowrap",
"whitespace-pre-line": "white-space:pre-line",
"whitespace-pre-wrap": "white-space:pre-wrap",
"break-words": "overflow-wrap:break-word",
"break-all": "word-break:break-all",
# ── Transform ────────────────────────────────────────────────────────
"rotate-180": "transform:rotate(180deg)",
"-translate-x-1/2": "transform:translateX(-50%)",
"-translate-y-1/2": "transform:translateY(-50%)",
# ── Transition ───────────────────────────────────────────────────────
"transition": "transition-property:color,background-color,border-color,text-decoration-color,fill,stroke,opacity,box-shadow,transform,filter,backdrop-filter;transition-timing-function:cubic-bezier(.4,0,.2,1);transition-duration:.15s",
"transition-all": "transition-property:all;transition-timing-function:cubic-bezier(.4,0,.2,1);transition-duration:.15s",
"transition-colors": "transition-property:color,background-color,border-color,text-decoration-color,fill,stroke;transition-timing-function:cubic-bezier(.4,0,.2,1);transition-duration:.15s",
"transition-opacity": "transition-property:opacity;transition-timing-function:cubic-bezier(.4,0,.2,1);transition-duration:.15s",
"transition-transform": "transition-property:transform;transition-timing-function:cubic-bezier(.4,0,.2,1);transition-duration:.15s",
"duration-75": "transition-duration:75ms",
"duration-100": "transition-duration:100ms",
"duration-150": "transition-duration:150ms",
"duration-200": "transition-duration:200ms",
"duration-300": "transition-duration:300ms",
"duration-500": "transition-duration:500ms",
"duration-700": "transition-duration:700ms",
# ── Animation ────────────────────────────────────────────────────────
"animate-spin": "animation:spin 1s linear infinite",
"animate-ping": "animation:ping 1s cubic-bezier(0,0,0.2,1) infinite",
"animate-pulse": "animation:pulse 2s cubic-bezier(0.4,0,0.6,1) infinite",
"animate-bounce": "animation:bounce 1s infinite",
"animate-none": "animation:none",
# ── Aspect Ratio ─────────────────────────────────────────────────────
"aspect-square": "aspect-ratio:1/1",
"aspect-video": "aspect-ratio:16/9",
# ── Object Fit / Position ────────────────────────────────────────────
"object-contain": "object-fit:contain",
"object-cover": "object-fit:cover",
"object-center": "object-position:center",
"object-top": "object-position:top",
# ── Cursor ───────────────────────────────────────────────────────────
"cursor-pointer": "cursor:pointer",
"cursor-move": "cursor:move",
# ── User Select ──────────────────────────────────────────────────────
"select-none": "user-select:none",
"select-all": "user-select:all",
# ── Pointer Events ───────────────────────────────────────────────────
"pointer-events-none": "pointer-events:none",
# ── Resize ───────────────────────────────────────────────────────────
"resize": "resize:both",
"resize-none": "resize:none",
# ── Scroll Snap ──────────────────────────────────────────────────────
"snap-y": "scroll-snap-type:y mandatory",
"snap-start": "scroll-snap-align:start",
"snap-mandatory": "scroll-snap-type:y mandatory",
# ── List Style ───────────────────────────────────────────────────────
"list-disc": "list-style-type:disc",
"list-decimal": "list-style-type:decimal",
"list-inside": "list-style-position:inside",
# ── Table ────────────────────────────────────────────────────────────
"table-fixed": "table-layout:fixed",
# ── Backdrop ─────────────────────────────────────────────────────────
"backdrop-blur": "backdrop-filter:blur(8px)",
"backdrop-blur-sm": "backdrop-filter:blur(4px)",
"backdrop-blur-md": "backdrop-filter:blur(12px)",
# ── Filter ───────────────────────────────────────────────────────────
"saturate-0": "filter:saturate(0)",
# ── Space Between (child selector atoms) ─────────────────────────────
# These generate `.atom > :not(:first-child)` rules
"space-y-0": "margin-top:0",
"space-y-0.5": "margin-top:.125rem",
"space-y-1": "margin-top:.25rem",
"space-y-2": "margin-top:.5rem",
"space-y-3": "margin-top:.75rem",
"space-y-4": "margin-top:1rem",
"space-y-6": "margin-top:1.5rem",
"space-y-8": "margin-top:2rem",
"space-y-10": "margin-top:2.5rem",
"space-x-1": "margin-left:.25rem",
"space-x-2": "margin-left:.5rem",
# ── Divide (child selector atoms) ────────────────────────────────────
# These generate `.atom > :not(:first-child)` rules
"divide-y": "border-top-width:1px",
"divide-stone-100": "border-color:rgb(245 245 244)",
"divide-stone-200": "border-color:rgb(231 229 228)",
# ── Important modifiers ──────────────────────────────────────────────
"!bg-stone-500": "background-color:rgb(120 113 108)!important",
"!text-white": "color:rgb(255 255 255)!important",
}
# Atoms that need a child selector: `.atom > :not(:first-child)` instead of `.atom`
CHILD_SELECTOR_ATOMS: frozenset[str] = frozenset({
k for k in STYLE_ATOMS
if k.startswith(("space-x-", "space-y-", "divide-y", "divide-x"))
and not k.startswith("divide-stone")
})
# ═══════════════════════════════════════════════════════════════════════════
# Pseudo-class / pseudo-element variants
# ═══════════════════════════════════════════════════════════════════════════
PSEUDO_VARIANTS: dict[str, str] = {
"hover": ":hover",
"focus": ":focus",
"focus-within": ":focus-within",
"focus-visible": ":focus-visible",
"active": ":active",
"disabled": ":disabled",
"first": ":first-child",
"last": ":last-child",
"odd": ":nth-child(odd)",
"even": ":nth-child(even)",
"empty": ":empty",
"open": "[open]",
"placeholder": "::placeholder",
"file": "::file-selector-button",
"aria-selected": "[aria-selected=true]",
"group-hover": ":is(.group:hover) &",
"group-open": ":is(.group[open]) &",
}
# ═══════════════════════════════════════════════════════════════════════════
# Responsive breakpoints
# ═══════════════════════════════════════════════════════════════════════════
RESPONSIVE_BREAKPOINTS: dict[str, str] = {
"sm": "(min-width:640px)",
"md": "(min-width:768px)",
"lg": "(min-width:1024px)",
"xl": "(min-width:1280px)",
"2xl": "(min-width:1536px)",
}
# ═══════════════════════════════════════════════════════════════════════════
# Keyframes — built-in animation definitions
# ═══════════════════════════════════════════════════════════════════════════
KEYFRAMES: dict[str, str] = {
"spin": "@keyframes spin{to{transform:rotate(360deg)}}",
"ping": "@keyframes ping{75%,100%{transform:scale(2);opacity:0}}",
"pulse": "@keyframes pulse{50%{opacity:.5}}",
"bounce": "@keyframes bounce{0%,100%{transform:translateY(-25%);animation-timing-function:cubic-bezier(0.8,0,1,1)}50%{transform:none;animation-timing-function:cubic-bezier(0,0,0.2,1)}}",
}
# ═══════════════════════════════════════════════════════════════════════════
# Arbitrary value patterns — fallback when atom not in STYLE_ATOMS
# ═══════════════════════════════════════════════════════════════════════════
#
# Each tuple is (regex_pattern, css_template).
# The regex captures value groups; the template uses {0}, {1}, etc.
ARBITRARY_PATTERNS: list[tuple[str, str]] = [
# Width / Height
(r"w-\[(.+)\]", "width:{0}"),
(r"h-\[(.+)\]", "height:{0}"),
(r"min-w-\[(.+)\]", "min-width:{0}"),
(r"min-h-\[(.+)\]", "min-height:{0}"),
(r"max-w-\[(.+)\]", "max-width:{0}"),
(r"max-h-\[(.+)\]", "max-height:{0}"),
# Spacing
(r"p-\[(.+)\]", "padding:{0}"),
(r"px-\[(.+)\]", "padding-left:{0};padding-right:{0}"),
(r"py-\[(.+)\]", "padding-top:{0};padding-bottom:{0}"),
(r"pt-\[(.+)\]", "padding-top:{0}"),
(r"pb-\[(.+)\]", "padding-bottom:{0}"),
(r"pl-\[(.+)\]", "padding-left:{0}"),
(r"pr-\[(.+)\]", "padding-right:{0}"),
(r"m-\[(.+)\]", "margin:{0}"),
(r"mx-\[(.+)\]", "margin-left:{0};margin-right:{0}"),
(r"my-\[(.+)\]", "margin-top:{0};margin-bottom:{0}"),
(r"mt-\[(.+)\]", "margin-top:{0}"),
(r"mb-\[(.+)\]", "margin-bottom:{0}"),
(r"ml-\[(.+)\]", "margin-left:{0}"),
(r"mr-\[(.+)\]", "margin-right:{0}"),
# Gap
(r"gap-\[(.+)\]", "gap:{0}"),
(r"gap-x-\[(.+)\]", "column-gap:{0}"),
(r"gap-y-\[(.+)\]", "row-gap:{0}"),
# Position
(r"top-\[(.+)\]", "top:{0}"),
(r"right-\[(.+)\]", "right:{0}"),
(r"bottom-\[(.+)\]", "bottom:{0}"),
(r"left-\[(.+)\]", "left:{0}"),
# Border radius
(r"rounded-\[(.+)\]", "border-radius:{0}"),
# Background / Text color
(r"bg-\[(.+)\]", "background-color:{0}"),
(r"text-\[(.+)\]", "font-size:{0}"),
# Grid
(r"grid-cols-\[(.+)\]", "grid-template-columns:{0}"),
(r"col-span-(\d+)", "grid-column:span {0}/span {0}"),
]

254
shared/sx/style_resolver.py Normal file
View File

@@ -0,0 +1,254 @@
"""
Style resolver — ``(css :flex :gap-4 :hover:bg-sky-200)`` → StyleValue.
Resolves a tuple of atom strings into a ``StyleValue`` with:
- A content-addressed class name (``sx-{hash[:6]}``)
- Base CSS declarations
- Pseudo-class rules (hover, focus, etc.)
- Media-query rules (responsive breakpoints)
- Referenced @keyframes definitions
Resolution order per atom:
1. Dictionary lookup in ``STYLE_ATOMS``
2. Arbitrary value pattern match (``w-[347px]`` → ``width:347px``)
3. Ignored (unknown atoms are silently skipped)
Results are memoized by input tuple for zero-cost repeat calls.
"""
from __future__ import annotations
import hashlib
import re
from functools import lru_cache
from typing import Sequence
from .style_dict import (
ARBITRARY_PATTERNS,
CHILD_SELECTOR_ATOMS,
KEYFRAMES,
PSEUDO_VARIANTS,
RESPONSIVE_BREAKPOINTS,
STYLE_ATOMS,
)
from .types import StyleValue
# ---------------------------------------------------------------------------
# Compiled arbitrary-value patterns
# ---------------------------------------------------------------------------
_COMPILED_PATTERNS: list[tuple[re.Pattern, str]] = [
(re.compile(f"^{pat}$"), tmpl)
for pat, tmpl in ARBITRARY_PATTERNS
]
# ---------------------------------------------------------------------------
# Public API
# ---------------------------------------------------------------------------
def resolve_style(atoms: tuple[str, ...]) -> StyleValue:
"""Resolve a tuple of keyword atoms into a StyleValue.
Each atom is a Tailwind-compatible keyword (``flex``, ``gap-4``,
``hover:bg-sky-200``, ``sm:flex-row``, etc.). Both keywords
(without leading colon) and runtime strings are accepted.
"""
return _resolve_cached(atoms)
def merge_styles(styles: Sequence[StyleValue]) -> StyleValue:
"""Merge multiple StyleValues into one.
Later declarations win for the same CSS property. Class name is
recomputed from the merged declarations.
"""
if len(styles) == 1:
return styles[0]
all_decls: list[str] = []
all_media: list[tuple[str, str]] = []
all_pseudo: list[tuple[str, str]] = []
all_kf: list[tuple[str, str]] = []
for sv in styles:
if sv.declarations:
all_decls.append(sv.declarations)
all_media.extend(sv.media_rules)
all_pseudo.extend(sv.pseudo_rules)
all_kf.extend(sv.keyframes)
merged_decls = ";".join(all_decls)
return _build_style_value(
merged_decls,
tuple(all_media),
tuple(all_pseudo),
tuple(dict(all_kf).items()), # dedupe keyframes by name
)
# ---------------------------------------------------------------------------
# Internal resolution
# ---------------------------------------------------------------------------
@lru_cache(maxsize=4096)
def _resolve_cached(atoms: tuple[str, ...]) -> StyleValue:
"""Memoized resolver."""
base_decls: list[str] = []
media_rules: list[tuple[str, str]] = [] # (query, decls)
pseudo_rules: list[tuple[str, str]] = [] # (selector_suffix, decls)
keyframes_needed: list[tuple[str, str]] = []
for atom in atoms:
if not atom:
continue
# Strip leading colon if keyword form (":flex" → "flex")
a = atom.lstrip(":")
# Split variant prefix(es): "hover:bg-sky-200" → ["hover", "bg-sky-200"]
# "sm:hover:bg-sky-200" → ["sm", "hover", "bg-sky-200"]
variant, base = _split_variant(a)
# Resolve the base atom to CSS declarations
decls = _resolve_atom(base)
if not decls:
continue
# Check if this atom references a keyframe
_check_keyframes(base, keyframes_needed)
# Route to the appropriate bucket
if variant is None:
base_decls.append(decls)
elif variant in RESPONSIVE_BREAKPOINTS:
query = RESPONSIVE_BREAKPOINTS[variant]
media_rules.append((query, decls))
elif variant in PSEUDO_VARIANTS:
pseudo_sel = PSEUDO_VARIANTS[variant]
pseudo_rules.append((pseudo_sel, decls))
else:
# Compound variant: "sm:hover:..." → media + pseudo
parts = variant.split(":")
media_part = None
pseudo_part = None
for p in parts:
if p in RESPONSIVE_BREAKPOINTS:
media_part = RESPONSIVE_BREAKPOINTS[p]
elif p in PSEUDO_VARIANTS:
pseudo_part = PSEUDO_VARIANTS[p]
if media_part and pseudo_part:
# Both media and pseudo — store as pseudo within media
# For now, put in pseudo_rules with media annotation
pseudo_rules.append((pseudo_part, decls))
media_rules.append((media_part, decls))
elif media_part:
media_rules.append((media_part, decls))
elif pseudo_part:
pseudo_rules.append((pseudo_part, decls))
else:
# Unknown variant — treat as base
base_decls.append(decls)
return _build_style_value(
";".join(base_decls),
tuple(media_rules),
tuple(pseudo_rules),
tuple(keyframes_needed),
)
def _split_variant(atom: str) -> tuple[str | None, str]:
"""Split a potentially variant-prefixed atom.
Returns (variant, base) where variant is None for non-prefixed atoms.
Examples:
"flex" → (None, "flex")
"hover:bg-sky-200" → ("hover", "bg-sky-200")
"sm:flex-row" → ("sm", "flex-row")
"sm:hover:bg-sky-200" → ("sm:hover", "bg-sky-200")
"""
# Check for responsive prefix first (always outermost)
for bp in RESPONSIVE_BREAKPOINTS:
prefix = bp + ":"
if atom.startswith(prefix):
rest = atom[len(prefix):]
# Check for nested pseudo variant
for pv in PSEUDO_VARIANTS:
inner_prefix = pv + ":"
if rest.startswith(inner_prefix):
return (bp + ":" + pv, rest[len(inner_prefix):])
return (bp, rest)
# Check for pseudo variant
for pv in PSEUDO_VARIANTS:
prefix = pv + ":"
if atom.startswith(prefix):
return (pv, atom[len(prefix):])
return (None, atom)
def _resolve_atom(atom: str) -> str | None:
"""Look up CSS declarations for a single base atom.
Returns None if the atom is unknown.
"""
# 1. Dictionary lookup
decls = STYLE_ATOMS.get(atom)
if decls is not None:
return decls
# 2. Dynamic keyframes: animate-{name} → animation-name:{name}
if atom.startswith("animate-"):
name = atom[len("animate-"):]
if name in KEYFRAMES:
return f"animation-name:{name}"
# 3. Arbitrary value pattern match
for pattern, template in _COMPILED_PATTERNS:
m = pattern.match(atom)
if m:
groups = m.groups()
result = template
for i, g in enumerate(groups):
result = result.replace(f"{{{i}}}", g)
return result
# 4. Unknown atom — silently skip
return None
def _check_keyframes(atom: str, kf_list: list[tuple[str, str]]) -> None:
"""If the atom references a built-in animation, add its @keyframes."""
if atom.startswith("animate-"):
name = atom[len("animate-"):]
if name in KEYFRAMES:
kf_list.append((name, KEYFRAMES[name]))
def _build_style_value(
declarations: str,
media_rules: tuple,
pseudo_rules: tuple,
keyframes: tuple,
) -> StyleValue:
"""Build a StyleValue with a content-addressed class name."""
# Build hash from all rules for deterministic class name
hash_input = declarations
for query, decls in media_rules:
hash_input += f"@{query}{{{decls}}}"
for sel, decls in pseudo_rules:
hash_input += f"{sel}{{{decls}}}"
for name, rule in keyframes:
hash_input += rule
h = hashlib.sha256(hash_input.encode()).hexdigest()[:6]
class_name = f"sx-{h}"
return StyleValue(
class_name=class_name,
declarations=declarations,
media_rules=media_rules,
pseudo_rules=pseudo_rules,
keyframes=keyframes,
)

View File

@@ -1,5 +1,65 @@
;; Shared auth components — login flow, check email
;; Used by account and federation services.
;; Shared auth components — login flow, check email, header rows
;; Used by account, orders, cart, and federation services.
;; ---------------------------------------------------------------------------
;; Auth / orders header rows — DRY extraction from per-service Python
;; ---------------------------------------------------------------------------
;; Auth section nav items (newsletters link + account_nav slot)
(defcomp ~auth-nav-items (&key account-url select-colours account-nav)
(<>
(~nav-link :href (str (or account-url "") "/newsletters/")
:label "newsletters"
:select-colours (or select-colours ""))
(when account-nav account-nav)))
;; Auth header row — wraps ~menu-row-sx for account section
(defcomp ~auth-header-row (&key account-url select-colours account-nav oob)
(~menu-row-sx :id "auth-row" :level 1 :colour "sky"
:link-href (str (or account-url "") "/")
:link-label "account" :icon "fa-solid fa-user"
:nav (~auth-nav-items :account-url account-url
:select-colours select-colours
:account-nav account-nav)
:child-id "auth-header-child" :oob oob))
;; Auth header row without nav (for cart service)
(defcomp ~auth-header-row-simple (&key account-url oob)
(~menu-row-sx :id "auth-row" :level 1 :colour "sky"
:link-href (str (or account-url "") "/")
:link-label "account" :icon "fa-solid fa-user"
:child-id "auth-header-child" :oob oob))
;; Auto-fetching auth header — uses IO primitives, no free variables needed.
;; Expands inline (defmacro) so IO calls resolve in _aser mode.
(defmacro ~auth-header-row-auto (oob)
(quasiquote
(~auth-header-row :account-url (app-url "account" "")
:select-colours (select-colours)
:account-nav (account-nav-ctx)
:oob (unquote oob))))
(defmacro ~auth-header-row-simple-auto (oob)
(quasiquote
(~auth-header-row-simple :account-url (app-url "account" "")
:oob (unquote oob))))
;; Auto-fetching auth nav items — for mobile menus
(defmacro ~auth-nav-items-auto ()
(quasiquote
(~auth-nav-items :account-url (app-url "account" "")
:select-colours (select-colours)
:account-nav (account-nav-ctx))))
;; Orders header row
(defcomp ~orders-header-row (&key list-url)
(~menu-row-sx :id "orders-row" :level 2 :colour "sky"
:link-href list-url :link-label "Orders" :icon "fa fa-gbp"
:child-id "orders-header-child"))
;; ---------------------------------------------------------------------------
;; Auth forms — login flow, check email
;; ---------------------------------------------------------------------------
(defcomp ~auth-error-banner (&key error)
(when error

View File

@@ -83,6 +83,7 @@
(when auth-menu auth-menu))))
; @css bg-sky-400 bg-sky-300 bg-sky-200 bg-sky-100 bg-violet-400 bg-violet-300 bg-violet-200 bg-violet-100
; @css aria-selected:bg-violet-200 aria-selected:text-violet-900 aria-selected:bg-stone-500 aria-selected:text-white
(defcomp ~menu-row-sx (&key id level colour link-href link-label link-label-content icon
selected hx-select nav child-id child oob external)
(let* ((c (or colour "sky"))
@@ -145,6 +146,113 @@
(when auth-menu
(div :class "p-3 border-t border-stone-200" auth-menu))))
;; ---------------------------------------------------------------------------
;; Root header/mobile shorthand — pass-through to shared defcomps.
;; All values must be supplied as &key args (not free variables) because
;; nested component calls in _aser are serialized without expansion.
;; ---------------------------------------------------------------------------
(defcomp ~root-header (&key cart-mini blog-url site-title app-label
nav-tree auth-menu nav-panel settings-url is-admin oob)
(~header-row-sx :cart-mini cart-mini :blog-url blog-url :site-title site-title
:app-label app-label :nav-tree nav-tree :auth-menu auth-menu
:nav-panel nav-panel :settings-url settings-url :is-admin is-admin
:oob oob))
(defcomp ~root-mobile (&key nav-tree auth-menu)
(~mobile-root-nav :nav-tree nav-tree :auth-menu auth-menu))
;; ---------------------------------------------------------------------------
;; Auto-fetching header/mobile macros — use IO primitives to self-populate.
;; These expand inline so IO calls resolve in _aser mode within layout bodies.
;; Replaces the 10-parameter ~root-header boilerplate in layout defcomps.
;; ---------------------------------------------------------------------------
(defmacro ~root-header-auto (oob)
(quasiquote
(let ((__rhctx (root-header-ctx)))
(~header-row-sx :cart-mini (get __rhctx "cart-mini")
:blog-url (get __rhctx "blog-url")
:site-title (get __rhctx "site-title")
:app-label (get __rhctx "app-label")
:nav-tree (get __rhctx "nav-tree")
:auth-menu (get __rhctx "auth-menu")
:nav-panel (get __rhctx "nav-panel")
:settings-url (get __rhctx "settings-url")
:is-admin (get __rhctx "is-admin")
:oob (unquote oob)))))
(defmacro ~root-mobile-auto ()
(quasiquote
(let ((__rhctx (root-header-ctx)))
(~mobile-root-nav :nav-tree (get __rhctx "nav-tree")
:auth-menu (get __rhctx "auth-menu")))))
;; ---------------------------------------------------------------------------
;; Built-in layout defcomps — used by register_sx_layout("root", ...)
;; These use ~root-header-auto / ~root-mobile-auto macros (IO primitives).
;; ---------------------------------------------------------------------------
(defcomp ~layout-root-full ()
(~root-header-auto))
(defcomp ~layout-root-oob ()
(~oob-header-sx :parent-id "root-header-child"
:row (~root-header-auto true)))
(defcomp ~layout-root-mobile ()
(~root-mobile-auto))
;; Post layout — root + post header
(defcomp ~layout-post-full ()
(<> (~root-header-auto)
(~header-child-sx :inner (~post-header-auto))))
(defcomp ~layout-post-oob ()
(<> (~post-header-auto true)
(~oob-header-sx :parent-id "post-header-child" :row "")))
(defcomp ~layout-post-mobile ()
(let ((__phctx (post-header-ctx))
(__rhctx (root-header-ctx)))
(<>
(when (get __phctx "slug")
(~mobile-menu-section
:label (slice (get __phctx "title") 0 40)
:href (get __phctx "link-href")
:level 1
:items (~post-nav-auto)))
(~root-mobile-auto))))
;; Post-admin layout — root + post header with nested admin row
(defcomp ~layout-post-admin-full (&key selected)
(let ((__admin-hdr (~post-admin-header-auto nil selected)))
(<> (~root-header-auto)
(~header-child-sx
:inner (~post-header-auto nil)))))
(defcomp ~layout-post-admin-oob (&key selected)
(<> (~post-header-auto true)
(~oob-header-sx :parent-id "post-header-child"
:row (~post-admin-header-auto nil selected))))
(defcomp ~layout-post-admin-mobile (&key selected)
(let ((__phctx (post-header-ctx)))
(<>
(when (get __phctx "slug")
(~mobile-menu-section
:label "admin"
:href (get __phctx "admin-href")
:level 2
:items (~post-admin-nav-auto selected)))
(when (get __phctx "slug")
(~mobile-menu-section
:label (slice (get __phctx "title") 0 40)
:href (get __phctx "link-href")
:level 1
:items (~post-nav-auto)))
(~root-mobile-auto))))
(defcomp ~error-content (&key errnum message image)
(div :class "text-center p-8 max-w-lg mx-auto"
(div :class "font-bold text-2xl md:text-4xl text-red-500 mb-4" errnum)
@@ -153,6 +261,112 @@
(div :class "flex justify-center"
(img :src image :width "300" :height "300")))))
(defcomp ~clear-oob-div (&key id)
(div :id id :sx-swap-oob "outerHTML"))
;; ---------------------------------------------------------------------------
;; Post-level auto-fetching macros — use (post-header-ctx) IO primitive
;; ---------------------------------------------------------------------------
(defmacro ~post-nav-auto ()
"Post-level nav items: page cart badge + container nav + admin cog."
(quasiquote
(let ((__phctx (post-header-ctx)))
(when (get __phctx "slug")
(<>
(when (> (get __phctx "page-cart-count") 0)
(~page-cart-badge :href (get __phctx "cart-href")
:count (str (get __phctx "page-cart-count"))))
(when (get __phctx "container-nav")
(~container-nav-wrapper :content (get __phctx "container-nav")))
(when (get __phctx "is-admin")
(~admin-cog-button :href (get __phctx "admin-href")
:is-admin-page (get __phctx "is-admin-page"))))))))
(defmacro ~post-header-auto (oob)
"Post-level header row. Reads post data via (post-header-ctx)."
(quasiquote
(let ((__phctx (post-header-ctx)))
(when (get __phctx "slug")
(~menu-row-sx :id "post-row" :level 1
:link-href (get __phctx "link-href")
:link-label-content (~post-label
:feature-image (get __phctx "feature-image")
:title (get __phctx "title"))
:nav (~post-nav-auto)
:child-id "post-header-child"
:oob (unquote oob) :external true)))))
(defmacro ~post-admin-nav-auto (selected)
"Post-admin nav items: calendars, markets, etc."
(quasiquote
(let ((__phctx (post-header-ctx)))
(when (get __phctx "slug")
(let ((__slug (get __phctx "slug"))
(__sc (get __phctx "select-colours")))
(<>
(~nav-link :href (app-url "events" (str "/" __slug "/admin/"))
:label "calendars" :select-colours __sc
:is-selected (when (= (unquote selected) "calendars") "true"))
(~nav-link :href (app-url "market" (str "/" __slug "/admin/"))
:label "markets" :select-colours __sc
:is-selected (when (= (unquote selected) "markets") "true"))
(~nav-link :href (app-url "cart" (str "/" __slug "/admin/payments/"))
:label "payments" :select-colours __sc
:is-selected (when (= (unquote selected) "payments") "true"))
(~nav-link :href (app-url "blog" (str "/" __slug "/admin/entries/"))
:label "entries" :select-colours __sc
:is-selected (when (= (unquote selected) "entries") "true"))
(~nav-link :href (app-url "blog" (str "/" __slug "/admin/data/"))
:label "data" :select-colours __sc
:is-selected (when (= (unquote selected) "data") "true"))
(~nav-link :href (app-url "blog" (str "/" __slug "/admin/preview/"))
:label "preview" :select-colours __sc
:is-selected (when (= (unquote selected) "preview") "true"))
(~nav-link :href (app-url "blog" (str "/" __slug "/admin/edit/"))
:label "edit" :select-colours __sc
:is-selected (when (= (unquote selected) "edit") "true"))
(~nav-link :href (app-url "blog" (str "/" __slug "/admin/settings/"))
:label "settings" :select-colours __sc
:is-selected (when (= (unquote selected) "settings") "true"))))))))
(defmacro ~post-admin-header-auto (oob selected)
"Post-admin header row. Uses (post-header-ctx) for slug + URLs."
(quasiquote
(let ((__phctx (post-header-ctx)))
(when (get __phctx "slug")
(~menu-row-sx :id "post-admin-row" :level 2
:link-href (get __phctx "admin-href")
:link-label-content (~post-admin-label
:selected (unquote selected))
:nav (~post-admin-nav-auto (unquote selected))
:child-id "post-admin-header-child"
:oob (unquote oob))))))
;; ---------------------------------------------------------------------------
;; Shared nav helpers — used by post_header_sx / post_admin_header_sx
;; ---------------------------------------------------------------------------
(defcomp ~container-nav-wrapper (&key content)
(div :id "entries-calendars-nav-wrapper"
:class "flex flex-col sm:flex-row sm:items-center gap-2 border-r border-stone-200 mr-2 sm:max-w-2xl"
content))
; @css justify-center cursor-pointer flex flex-row items-center gap-2 rounded bg-stone-200 text-black p-3 !bg-stone-500 !text-white
(defcomp ~admin-cog-button (&key href is-admin-page)
(div :class "relative nav-group"
(a :href href
:class (str "justify-center cursor-pointer flex flex-row items-center gap-2 rounded bg-stone-200 text-black p-3 "
(if is-admin-page "!bg-stone-500 !text-white" ""))
(i :class "fa fa-cog" :aria-hidden "true"))))
(defcomp ~post-admin-label (&key selected)
(<>
(i :class "fa fa-shield-halved" :aria-hidden "true")
" admin"
(when selected
(span :class "text-white" selected))))
(defcomp ~nav-link (&key href hx-select label icon aclass select-colours is-selected)
(div :class "relative nav-group"
(a :href href

View File

@@ -124,6 +124,155 @@
;; Checkout error screens
;; ---------------------------------------------------------------------------
;; ---------------------------------------------------------------------------
;; Assembled order list content — replaces Python _orders_rows_sx / _orders_main_panel_sx
;; ---------------------------------------------------------------------------
;; Status pill class mapping
(defcomp ~order-status-pill-cls (&key status)
(let* ((sl (lower (or status ""))))
(cond
((= sl "paid") "border-emerald-300 bg-emerald-50 text-emerald-700")
((or (= sl "failed") (= sl "cancelled")) "border-rose-300 bg-rose-50 text-rose-700")
(true "border-stone-300 bg-stone-50 text-stone-700"))))
;; Single order row pair (desktop + mobile) — takes serialized order data dict
(defcomp ~order-row-pair (&key order detail-url-prefix)
(let* ((status (or (get order "status") "pending"))
(pill-base (~order-status-pill-cls :status status))
(oid (str "#" (get order "id")))
(created (or (get order "created_at_formatted") "\u2014"))
(desc (or (get order "description") ""))
(total (str (or (get order "currency") "GBP") " " (or (get order "total_formatted") "0.00")))
(url (str detail-url-prefix (get order "id") "/")))
(<>
(~order-row-desktop
:oid oid :created created :desc desc :total total
:pill (str "inline-flex items-center rounded-full border px-2 py-0.5 text-[11px] sm:text-xs " pill-base)
:status status :url url)
(~order-row-mobile
:oid oid :created created :total total
:pill (str "inline-flex items-center rounded-full border px-2 py-0.5 text-[11px] " pill-base)
:status status :url url))))
;; Assembled orders list content
(defcomp ~orders-list-content (&key orders page total-pages rows-url detail-url-prefix)
(if (empty? orders)
(~order-empty-state)
(~order-table
:rows (<>
(map (lambda (order)
(~order-row-pair :order order :detail-url-prefix detail-url-prefix))
orders)
(if (< page total-pages)
(~infinite-scroll
:url (str rows-url "?page=" (inc page))
:page page :total-pages total-pages
:id-prefix "orders" :colspan 5)
(~order-end-row))))))
;; Assembled order detail content — replaces Python _order_main_sx
(defcomp ~order-detail-content (&key order calendar-entries)
(let* ((items (get order "items")))
(~order-detail-panel
:summary (~order-summary-card
:order-id (get order "id")
:created-at (get order "created_at_formatted")
:description (get order "description")
:status (get order "status")
:currency (get order "currency")
:total-amount (get order "total_formatted"))
:items (when (not (empty? (or items (list))))
(~order-items-panel
:items (map (lambda (item)
(~order-item-row
:href (get item "product_url")
:img (if (get item "product_image")
(~order-item-image :src (get item "product_image")
:alt (or (get item "product_title") "Product image"))
(~order-item-no-image))
:title (or (get item "product_title") "Unknown product")
:pid (str "Product ID: " (get item "product_id"))
:qty (str "Qty: " (get item "quantity"))
:price (str (or (get item "currency") (get order "currency") "GBP") " " (or (get item "unit_price_formatted") "0.00"))))
items)))
:calendar (when (not (empty? (or calendar-entries (list))))
(~order-calendar-section
:items (map (lambda (e)
(let* ((st (or (get e "state") ""))
(pill (cond
((= st "confirmed") "bg-emerald-100 text-emerald-800")
((= st "provisional") "bg-amber-100 text-amber-800")
((= st "ordered") "bg-blue-100 text-blue-800")
(true "bg-stone-100 text-stone-700"))))
(~order-calendar-entry
:name (get e "name")
:pill (str "inline-flex items-center rounded-full px-2 py-0.5 text-[11px] font-medium " pill)
:status (upper (slice st 0 1))
:date-str (get e "date_str")
:cost (str "\u00a3" (or (get e "cost_formatted") "0.00")))))
calendar-entries))))))
;; Assembled order detail filter — replaces Python _order_filter_sx
(defcomp ~order-detail-filter-content (&key order list-url recheck-url pay-url csrf)
(let* ((status (or (get order "status") "pending"))
(created (or (get order "created_at_formatted") "\u2014")))
(~order-detail-filter
:info (str "Placed " created " \u00b7 Status: " status)
:list-url list-url
:recheck-url recheck-url
:csrf csrf
:pay (when (!= status "paid")
(~order-pay-btn :url pay-url)))))
;; ---------------------------------------------------------------------------
;; Checkout return components
;; ---------------------------------------------------------------------------
(defcomp ~checkout-return-header (&key status)
(header :class "mb-6 sm:mb-8"
(h1 :class "text-xl sm:text-2xl md:text-3xl font-semibold tracking-tight" "Payment complete")
(p :class "text-xs sm:text-sm text-stone-600"
(str "Your checkout session is " status "."))))
(defcomp ~checkout-return-missing ()
(div :class "max-w-full px-3 py-3 space-y-4"
(p :class "text-sm text-stone-600" "Order not found.")))
(defcomp ~checkout-return-ticket (&key name pill state type-name date-str code price)
(li :class "px-4 py-3 flex items-start justify-between text-sm"
(div
(div :class "font-medium flex items-center gap-2"
name (span :class pill state))
(when type-name (div :class "text-xs text-stone-500" type-name))
(div :class "text-xs text-stone-500" date-str)
(when code (div :class "font-mono text-xs text-stone-400" code)))
(div :class "ml-4 font-medium" price)))
(defcomp ~checkout-return-tickets (&key items)
(section :class "mt-6 space-y-3"
(h2 :class "text-base sm:text-lg font-semibold" "Tickets")
(ul :class "divide-y divide-stone-200 rounded-2xl border border-stone-200 bg-white/80" items)))
(defcomp ~checkout-return-failed (&key order-id)
(div :class "rounded-lg border border-rose-200 bg-rose-50 p-4 text-sm text-rose-900"
(p :class "font-medium" "Payment failed")
(p "Please try again or contact support."
(when order-id (span " Order #" (str order-id))))))
(defcomp ~checkout-return-paid ()
(div :class "rounded-lg border border-emerald-200 bg-emerald-50 p-4 text-sm text-emerald-900"
(p :class "font-medium" "Payment successful!")
(p "Your order has been confirmed.")))
(defcomp ~checkout-return-content (&key summary items calendar tickets status-message)
(div :class "max-w-full px-3 py-3 space-y-4"
status-message summary items calendar tickets))
;; ---------------------------------------------------------------------------
;; Checkout error screens
;; ---------------------------------------------------------------------------
(defcomp ~checkout-error-header ()
(header :class "mb-6 sm:mb-8"
(h1 :class "text-xl sm:text-2xl md:text-3xl font-semibold tracking-tight" "Checkout error")

View File

@@ -0,0 +1,394 @@
"""Test SxEngine features in sx.js — trigger parsing, param filtering, etc.
Runs pure-logic SxEngine functions through Node.js (no DOM required).
"""
from __future__ import annotations
import json
import subprocess
from pathlib import Path
import pytest
SX_JS = Path(__file__).resolve().parents[2] / "static" / "scripts" / "sx.js"
def _run_engine_js(js_code: str) -> str:
"""Run a JS snippet that has access to SxEngine internals.
We load sx.js with a minimal DOM stub so the IIFE doesn't crash,
then expose internal functions via a test harness.
"""
stub = """
// Minimal DOM stub for SxEngine initialisation
global.document = {
readyState: "complete",
head: { querySelector: function() { return null; } },
body: null,
querySelector: function() { return null; },
querySelectorAll: function() { return []; },
getElementById: function() { return null; },
createElement: function(t) {
return {
tagName: t, attributes: [], childNodes: [],
setAttribute: function() {},
appendChild: function() {},
querySelectorAll: function() { return []; },
};
},
createTextNode: function(t) { return { nodeType: 3, nodeValue: t }; },
createDocumentFragment: function() { return { nodeType: 11, childNodes: [], appendChild: function() {} }; },
addEventListener: function() {},
title: "",
cookie: "",
};
global.window = global;
global.window.addEventListener = function() {};
global.window.matchMedia = function() { return { matches: false }; };
global.window.confirm = function() { return true; };
global.window.prompt = function() { return ""; };
global.window.scrollTo = function() {};
global.requestAnimationFrame = function(fn) { fn(); };
global.setTimeout = global.setTimeout || function(fn) { fn(); };
global.setInterval = global.setInterval || function() {};
global.clearTimeout = global.clearTimeout || function() {};
global.console = { log: function() {}, error: function() {}, warn: function() {} };
global.CSS = { escape: function(s) { return s; } };
global.location = { href: "http://localhost/", hostname: "localhost", origin: "http://localhost", assign: function() {}, reload: function() {} };
global.history = { pushState: function() {}, replaceState: function() {} };
global.fetch = function() { return Promise.resolve({ ok: true, headers: new Map(), text: function() { return Promise.resolve(""); } }); };
global.Headers = function(o) { this._h = o || {}; this.get = function(k) { return this._h[k] || null; }; };
global.URL = function(u, b) { var full = u.indexOf("://") >= 0 ? u : b + u; this.origin = "http://localhost"; this.hostname = "localhost"; };
global.CustomEvent = function(n, o) { this.type = n; this.detail = (o || {}).detail; };
global.AbortController = function() { this.signal = {}; this.abort = function() {}; };
global.URLSearchParams = function(init) {
this._data = [];
if (init) {
if (typeof init.forEach === "function") {
var self = this;
init.forEach(function(v, k) { self._data.push([k, v]); });
}
}
this.append = function(k, v) { this._data.push([k, v]); };
this.delete = function(k) { this._data = this._data.filter(function(p) { return p[0] !== k; }); };
this.getAll = function(k) { return this._data.filter(function(p) { return p[0] === k; }).map(function(p) { return p[1]; }); };
this.toString = function() { return this._data.map(function(p) { return p[0] + "=" + p[1]; }).join("&"); };
this.forEach = function(fn) { this._data.forEach(function(p) { fn(p[1], p[0]); }); };
};
global.FormData = function() { this._data = []; this.forEach = function(fn) { this._data.forEach(function(p) { fn(p[1], p[0]); }); }; };
global.MutationObserver = function() { this.observe = function() {}; this.disconnect = function() {}; };
global.EventSource = function(url) { this.url = url; this.addEventListener = function() {}; this.close = function() {}; };
global.IntersectionObserver = function() { this.observe = function() {}; };
"""
script = f"""
{stub}
{SX_JS.read_text()}
// --- test code ---
{js_code}
"""
result = subprocess.run(
["node", "-e", script],
capture_output=True, text=True, timeout=5,
)
if result.returncode != 0:
pytest.fail(f"Node.js error:\n{result.stderr}")
return result.stdout
# ---------------------------------------------------------------------------
# parseTrigger tests
# ---------------------------------------------------------------------------
class TestParseTrigger:
"""Test the parseTrigger function for various trigger specifications."""
def _parse(self, spec: str) -> list[dict]:
out = _run_engine_js(f"""
// Access parseTrigger via the IIFE's internal scope isn't possible directly,
// but we can test it indirectly. Actually, we need to extract it.
// Since SxEngine is built as an IIFE, we need to re-expose parseTrigger.
// Let's test via a workaround: add a test method.
// Actually, parseTrigger is captured in the closure. Let's hook into process.
// Better approach: just re-parse the function from sx.js source.
// Simplest: duplicate parseTrigger logic for testing (not ideal).
// Best: we patch SxEngine to expose it before the IIFE closes.
// Actually, the simplest approach: the _parseTime and parseTrigger functions
// are inside the SxEngine IIFE. We can test them by examining the behavior
// through the process() function, but that needs DOM.
//
// Instead, let's just eval the same code to test the logic:
var _parseTime = function(s) {{
if (!s) return 0;
if (s.indexOf("ms") >= 0) return parseInt(s, 10);
if (s.indexOf("s") >= 0) return parseFloat(s) * 1000;
return parseInt(s, 10);
}};
var parseTrigger = function(spec) {{
if (!spec) return null;
var triggers = [];
var parts = spec.split(",");
for (var i = 0; i < parts.length; i++) {{
var p = parts[i].trim();
if (!p) continue;
var tokens = p.split(/\\s+/);
if (tokens[0] === "every" && tokens.length >= 2) {{
triggers.push({{ event: "every", modifiers: {{ interval: _parseTime(tokens[1]) }} }});
continue;
}}
var trigger = {{ event: tokens[0], modifiers: {{}} }};
for (var j = 1; j < tokens.length; j++) {{
var tok = tokens[j];
if (tok === "once") trigger.modifiers.once = true;
else if (tok === "changed") trigger.modifiers.changed = true;
else if (tok.indexOf("delay:") === 0) trigger.modifiers.delay = _parseTime(tok.substring(6));
else if (tok.indexOf("from:") === 0) trigger.modifiers.from = tok.substring(5);
}}
triggers.push(trigger);
}}
return triggers;
}};
process.stdout.write(JSON.stringify(parseTrigger({json.dumps(spec)})));
""")
return json.loads(out)
def test_click(self):
result = self._parse("click")
assert len(result) == 1
assert result[0]["event"] == "click"
def test_every_seconds(self):
result = self._parse("every 2s")
assert len(result) == 1
assert result[0]["event"] == "every"
assert result[0]["modifiers"]["interval"] == 2000
def test_every_milliseconds(self):
result = self._parse("every 500ms")
assert len(result) == 1
assert result[0]["event"] == "every"
assert result[0]["modifiers"]["interval"] == 500
def test_delay_modifier(self):
result = self._parse("input changed delay:300ms")
assert result[0]["event"] == "input"
assert result[0]["modifiers"]["changed"] is True
assert result[0]["modifiers"]["delay"] == 300
def test_multiple_triggers(self):
result = self._parse("click, every 5s")
assert len(result) == 2
assert result[0]["event"] == "click"
assert result[1]["event"] == "every"
assert result[1]["modifiers"]["interval"] == 5000
def test_once_modifier(self):
result = self._parse("click once")
assert result[0]["modifiers"]["once"] is True
def test_from_modifier(self):
result = self._parse("keyup from:#search")
assert result[0]["event"] == "keyup"
assert result[0]["modifiers"]["from"] == "#search"
def test_load_trigger(self):
result = self._parse("load")
assert result[0]["event"] == "load"
def test_intersect(self):
result = self._parse("intersect once")
assert result[0]["event"] == "intersect"
assert result[0]["modifiers"]["once"] is True
def test_delay_seconds(self):
result = self._parse("click delay:1s")
assert result[0]["modifiers"]["delay"] == 1000
# ---------------------------------------------------------------------------
# sx-params filtering tests
# ---------------------------------------------------------------------------
class TestParamsFiltering:
"""Test the sx-params parameter filtering logic."""
def _filter(self, params_spec: str, form_data: dict[str, str]) -> dict[str, str]:
fd_entries = json.dumps([[k, v] for k, v in form_data.items()])
out = _run_engine_js(f"""
var body = new URLSearchParams();
var entries = {fd_entries};
entries.forEach(function(p) {{ body.append(p[0], p[1]); }});
var paramsSpec = {json.dumps(params_spec)};
if (paramsSpec === "none") {{
body = new URLSearchParams();
}} else if (paramsSpec.indexOf("not ") === 0) {{
var excluded = paramsSpec.substring(4).split(",").map(function(s) {{ return s.trim(); }});
excluded.forEach(function(k) {{ body.delete(k); }});
}} else if (paramsSpec !== "*") {{
var allowed = paramsSpec.split(",").map(function(s) {{ return s.trim(); }});
var filtered = new URLSearchParams();
allowed.forEach(function(k) {{ body.getAll(k).forEach(function(v) {{ filtered.append(k, v); }}); }});
body = filtered;
}}
var result = {{}};
body.forEach(function(v, k) {{ result[k] = v; }});
process.stdout.write(JSON.stringify(result));
""")
return json.loads(out)
def test_all(self):
result = self._filter("*", {"a": "1", "b": "2"})
assert result == {"a": "1", "b": "2"}
def test_none(self):
result = self._filter("none", {"a": "1", "b": "2"})
assert result == {}
def test_include(self):
result = self._filter("name", {"name": "Alice", "secret": "123"})
assert result == {"name": "Alice"}
def test_include_multiple(self):
result = self._filter("name,email", {"name": "Alice", "email": "a@b.c", "secret": "123"})
assert "name" in result
assert "email" in result
assert "secret" not in result
def test_exclude(self):
result = self._filter("not secret", {"name": "Alice", "secret": "123", "email": "a@b.c"})
assert "name" in result
assert "email" in result
assert "secret" not in result
# ---------------------------------------------------------------------------
# _dispatchTriggerEvents parsing tests
# ---------------------------------------------------------------------------
class TestTriggerEventParsing:
"""Test SX-Trigger header value parsing."""
def _parse_trigger(self, header_val: str) -> list[dict]:
out = _run_engine_js(f"""
var events = [];
// Stub dispatch to capture events
function dispatch(el, name, detail) {{
events.push({{ name: name, detail: detail }});
return true;
}}
function _dispatchTriggerEvents(el, headerVal) {{
if (!headerVal) return;
try {{
var parsed = JSON.parse(headerVal);
if (typeof parsed === "object" && parsed !== null) {{
for (var evtName in parsed) dispatch(el, evtName, parsed[evtName]);
}} else {{
dispatch(el, String(parsed), {{}});
}}
}} catch (e) {{
headerVal.split(",").forEach(function(name) {{
var n = name.trim();
if (n) dispatch(el, n, {{}});
}});
}}
}}
_dispatchTriggerEvents(null, {json.dumps(header_val)});
process.stdout.write(JSON.stringify(events));
""")
return json.loads(out)
def test_plain_string(self):
events = self._parse_trigger("myEvent")
assert len(events) == 1
assert events[0]["name"] == "myEvent"
def test_comma_separated(self):
events = self._parse_trigger("eventA, eventB")
assert len(events) == 2
assert events[0]["name"] == "eventA"
assert events[1]["name"] == "eventB"
def test_json_object(self):
events = self._parse_trigger('{"myEvent": {"key": "val"}}')
assert len(events) == 1
assert events[0]["name"] == "myEvent"
assert events[0]["detail"]["key"] == "val"
def test_json_multiple(self):
events = self._parse_trigger('{"a": {}, "b": {"x": 1}}')
assert len(events) == 2
names = [e["name"] for e in events]
assert "a" in names
assert "b" in names
# ---------------------------------------------------------------------------
# _parseTime tests
# ---------------------------------------------------------------------------
class TestParseTime:
"""Test the time parsing utility."""
def _parse_time(self, s: str) -> int:
out = _run_engine_js(f"""
var _parseTime = function(s) {{
if (!s) return 0;
if (s.indexOf("ms") >= 0) return parseInt(s, 10);
if (s.indexOf("s") >= 0) return parseFloat(s) * 1000;
return parseInt(s, 10);
}};
process.stdout.write(String(_parseTime({json.dumps(s)})));
""")
return int(out)
def test_seconds(self):
assert self._parse_time("2s") == 2000
def test_milliseconds(self):
assert self._parse_time("500ms") == 500
def test_fractional_seconds(self):
assert self._parse_time("1.5s") == 1500
def test_plain_number(self):
assert self._parse_time("100") == 100
def test_empty(self):
assert self._parse_time("") == 0
# ---------------------------------------------------------------------------
# View Transition parsing tests
# ---------------------------------------------------------------------------
class TestSwapParsing:
"""Test sx-swap value parsing with transition modifier."""
def _parse_swap(self, raw_swap: str) -> dict:
out = _run_engine_js(f"""
var rawSwap = {json.dumps(raw_swap)};
var swapParts = rawSwap.split(/\\s+/);
var swapStyle = swapParts[0];
var useTransition = false;
for (var sp = 1; sp < swapParts.length; sp++) {{
if (swapParts[sp] === "transition:true") useTransition = true;
else if (swapParts[sp] === "transition:false") useTransition = false;
}}
process.stdout.write(JSON.stringify({{ style: swapStyle, transition: useTransition }}));
""")
return json.loads(out)
def test_plain_swap(self):
result = self._parse_swap("innerHTML")
assert result["style"] == "innerHTML"
assert result["transition"] is False
def test_transition_true(self):
result = self._parse_swap("innerHTML transition:true")
assert result["style"] == "innerHTML"
assert result["transition"] is True
def test_transition_false(self):
result = self._parse_swap("outerHTML transition:false")
assert result["style"] == "outerHTML"
assert result["transition"] is False

View File

@@ -15,14 +15,16 @@ from shared.sx.html import render as py_render
from shared.sx.evaluator import evaluate
SX_JS = Path(__file__).resolve().parents[2] / "static" / "scripts" / "sx.js"
SX_TEST_JS = Path(__file__).resolve().parents[2] / "static" / "scripts" / "sx-test.js"
def _js_render(sx_text: str, components_text: str = "") -> str:
"""Run sx.js in Node and return the renderToString result."""
"""Run sx.js + sx-test.js in Node and return the renderToString result."""
# Build a small Node script
script = f"""
global.document = undefined; // no DOM needed for string render
{SX_JS.read_text()}
{SX_TEST_JS.read_text()}
if ({json.dumps(components_text)}) Sx.loadComponents({json.dumps(components_text)});
var result = Sx.renderToString({json.dumps(sx_text)});
process.stdout.write(result);

View File

@@ -240,9 +240,71 @@ class PageDef:
return f"<page:{self.name} path={self.path!r}>"
# ---------------------------------------------------------------------------
# QueryDef / ActionDef
# ---------------------------------------------------------------------------
@dataclass
class QueryDef:
"""A declarative data query defined in an .sx file.
Created by ``(defquery name (&key param...) "docstring" body)``.
The body is evaluated with async I/O primitives to produce JSON data.
"""
name: str
params: list[str] # keyword parameter names
doc: str # docstring
body: Any # unevaluated s-expression body
closure: dict[str, Any] = field(default_factory=dict)
def __repr__(self):
return f"<query:{self.name}({', '.join(self.params)})>"
@dataclass
class ActionDef:
"""A declarative action defined in an .sx file.
Created by ``(defaction name (&key param...) "docstring" body)``.
The body is evaluated with async I/O primitives to produce JSON data.
"""
name: str
params: list[str] # keyword parameter names
doc: str # docstring
body: Any # unevaluated s-expression body
closure: dict[str, Any] = field(default_factory=dict)
def __repr__(self):
return f"<action:{self.name}({', '.join(self.params)})>"
# ---------------------------------------------------------------------------
# StyleValue
# ---------------------------------------------------------------------------
@dataclass(frozen=True)
class StyleValue:
"""A resolved CSS style produced by ``(css :flex :gap-4 :hover:bg-sky-200)``.
Generated by the style resolver. The renderer emits ``class_name`` as a
CSS class and registers the CSS rule for on-demand delivery.
"""
class_name: str # "sx-a3f2c1"
declarations: str # "display:flex;gap:1rem"
media_rules: tuple = () # ((query, decls), ...)
pseudo_rules: tuple = () # ((selector, decls), ...)
keyframes: tuple = () # (("spin", "@keyframes spin{...}"), ...)
def __repr__(self):
return f"<StyleValue {self.class_name}>"
def __str__(self):
return self.class_name
# ---------------------------------------------------------------------------
# Type alias
# ---------------------------------------------------------------------------
# An s-expression value after evaluation
SExp = int | float | str | bool | Symbol | Keyword | Lambda | Macro | Component | HandlerDef | RelationDef | PageDef | list | dict | _Nil | None
SExp = int | float | str | bool | Symbol | Keyword | Lambda | Macro | Component | HandlerDef | RelationDef | PageDef | QueryDef | ActionDef | StyleValue | list | dict | _Nil | None