Rebrand sexp → sx across web platform (173 files)

Rename all sexp directories, files, identifiers, and references to sx.
artdag/ excluded (separate media processing DSL).

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-03-01 11:06:57 +00:00
parent 17cebe07e7
commit e8bc228c7f
174 changed files with 3126 additions and 2952 deletions

66
shared/sx/__init__.py Normal file
View File

@@ -0,0 +1,66 @@
"""
S-expression language core.
Parse, evaluate, and serialize s-expressions. This package provides the
foundation for the composable fragment architecture described in
``docs/sx-architecture-plan.md``.
Quick start::
from shared.sx import parse, evaluate, serialize, Symbol, Keyword
expr = parse('(let ((x 10)) (+ x 1))')
result = evaluate(expr) # → 11
expr2 = parse('(map (fn (n) (* n n)) (list 1 2 3))')
result2 = evaluate(expr2) # → [1, 4, 9]
"""
from .types import (
NIL,
Component,
Keyword,
Lambda,
Symbol,
)
from .parser import (
ParseError,
parse,
parse_all,
serialize,
)
from .evaluator import (
EvalError,
evaluate,
make_env,
)
from .primitives import (
all_primitives,
get_primitive,
register_primitive,
)
from .env import Env
__all__ = [
# Types
"Symbol",
"Keyword",
"Lambda",
"Component",
"NIL",
# Parser
"parse",
"parse_all",
"serialize",
"ParseError",
# Evaluator
"evaluate",
"make_env",
"EvalError",
# Primitives
"register_primitive",
"get_primitive",
"all_primitives",
# Environment
"Env",
]

19
shared/sx/components.py Normal file
View File

@@ -0,0 +1,19 @@
"""
Shared s-expression component definitions.
Loaded at app startup via ``load_shared_components()``. Each component
is defined in an external ``.sx`` file under ``templates/``.
"""
from __future__ import annotations
import os
from .jinja_bridge import load_sx_dir, watch_sx_dir
def load_shared_components() -> None:
"""Register all shared s-expression components."""
templates_dir = os.path.join(os.path.dirname(__file__), "templates")
load_sx_dir(templates_dir)
watch_sx_dir(templates_dir)

97
shared/sx/env.py Normal file
View File

@@ -0,0 +1,97 @@
"""
Lexical environment for s-expression evaluation.
Environments form a parent chain so inner scopes shadow outer ones
while still allowing lookup of free variables.
"""
from __future__ import annotations
from typing import Any
class Env:
"""A lexical scope mapping names → values with an optional parent."""
__slots__ = ("_bindings", "_parent")
def __init__(
self,
bindings: dict[str, Any] | None = None,
parent: Env | None = None,
):
self._bindings: dict[str, Any] = bindings or {}
self._parent = parent
# -- lookup -------------------------------------------------------------
def lookup(self, name: str) -> Any:
"""Resolve *name*, walking the parent chain.
Raises ``KeyError`` if not found.
"""
if name in self._bindings:
return self._bindings[name]
if self._parent is not None:
return self._parent.lookup(name)
raise KeyError(name)
def __contains__(self, name: str) -> bool:
if name in self._bindings:
return True
if self._parent is not None:
return name in self._parent
return False
def __getitem__(self, name: str) -> Any:
return self.lookup(name)
def get(self, name: str, default: Any = None) -> Any:
try:
return self.lookup(name)
except KeyError:
return default
# -- mutation -----------------------------------------------------------
def define(self, name: str, value: Any) -> None:
"""Bind *name* in the **current** scope."""
self._bindings[name] = value
def set(self, name: str, value: Any) -> None:
"""Update *name* in the **nearest enclosing** scope that contains it.
Raises ``KeyError`` if the name is not bound anywhere.
"""
if name in self._bindings:
self._bindings[name] = value
elif self._parent is not None:
self._parent.set(name, value)
else:
raise KeyError(f"Cannot set! undefined variable: {name}")
# -- construction -------------------------------------------------------
def extend(self, bindings: dict[str, Any] | None = None) -> Env:
"""Return a child environment."""
return Env(bindings or {}, parent=self)
# -- conversion ---------------------------------------------------------
def to_dict(self) -> dict[str, Any]:
"""Flatten the full chain into a single dict (parent first)."""
if self._parent is not None:
d = self._parent.to_dict()
else:
d = {}
d.update(self._bindings)
return d
def __repr__(self) -> str:
keys = list(self._bindings.keys())
depth = 0
p = self._parent
while p:
depth += 1
p = p._parent
return f"<Env depth={depth} keys={keys}>"

620
shared/sx/evaluator.py Normal file
View File

@@ -0,0 +1,620 @@
"""
S-expression evaluator.
Walks a parsed s-expression tree and evaluates it in an environment.
Special forms:
(if cond then else?)
(when cond body)
(cond clause...) — Scheme-style ((test body)...) or Clojure-style (test body...)
(case expr val body... :else default)
(and expr...) (or expr...)
(let ((name val)...) body) or (let (name val name val...) body)
(lambda (params...) body) or (fn (params...) body)
(define name value)
(defcomp ~name (&key param...) body)
(defrelation :name :from "type" :to "type" :cardinality :card ...)
(begin expr...)
(quote expr)
(do expr...) — alias for begin
(-> val form...) — thread-first macro
Higher-order forms (operate on lambdas):
(map fn coll)
(map-indexed fn coll)
(filter fn coll)
(reduce fn init coll)
(some fn coll)
(every? fn coll)
(for-each fn coll)
"""
from __future__ import annotations
from typing import Any
from .types import Component, Keyword, Lambda, NIL, RelationDef, Symbol
from .primitives import _PRIMITIVES
class EvalError(Exception):
"""Error during expression evaluation."""
pass
# ---------------------------------------------------------------------------
# Public API
# ---------------------------------------------------------------------------
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)
def make_env(**kwargs: Any) -> dict[str, Any]:
"""Convenience: create an environment dict with initial bindings."""
return dict(kwargs)
# ---------------------------------------------------------------------------
# Internal evaluator
# ---------------------------------------------------------------------------
def _eval(expr: Any, env: dict[str, Any]) -> Any:
# --- literals ---------------------------------------------------------
if isinstance(expr, (int, float, str, bool)):
return expr
if expr is None or expr is NIL:
return NIL
# --- symbol lookup ----------------------------------------------------
if isinstance(expr, Symbol):
name = expr.name
if name in env:
return env[name]
if name in _PRIMITIVES:
return _PRIMITIVES[name]
if name == "true":
return True
if name == "false":
return False
if name == "nil":
return NIL
raise EvalError(f"Undefined symbol: {name}")
# --- keyword → its string name ----------------------------------------
if isinstance(expr, Keyword):
return expr.name
# --- dict literal -----------------------------------------------------
if isinstance(expr, dict):
return {k: _eval(v, env) for k, v in expr.items()}
# --- list = call or special form --------------------------------------
if not isinstance(expr, list):
return expr
if not expr:
return []
head = expr[0]
# 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]
# --- special forms ----------------------------------------------------
if isinstance(head, Symbol):
name = head.name
handler = _SPECIAL_FORMS.get(name)
if handler is not None:
return handler(expr, env)
# Higher-order forms (need lazy eval of lambda arg)
ho = _HO_FORMS.get(name)
if ho is not None:
return ho(expr, env)
# --- function / lambda call -------------------------------------------
fn = _eval(head, env)
args = [_eval(a, env) for a in expr[1:]]
if callable(fn) and not isinstance(fn, (Lambda, Component)):
return fn(*args)
if isinstance(fn, Lambda):
return _call_lambda(fn, args, env)
if isinstance(fn, Component):
return _call_component(fn, expr[1:], env)
raise EvalError(f"Not callable: {fn!r}")
# ---------------------------------------------------------------------------
# Lambda / component invocation
# ---------------------------------------------------------------------------
def _call_lambda(fn: Lambda, args: list[Any], caller_env: dict[str, Any]) -> Any:
if len(args) != len(fn.params):
raise EvalError(f"{fn!r} expects {len(fn.params)} args, got {len(args)}")
local = dict(fn.closure)
local.update(caller_env)
for p, v in zip(fn.params, args):
local[p] = v
return _eval(fn.body, local)
def _call_component(comp: Component, raw_args: list[Any], env: dict[str, Any]) -> Any:
"""Evaluate a component invocation with keyword arguments.
``(~card :title "Hello" (p "child"))``
→ comp.params gets ``title="Hello"``, comp children gets ``[(p "child")]``
"""
kwargs: dict[str, Any] = {}
children: list[Any] = []
i = 0
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)
i += 2
else:
children.append(_eval(arg, env))
i += 1
local = dict(comp.closure)
local.update(env)
for p in comp.params:
if p in kwargs:
local[p] = kwargs[p]
else:
local[p] = NIL
if comp.has_children:
local["children"] = children
return _eval(comp.body, local)
# ---------------------------------------------------------------------------
# Special forms
# ---------------------------------------------------------------------------
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)
if cond and cond is not NIL:
return _eval(expr[2], env)
if len(expr) > 3:
return _eval(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)
if cond and cond is not NIL:
result = NIL
for body_expr in expr[2:]:
result = _eval(body_expr, env)
return result
return NIL
def _sf_cond(expr: list, env: dict) -> Any:
clauses = expr[1:]
if not clauses:
return NIL
# Detect scheme-style: first clause is a 2-element list that isn't a comparison
if (
isinstance(clauses[0], list)
and len(clauses[0]) == 2
and not (isinstance(clauses[0][0], Symbol) and clauses[0][0].name in (
"=", "<", ">", "<=", ">=", "!=", "and", "or",
))
):
for clause in clauses:
if not isinstance(clause, list) or len(clause) < 2:
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)
if isinstance(test, Keyword) and test.name == "else":
return _eval(clause[1], env)
if _eval(test, env):
return _eval(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)
if isinstance(test, Symbol) and test.name in (":else", "else"):
return _eval(result, env)
if _eval(test, env):
return _eval(result, env)
i += 2
return NIL
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)
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)
if isinstance(test, Symbol) and test.name in (":else", "else"):
return _eval(result, env)
if match_val == _eval(test, env):
return _eval(result, env)
i += 2
return NIL
def _sf_and(expr: list, env: dict) -> Any:
result: Any = True
for arg in expr[1:]:
result = _eval(arg, env)
if not result:
return result
return result
def _sf_or(expr: list, env: dict) -> Any:
result: Any = False
for arg in expr[1:]:
result = _eval(arg, env)
if result:
return result
return result
def _sf_let(expr: list, env: dict) -> Any:
if len(expr) < 3:
raise EvalError("let requires bindings and body")
bindings = expr[1]
local = dict(env)
if isinstance(bindings, list):
if bindings and isinstance(bindings[0], list):
# Scheme-style: ((name val) ...)
for binding in bindings:
if len(binding) != 2:
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)
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)
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
def _sf_lambda(expr: list, env: dict) -> Lambda:
if len(expr) < 3:
raise EvalError("lambda requires params and body")
params_expr = expr[1]
if not isinstance(params_expr, list):
raise EvalError("lambda params must be a list")
param_names = []
for p in params_expr:
if isinstance(p, Symbol):
param_names.append(p.name)
elif isinstance(p, str):
param_names.append(p)
else:
raise EvalError(f"Invalid lambda param: {p}")
return Lambda(param_names, expr[2], dict(env))
def _sf_define(expr: list, env: dict) -> Any:
if len(expr) < 3:
raise EvalError("define requires name and value")
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)
if isinstance(value, Lambda) and value.name is None:
value.name = name_sym.name
env[name_sym.name] = value
return value
def _sf_defcomp(expr: list, env: dict) -> Component:
"""``(defcomp ~name (&key param1 param2 &rest children) body)``"""
if len(expr) < 4:
raise EvalError("defcomp requires name, params, and body")
name_sym = expr[1]
if not isinstance(name_sym, Symbol):
raise EvalError(f"defcomp name must be symbol, got {type(name_sym).__name__}")
comp_name = name_sym.name.lstrip("~")
params_expr = expr[2]
if not isinstance(params_expr, list):
raise EvalError("defcomp params must be a list")
params: list[str] = []
has_children = False
in_key = False
for p in params_expr:
if isinstance(p, Symbol):
if p.name == "&key":
in_key = True
continue
if p.name == "&rest":
has_children = True
continue
if in_key or has_children:
if not has_children:
params.append(p.name)
else:
params.append(p.name)
# Skip children param name after &rest
elif isinstance(p, str):
params.append(p)
comp = Component(
name=comp_name,
params=params,
has_children=has_children,
body=expr[3],
closure=dict(env),
)
env[name_sym.name] = comp
return comp
def _sf_begin(expr: list, env: dict) -> Any:
result: Any = NIL
for sub in expr[1:]:
result = _eval(sub, env)
return result
def _sf_quote(expr: list, _env: dict) -> Any:
return expr[1] if len(expr) > 1 else NIL
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)
for form in expr[2:]:
if isinstance(form, list):
fn = _eval(form[0], env)
args = [result] + [_eval(a, env) for a in form[1:]]
else:
fn = _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)
else:
raise EvalError(f"-> form not callable: {fn!r}")
return result
def _sf_set_bang(expr: list, env: dict) -> Any:
"""``(set! name value)`` — mutate existing binding."""
if len(expr) != 3:
raise EvalError("set! requires name and value")
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)
# Walk up scope if using Env objects; for plain dicts just overwrite
env[name_sym.name] = value
return value
_VALID_CARDINALITIES = {"one-to-one", "one-to-many", "many-to-many"}
_VALID_NAV = {"submenu", "tab", "badge", "inline", "hidden"}
def _sf_defrelation(expr: list, env: dict) -> RelationDef:
"""``(defrelation :name :from "t" :to "t" :cardinality :card ...)``"""
if len(expr) < 2:
raise EvalError("defrelation requires a name")
name_kw = expr[1]
if not isinstance(name_kw, Keyword):
raise EvalError(f"defrelation name must be a keyword, got {type(name_kw).__name__}")
rel_name = name_kw.name
# Parse keyword args from remaining elements
kwargs: dict[str, str | None] = {}
i = 2
while i < len(expr):
key = expr[i]
if isinstance(key, Keyword):
if i + 1 < len(expr):
val = expr[i + 1]
if isinstance(val, Keyword):
kwargs[key.name] = val.name
else:
kwargs[key.name] = _eval(val, env) if not isinstance(val, str) else val
i += 2
else:
kwargs[key.name] = None
i += 1
else:
i += 1
for field in ("from", "to", "cardinality"):
if field not in kwargs:
raise EvalError(f"defrelation {rel_name} missing required :{field}")
card = kwargs["cardinality"]
if card not in _VALID_CARDINALITIES:
raise EvalError(
f"defrelation {rel_name}: invalid cardinality {card!r}, "
f"expected one of {_VALID_CARDINALITIES}"
)
nav = kwargs.get("nav", "hidden")
if nav not in _VALID_NAV:
raise EvalError(
f"defrelation {rel_name}: invalid nav {nav!r}, "
f"expected one of {_VALID_NAV}"
)
defn = RelationDef(
name=rel_name,
from_type=kwargs["from"],
to_type=kwargs["to"],
cardinality=card,
inverse=kwargs.get("inverse"),
nav=nav,
nav_icon=kwargs.get("nav-icon"),
nav_label=kwargs.get("nav-label"),
)
from .relations import register_relation
register_relation(defn)
env[f"relation:{rel_name}"] = defn
return defn
_SPECIAL_FORMS: dict[str, Any] = {
"if": _sf_if,
"when": _sf_when,
"cond": _sf_cond,
"case": _sf_case,
"and": _sf_and,
"or": _sf_or,
"let": _sf_let,
"let*": _sf_let,
"lambda": _sf_lambda,
"fn": _sf_lambda,
"define": _sf_define,
"defcomp": _sf_defcomp,
"defrelation": _sf_defrelation,
"begin": _sf_begin,
"do": _sf_begin,
"quote": _sf_quote,
"->": _sf_thread_first,
"set!": _sf_set_bang,
}
# ---------------------------------------------------------------------------
# Higher-order forms (need to evaluate the fn arg first)
# ---------------------------------------------------------------------------
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)
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]
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)
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)]
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)
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)]
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)
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)
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)
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)
if result:
return result
return NIL
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)
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):
return False
return True
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)
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)
return NIL
_HO_FORMS: dict[str, Any] = {
"map": _ho_map,
"map-indexed": _ho_map_indexed,
"filter": _ho_filter,
"reduce": _ho_reduce,
"some": _ho_some,
"every?": _ho_every,
"for-each": _ho_for_each,
}

458
shared/sx/helpers.py Normal file
View File

@@ -0,0 +1,458 @@
"""
Shared helper functions for s-expression page rendering.
These are used by per-service sx_components.py files to build common
page elements (headers, search, etc.) from template context.
"""
from __future__ import annotations
from typing import Any
from markupsafe import escape
from .page import SEARCH_HEADERS_MOBILE, SEARCH_HEADERS_DESKTOP
from .parser import SxExpr
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)
if callable(fn):
return fn(path)
return str(fn or "") + path
def get_asset_url(ctx: dict) -> str:
"""Extract the asset URL base from context."""
au = ctx.get("asset_url")
if callable(au):
result = au("")
return result.rsplit("/", 1)[0] if "/" in result else result
return au or ""
# ---------------------------------------------------------------------------
# Sx-native helper functions — return sx source (not HTML)
# ---------------------------------------------------------------------------
def _as_sx(val: Any) -> SxExpr | None:
"""Coerce a fragment value to SxExpr.
If *val* is already a ``SxExpr`` (from a ``text/sx`` fragment),
return it as-is. If it's a non-empty string (HTML from a
``text/html`` fragment), wrap it in ``~rich-text``. Otherwise
return ``None``.
"""
if not val:
return None
if isinstance(val, SxExpr):
return val
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."""
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",
cart_mini=_as_sx(ctx.get("cart_mini")),
blog_url=call_url(ctx, "blog_url", ""),
site_title=ctx.get("base_title", ""),
app_label=ctx.get("app_label", ""),
nav_tree=_as_sx(ctx.get("nav_tree")),
auth_menu=_as_sx(ctx.get("auth_menu")),
nav_panel=_as_sx(ctx.get("nav_panel")),
settings_url=settings_url,
is_admin=is_admin,
oob=oob,
)
def search_mobile_sx(ctx: dict) -> str:
"""Build mobile search input as sx call string."""
return sx_call("search-mobile",
current_local_href=ctx.get("current_local_href", "/"),
search=ctx.get("search", ""),
search_count=ctx.get("search_count", ""),
hx_select=ctx.get("hx_select", "#main-panel"),
search_headers_mobile=SEARCH_HEADERS_MOBILE,
)
def search_desktop_sx(ctx: dict) -> str:
"""Build desktop search input as sx call string."""
return sx_call("search-desktop",
current_local_href=ctx.get("current_local_href", "/"),
search=ctx.get("search", ""),
search_count=ctx.get("search_count", ""),
hx_select=ctx.get("hx_select", "#main-panel"),
search_headers_desktop=SEARCH_HEADERS_DESKTOP,
)
def post_header_sx(ctx: dict, *, oob: bool = False) -> str:
"""Build the post-level header row as sx call string."""
post = ctx.get("post") or {}
slug = post.get("slug", "")
if not slug:
return ""
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_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}/")
nav_parts.append(sx_call("page-cart-badge", href=cart_href, count=str(page_cart_count)))
container_nav = ctx.get("container_nav")
if container_nav:
nav_parts.append(
f'(div :id "entries-calendars-nav-wrapper"'
f' :class "flex flex-col sm:flex-row sm:items-center gap-2 border-r border-stone-200 mr-2 sm:max-w-2xl"'
f' {container_nav})'
)
# Admin cog
admin_nav = ctx.get("post_admin_nav")
if not admin_nav:
rights = ctx.get("rights") or {}
has_admin = rights.get("admin") if isinstance(rights, dict) else getattr(rights, "admin", False)
if has_admin and slug:
from quart import request
admin_href = call_url(ctx, "blog_url", f"/{slug}/admin/")
is_admin_page = "/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")))'
)
if admin_nav:
nav_parts.append(admin_nav)
nav_sx = "(<> " + " ".join(nav_parts) + ")" if nav_parts else None
link_href = call_url(ctx, "blog_url", f"/{slug}/")
return sx_call("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,
child_id="post-header-child",
oob=oob, external=True,
)
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."""
# 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) + ")"
# Nav items
select_colours = ctx.get("select_colours", "")
base_cls = ("justify-center cursor-pointer flex flex-row items-center"
" gap-2 rounded bg-stone-200 text-black p-3")
selected_cls = ("justify-center cursor-pointer flex flex-row items-center"
" gap-2 rounded !bg-stone-500 !text-white p-3")
nav_parts: list[str] = []
items = [
("events_url", f"/{slug}/admin/", "calendars"),
("market_url", f"/{slug}/admin/", "markets"),
("cart_url", f"/{slug}/admin/payments/", "payments"),
("blog_url", f"/{slug}/admin/entries/", "entries"),
("blog_url", f"/{slug}/admin/data/", "data"),
("blog_url", f"/{slug}/admin/edit/", "edit"),
("blog_url", f"/{slug}/admin/settings/", "settings"),
]
for url_key, path, label in items:
url_fn = ctx.get(url_key)
if not callable(url_fn):
continue
href = url_fn(path)
is_sel = label == selected
cls = selected_cls if is_sel else base_cls
aria = "true" if is_sel else None
nav_parts.append(
f'(div :class "relative nav-group"'
f' (a :href "{escape(href)}"'
+ (f' :aria-selected "true"' if aria else "")
+ f' :class "{cls} {escape(select_colours)}"'
+ f' "{escape(label)}"))'
)
nav_sx = "(<> " + " ".join(nav_parts) + ")" if nav_parts else 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",
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,
child_id="post-admin-header-child", oob=oob,
)
def oob_header_sx(parent_id: str, child_id: str, row_sx: str) -> str:
"""Wrap a header row sx in an OOB swap."""
return sx_call("oob-header-sx",
parent_id=parent_id, child_id=child_id,
row=SxExpr(row_sx),
)
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",
id=id, inner=SxExpr(inner_sx),
)
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",
oobs=SxExpr(oobs) if oobs else None,
filter=SxExpr(filter) if filter else None,
aside=SxExpr(aside) if aside else None,
menu=SxExpr(menu) if menu else None,
content=SxExpr(content) if content else None,
)
def full_page_sx(ctx: dict, *, header_rows: str,
filter: str = "", aside: str = "",
content: str = "", menu: str = "",
meta_html: str = "", meta: str = "") -> str:
"""Build a full page using sx_page() with ~app-body.
meta_html: raw HTML injected into the <head> shell (legacy).
meta: sx source for meta tags — auto-hoisted to <head> by sx.js.
"""
body_sx = sx_call("app-body",
header_rows=SxExpr(header_rows) if header_rows else None,
filter=SxExpr(filter) if filter else None,
aside=SxExpr(aside) if aside else None,
menu=SxExpr(menu) if menu else None,
content=SxExpr(content) if content else None,
)
if meta:
# Wrap body + meta in a fragment so sx.js renders both;
# auto-hoist moves meta/title/link elements to <head>.
body_sx = "(<> " + meta + " " + body_sx + ")"
return sx_page(ctx, body_sx, meta_html=meta_html)
def sx_call(component_name: str, **kwargs: Any) -> str:
"""Build an s-expression component call string from Python kwargs.
Converts snake_case to kebab-case automatically::
sx_call("test-row", nodeid="foo", outcome="passed")
# => '(~test-row :nodeid "foo" :outcome "passed")'
Values are serialized: strings are quoted, None becomes nil,
bools become true/false, numbers stay as-is.
"""
from .parser import serialize
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) + ")"
def components_for_request() -> str:
"""Return defcomp source for components the client doesn't have yet.
Reads the ``SX-Components`` header (comma-separated component names
like ``~card,~nav-item``) and returns only the definitions the client
is missing. If the header is absent, returns all component defs.
"""
from quart import request
from .jinja_bridge import client_components_tag, _COMPONENT_ENV
from .types import Component
from .parser import serialize
loaded_raw = request.headers.get("SX-Components", "")
if not loaded_raw:
# Client has nothing — send all
tag = client_components_tag()
if not tag:
return ""
start = tag.find(">") + 1
end = tag.rfind("</script>")
return tag[start:end] if start > 0 and end > start else ""
loaded = set(loaded_raw.split(","))
parts = []
for key, val in _COMPONENT_ENV.items():
if not isinstance(val, Component):
continue
# Skip components the client already has
if f"~{val.name}" in loaded or val.name in loaded:
continue
# Reconstruct defcomp source
param_strs = ["&key"] + list(val.params)
if val.has_children:
param_strs.extend(["&rest", "children"])
params_sx = "(" + " ".join(param_strs) + ")"
body_sx = serialize(val.body, pretty=True)
parts.append(f"(defcomp ~{val.name} {params_sx} {body_sx})")
return "\n".join(parts)
def sx_response(source_or_component: str, status: int = 200,
headers: dict | None = None, **kwargs: Any):
"""Return an s-expression wire-format response.
Can be called with 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
# For SX requests, prepend missing component definitions
if request.headers.get("SX-Request"):
comp_defs = components_for_request()
if comp_defs:
body = (f'<script type="text/sx" data-components>'
f'{comp_defs}</script>\n{body}')
resp = Response(body, status=status, content_type="text/sx")
if headers:
for k, v in headers.items():
resp.headers[k] = v
return resp
# ---------------------------------------------------------------------------
# Sx wire-format full page shell
# ---------------------------------------------------------------------------
_SX_PAGE_TEMPLATE = """\
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<meta name="robots" content="index,follow">
<meta name="theme-color" content="#ffffff">
<title>{title}</title>
{meta_html}
<style>@media (min-width: 768px) {{ .js-mobile-sentinel {{ display:none !important; }} }}</style>
<link rel="stylesheet" type="text/css" href="{asset_url}/styles/basics.css">
<link rel="stylesheet" type="text/css" href="{asset_url}/styles/cards.css">
<link rel="stylesheet" type="text/css" href="{asset_url}/styles/blog-content.css">
<meta name="csrf-token" content="{csrf}">
<script src="https://cdn.tailwindcss.com"></script>
<link rel="stylesheet" href="{asset_url}/fontawesome/css/all.min.css">
<link rel="stylesheet" href="{asset_url}/fontawesome/css/v4-shims.min.css">
<link href="https://unpkg.com/prismjs/themes/prism.css" rel="stylesheet">
<script src="https://unpkg.com/prismjs/prism.js"></script>
<script src="https://unpkg.com/prismjs/components/prism-javascript.min.js"></script>
<script src="https://unpkg.com/prismjs/components/prism-python.min.js"></script>
<script src="https://unpkg.com/prismjs/components/prism-bash.min.js"></script>
<script src="https://cdn.jsdelivr.net/npm/sweetalert2@11"></script>
<script>if(matchMedia('(hover:hover) and (pointer:fine)').matches){{document.documentElement.classList.add('hover-capable')}}</script>
<script>document.addEventListener('click',function(e){{var t=e.target.closest('[data-close-details]');if(!t)return;var d=t.closest('details');if(d)d.removeAttribute('open')}})</script>
<style>
details[data-toggle-group="mobile-panels"]>summary{{list-style:none}}
details[data-toggle-group="mobile-panels"]>summary::-webkit-details-marker{{display:none}}
@media(min-width:768px){{.nav-group:focus-within .submenu,.nav-group:hover .submenu{{display:block}}}}
img{{max-width:100%;height:auto}}
.clamp-2{{display:-webkit-box;-webkit-line-clamp:2;-webkit-box-orient:vertical;overflow:hidden}}
.clamp-3{{display:-webkit-box;-webkit-line-clamp:3;-webkit-box-orient:vertical;overflow:hidden}}
.no-scrollbar::-webkit-scrollbar{{display:none}}.no-scrollbar{{-ms-overflow-style:none;scrollbar-width:none}}
details.group{{overflow:hidden}}details.group>summary{{list-style:none}}details.group>summary::-webkit-details-marker{{display:none}}
.sx-indicator{{display:none}}.sx-request .sx-indicator{{display:inline-flex}}
.sx-error .sx-indicator{{display:none}}.sx-loading .sx-indicator{{display:inline-flex}}
.js-wrap.open .js-pop{{display:block}}.js-wrap.open .js-backdrop{{display:block}}
</style>
</head>
<body class="bg-stone-50 text-stone-900">
<script type="text/sx" data-components>{component_defs}</script>
<script type="text/sx" data-mount="body">{page_sx}</script>
<script src="{asset_url}/scripts/sx.js"></script>
<script src="{asset_url}/scripts/body.js"></script>
</body>
</html>"""
def sx_page(ctx: dict, page_sx: str, *,
meta_html: str = "") -> str:
"""Return a minimal HTML shell that boots the page from sx source.
The browser loads component definitions and page sx, then sx.js
renders everything client-side.
"""
from .jinja_bridge import client_components_tag
components_tag = client_components_tag()
# Extract just the inner source from the <script> tag
component_defs = ""
if components_tag:
# Strip <script type="text/sx" data-components>...</script>
start = components_tag.find(">") + 1
end = components_tag.rfind("</script>")
if start > 0 and end > start:
component_defs = components_tag[start:end]
asset_url = get_asset_url(ctx)
title = ctx.get("base_title", "Rose Ash")
csrf = _get_csrf_token()
return _SX_PAGE_TEMPLATE.format(
title=_html_escape(title),
asset_url=asset_url,
meta_html=meta_html,
csrf=_html_escape(csrf),
component_defs=component_defs,
page_sx=page_sx,
)
def _get_csrf_token() -> str:
"""Get the CSRF token from the current request context."""
try:
from quart import g
return getattr(g, "csrf_token", "")
except Exception:
return ""
def _html_escape(s: str) -> str:
"""Minimal HTML escaping for attribute values."""
return (s.replace("&", "&amp;")
.replace("<", "&lt;")
.replace(">", "&gt;")
.replace('"', "&quot;"))

481
shared/sx/html.py Normal file
View File

@@ -0,0 +1,481 @@
"""
HSX-style HTML renderer.
Walks an s-expression tree and emits an HTML string. HTML elements are
recognised by tag name; everything else is evaluated via the s-expression
evaluator and then rendered recursively.
Usage::
from shared.sx import parse, make_env
from shared.sx.html import render
expr = parse('(div :class "card" (h1 "Hello") (p "World"))')
html = render(expr)
# → '<div class="card"><h1>Hello</h1><p>World</p></div>'
Components defined with ``defcomp`` are evaluated and their result is
rendered as HTML::
env = {}
evaluate(parse('(defcomp ~card (&key title &rest children) ...)'), env)
html = render(parse('(~card :title "Hi" (p "body"))'), env)
"""
from __future__ import annotations
from typing import Any
from .types import Component, Keyword, Lambda, NIL, Symbol
from .evaluator import _eval, _call_component
class _RawHTML:
"""Marker for pre-rendered HTML that should not be escaped."""
__slots__ = ("html",)
def __init__(self, html: str):
self.html = html
# ---------------------------------------------------------------------------
# HTML constants
# ---------------------------------------------------------------------------
# Tags that must not have a closing tag
VOID_ELEMENTS = frozenset({
"area", "base", "br", "col", "embed", "hr", "img", "input",
"link", "meta", "param", "source", "track", "wbr",
})
# Standard HTML tags (subset — any symbol that isn't recognised here will be
# treated as a function call and evaluated instead of rendered as a tag).
HTML_TAGS = frozenset({
# Root / document
"html", "head", "body",
# Metadata
"title", "meta", "link", "style", "script", "base", "noscript",
# Sections
"header", "footer", "main", "nav", "aside", "section", "article",
"address", "hgroup",
# Headings
"h1", "h2", "h3", "h4", "h5", "h6",
# Grouping
"div", "p", "blockquote", "pre", "figure", "figcaption",
"ul", "ol", "li", "dl", "dt", "dd", "hr",
# Text
"a", "span", "em", "strong", "small", "s", "cite", "q",
"abbr", "code", "var", "samp", "kbd", "sub", "sup",
"i", "b", "u", "mark", "ruby", "rt", "rp",
"bdi", "bdo", "br", "wbr", "time", "data",
# Edits
"ins", "del",
# Embedded
"img", "picture", "source", "iframe", "embed", "object", "param",
"video", "audio", "track", "canvas", "map", "area",
"svg", "math",
# SVG child elements
"path", "circle", "ellipse", "line", "polygon", "polyline", "rect",
"g", "defs", "use", "text", "tspan", "clipPath", "mask",
"linearGradient", "radialGradient", "stop", "filter",
"feGaussianBlur", "feOffset", "feMerge", "feMergeNode",
"animate", "animateTransform",
# Table
"table", "thead", "tbody", "tfoot", "tr", "th", "td",
"caption", "colgroup", "col",
# Forms
"form", "fieldset", "legend", "label", "input", "button",
"select", "option", "optgroup", "textarea", "output",
"datalist", "progress", "meter",
# Interactive
"details", "summary", "dialog",
# Template
"template", "slot",
})
# Attributes that are boolean (presence = true, absence = false)
BOOLEAN_ATTRS = frozenset({
"async", "autofocus", "autoplay", "checked", "controls",
"default", "defer", "disabled", "formnovalidate", "hidden",
"inert", "ismap", "loop", "multiple", "muted", "nomodule",
"novalidate", "open", "playsinline", "readonly", "required",
"reversed", "selected",
})
# ---------------------------------------------------------------------------
# Escaping
# ---------------------------------------------------------------------------
def escape_text(s: str) -> str:
"""Escape text content for safe HTML embedding."""
return (
s.replace("&", "&amp;")
.replace("<", "&lt;")
.replace(">", "&gt;")
)
def escape_attr(s: str) -> str:
"""Escape an attribute value for safe embedding in double quotes."""
return (
s.replace("&", "&amp;")
.replace('"', "&quot;")
.replace("<", "&lt;")
.replace(">", "&gt;")
)
# ---------------------------------------------------------------------------
# Renderer
# ---------------------------------------------------------------------------
def render(expr: Any, env: dict[str, Any] | None = None) -> str:
"""Render an s-expression as an HTML string.
*expr* can be:
- A parsed (unevaluated) s-expression from ``parse()``
- An already-evaluated value (string, list, etc.)
*env* provides variable bindings for evaluation.
"""
if env is None:
env = {}
return _render(expr, env)
def _render(expr: Any, env: dict[str, Any]) -> str:
# --- nil / None / False → empty string --------------------------------
if expr is None or expr is NIL or expr is False:
return ""
# --- True → empty (typically from a boolean expression, not content) ---
if expr is True:
return ""
# --- pre-rendered HTML → pass through ----------------------------------
if isinstance(expr, _RawHTML):
return expr.html
# --- string → escaped text --------------------------------------------
if isinstance(expr, str):
return escape_text(expr)
# --- number → string --------------------------------------------------
if isinstance(expr, (int, float)):
return escape_text(str(expr))
# --- symbol → evaluate then render ------------------------------------
if isinstance(expr, Symbol):
val = _eval(expr, env)
return _render(val, env)
# --- keyword → its name (unlikely in render context, but safe) --------
if isinstance(expr, Keyword):
return escape_text(expr.name)
# --- list → main dispatch ---------------------------------------------
if isinstance(expr, list):
if not expr:
return ""
return _render_list(expr, env)
# --- dict → skip (data, not renderable) -------------------------------
if isinstance(expr, dict):
return ""
# --- fallback ---------------------------------------------------------
return escape_text(str(expr))
# ---------------------------------------------------------------------------
# Render-aware special forms
# ---------------------------------------------------------------------------
# These mirror the evaluator's special forms but call _render on the result
# branches, so that HTML tags inside (if ...), (when ...), (let ...) etc.
# are rendered correctly instead of being evaluated as function calls.
def _rsf_if(expr: list, env: dict[str, Any]) -> str:
cond = _eval(expr[1], env)
if cond and cond is not NIL:
return _render(expr[2], env)
if len(expr) > 3:
return _render(expr[3], env)
return ""
def _rsf_when(expr: list, env: dict[str, Any]) -> str:
cond = _eval(expr[1], env)
if cond and cond is not NIL:
parts = []
for body_expr in expr[2:]:
parts.append(_render(body_expr, env))
return "".join(parts)
return ""
def _rsf_cond(expr: list, env: dict[str, Any]) -> str:
from .types import Keyword as Kw
clauses = expr[1:]
if not clauses:
return ""
# Scheme-style: ((test body) ...)
if isinstance(clauses[0], list) and len(clauses[0]) == 2:
for clause in clauses:
test = clause[0]
if isinstance(test, Symbol) and test.name in ("else", ":else"):
return _render(clause[1], env)
if isinstance(test, Kw) and test.name == "else":
return _render(clause[1], env)
if _eval(test, env):
return _render(clause[1], env)
else:
# Clojure-style: test body test body ...
i = 0
while i < len(clauses) - 1:
test = clauses[i]
result = clauses[i + 1]
if isinstance(test, Kw) and test.name == "else":
return _render(result, env)
if isinstance(test, Symbol) and test.name in (":else", "else"):
return _render(result, env)
if _eval(test, env):
return _render(result, env)
i += 2
return ""
def _rsf_let(expr: list, env: dict[str, Any]) -> str:
bindings = expr[1]
local = dict(env)
if isinstance(bindings, list):
if bindings and isinstance(bindings[0], list):
for binding in bindings:
var = binding[0]
vname = var.name if isinstance(var, Symbol) else var
local[vname] = _eval(binding[1], local)
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] = _eval(bindings[i + 1], local)
parts = []
for body_expr in expr[2:]:
parts.append(_render(body_expr, local))
return "".join(parts)
def _rsf_begin(expr: list, env: dict[str, Any]) -> str:
parts = []
for sub in expr[1:]:
parts.append(_render(sub, env))
return "".join(parts)
def _rsf_define(expr: list, env: dict[str, Any]) -> str:
_eval(expr, env) # side effect: define in env
return ""
def _rsf_defcomp(expr: list, env: dict[str, Any]) -> str:
_eval(expr, env) # side effect: register component
return ""
def _render_lambda_call(fn: Lambda, args: tuple, env: dict[str, Any]) -> str:
"""Call a lambda and render the result — the body may contain HTML tags."""
local = dict(fn.closure)
local.update(env)
for p, v in zip(fn.params, args):
local[p] = v
return _render(fn.body, local)
def _rsf_map(expr: list, env: dict[str, Any]) -> str:
fn = _eval(expr[1], env)
coll = _eval(expr[2], env)
parts = []
for item in coll:
if isinstance(fn, Lambda):
parts.append(_render_lambda_call(fn, (item,), env))
elif callable(fn):
parts.append(_render(fn(item), env))
else:
parts.append(_render(item, env))
return "".join(parts)
def _rsf_map_indexed(expr: list, env: dict[str, Any]) -> str:
fn = _eval(expr[1], env)
coll = _eval(expr[2], env)
parts = []
for i, item in enumerate(coll):
if isinstance(fn, Lambda):
parts.append(_render_lambda_call(fn, (i, item), env))
elif callable(fn):
parts.append(_render(fn(i, item), env))
else:
parts.append(_render(item, env))
return "".join(parts)
def _rsf_filter(expr: list, env: dict[str, Any]) -> str:
# filter returns a list — render each kept item
result = _eval(expr, env)
return _render(result, env)
def _rsf_for_each(expr: list, env: dict[str, Any]) -> str:
fn = _eval(expr[1], env)
coll = _eval(expr[2], env)
parts = []
for item in coll:
if isinstance(fn, Lambda):
parts.append(_render_lambda_call(fn, (item,), env))
elif callable(fn):
parts.append(_render(fn(item), env))
else:
parts.append(_render(item, env))
return "".join(parts)
_RENDER_FORMS: dict[str, Any] = {
"if": _rsf_if,
"when": _rsf_when,
"cond": _rsf_cond,
"let": _rsf_let,
"let*": _rsf_let,
"begin": _rsf_begin,
"do": _rsf_begin,
"define": _rsf_define,
"defcomp": _rsf_defcomp,
"map": _rsf_map,
"map-indexed": _rsf_map_indexed,
"filter": _rsf_filter,
"for-each": _rsf_for_each,
}
def _render_component(comp: Component, args: list, env: dict[str, Any]) -> str:
"""Render-aware component call: sets up scope then renders the body."""
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] = _eval(args[i + 1], env)
i += 2
else:
children.append(arg)
i += 1
local = dict(comp.closure)
local.update(env)
for p in comp.params:
if p in kwargs:
local[p] = kwargs[p]
else:
local[p] = NIL
if comp.has_children:
# Render children to HTML and wrap as _RawHTML to prevent re-escaping
local["children"] = _RawHTML("".join(_render(c, env) for c in children))
return _render(comp.body, local)
def _render_list(expr: list, env: dict[str, Any]) -> str:
"""Render a list expression — could be an HTML element, special form,
component call, or data list."""
head = expr[0]
if isinstance(head, Symbol):
name = head.name
# --- raw! → unescaped HTML ----------------------------------------
if name == "raw!":
parts = []
for arg in expr[1:]:
val = _eval(arg, env)
if isinstance(val, _RawHTML):
parts.append(val.html)
elif isinstance(val, str):
parts.append(val)
elif val is not None and val is not NIL:
parts.append(str(val))
return "".join(parts)
# --- <> → fragment (render children, no wrapper) ------------------
if name == "<>":
return "".join(_render(child, env) for child in expr[1:])
# --- 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)
# --- HTML tag → render as element ---------------------------------
if name in HTML_TAGS:
return _render_element(name, expr[1:], env)
# --- Component (~prefix) → render-aware component call ------------
if name.startswith("~"):
val = env.get(name)
if isinstance(val, Component):
return _render_component(val, expr[1:], env)
# Fall through to evaluation
# --- Other special forms / function calls → evaluate then render ---
result = _eval(expr, env)
return _render(result, env)
# --- head is lambda or other callable → evaluate then render ----------
if isinstance(head, (Lambda, list)):
result = _eval(expr, env)
return _render(result, env)
# --- data list → render each item -------------------------------------
return "".join(_render(item, env) for item in expr)
def _render_element(tag: str, args: list, env: dict[str, Any]) -> str:
"""Render an HTML element: extract attrs (keywords), render children."""
attrs: dict[str, Any] = {}
children: list[Any] = []
i = 0
while i < len(args):
arg = args[i]
# Keyword followed by value → attribute
if isinstance(arg, Keyword) and i + 1 < len(args):
attr_name = arg.name
attr_val = _eval(args[i + 1], env)
attrs[attr_name] = attr_val
i += 2
else:
children.append(arg)
i += 1
# Build opening tag
parts = [f"<{tag}"]
for attr_name, attr_val in attrs.items():
if attr_val is None or attr_val is NIL or attr_val is False:
continue
if attr_name in BOOLEAN_ATTRS:
if attr_val:
parts.append(f" {attr_name}")
elif attr_val is True:
parts.append(f" {attr_name}")
else:
parts.append(f' {attr_name}="{escape_attr(str(attr_val))}"')
parts.append(">")
opening = "".join(parts)
# Void elements: no closing tag, no children
if tag in VOID_ELEMENTS:
return opening
# Render children
child_html = "".join(_render(child, env) for child in children)
return f"{opening}{child_html}</{tag}>"

249
shared/sx/jinja_bridge.py Normal file
View File

@@ -0,0 +1,249 @@
"""
Jinja ↔ s-expression bridge.
Provides two-way integration so s-expression components and Jinja templates
can coexist during incremental migration:
**Jinja → s-expression** (use s-expression components inside Jinja templates)::
{{ sx('(~link-card :slug "apple" :title "Apple")') | safe }}
**S-expression → Jinja** (embed Jinja output inside s-expressions)::
(raw! (jinja "fragments/link_card.html" :slug "apple" :title "Apple"))
Setup::
from shared.sx.jinja_bridge import setup_sx_bridge
setup_sx_bridge(app) # call after setup_jinja(app)
"""
from __future__ import annotations
import glob
import os
from typing import Any
from .types import NIL, Component, Keyword, Symbol
from .parser import parse
from .html import render as html_render, _render_component
# ---------------------------------------------------------------------------
# Shared component environment
# ---------------------------------------------------------------------------
# Global component registry — populated at app startup by loading component
# definition files or calling register_components().
_COMPONENT_ENV: dict[str, Any] = {}
def get_component_env() -> dict[str, Any]:
"""Return the shared component environment."""
return _COMPONENT_ENV
def load_sx_dir(directory: str) -> None:
"""Load all .sx files from a directory and register components."""
for filepath in sorted(
glob.glob(os.path.join(directory, "*.sx"))
):
with open(filepath, encoding="utf-8") as f:
register_components(f.read())
# ---------------------------------------------------------------------------
# Dev-mode auto-reload of sx templates
# ---------------------------------------------------------------------------
_watched_dirs: list[str] = []
_file_mtimes: dict[str, float] = {}
def watch_sx_dir(directory: str) -> None:
"""Register a directory for dev-mode file watching."""
_watched_dirs.append(directory)
# Seed mtimes
for fp in sorted(
glob.glob(os.path.join(directory, "*.sx"))
):
_file_mtimes[fp] = os.path.getmtime(fp)
def reload_if_changed() -> None:
"""Re-read sx files if any have changed on disk. Called per-request in dev."""
changed = False
for directory in _watched_dirs:
for fp in sorted(
glob.glob(os.path.join(directory, "*.sx"))
):
mtime = os.path.getmtime(fp)
if fp not in _file_mtimes or _file_mtimes[fp] != mtime:
_file_mtimes[fp] = mtime
changed = True
if changed:
_COMPONENT_ENV.clear()
for directory in _watched_dirs:
load_sx_dir(directory)
def load_service_components(service_dir: str) -> None:
"""Load service-specific s-expression components from {service_dir}/sx/."""
sx_dir = os.path.join(service_dir, "sx")
if os.path.isdir(sx_dir):
load_sx_dir(sx_dir)
watch_sx_dir(sx_dir)
def register_components(sx_source: str) -> None:
"""Parse and evaluate s-expression component definitions into the
shared environment.
Typically called at app startup::
register_components('''
(defcomp ~link-card (&key link title image icon)
(a :href link :class "block rounded ..."
(div :class "flex ..."
(if image
(img :src image :class "...")
(div :class "..." (i :class icon)))
(div :class "..." (div :class "..." title)))))
''')
"""
from .evaluator import _eval
from .parser import parse_all
exprs = parse_all(sx_source)
for expr in exprs:
_eval(expr, _COMPONENT_ENV)
# ---------------------------------------------------------------------------
# sx() — render s-expression from Jinja template
# ---------------------------------------------------------------------------
def sx(source: str, **kwargs: Any) -> str:
"""Render an s-expression string to HTML.
Keyword arguments are merged into the evaluation environment,
so Jinja context variables can be passed through::
{{ sx('(~link-card :title title :slug slug)',
title=post.title, slug=post.slug) | safe }}
This is a synchronous function — suitable for Jinja globals.
For async resolution (with I/O primitives), use ``sx_async()``.
"""
env = dict(_COMPONENT_ENV)
env.update(kwargs)
expr = parse(source)
return html_render(expr, env)
def render(component_name: str, **kwargs: Any) -> str:
"""Call a registered component by name with Python kwargs.
Automatically converts Python snake_case to sx kebab-case.
No sx strings needed — just a function call.
"""
name = component_name if component_name.startswith("~") else f"~{component_name}"
comp = _COMPONENT_ENV.get(name)
if not isinstance(comp, Component):
raise ValueError(f"Unknown component: {name}")
env = dict(_COMPONENT_ENV)
args: list[Any] = []
for key, val in kwargs.items():
kw_name = key.replace("_", "-")
args.append(Keyword(kw_name))
args.append(val)
env[kw_name] = val
return _render_component(comp, args, env)
async def sx_async(source: str, **kwargs: Any) -> str:
"""Async version of ``sx()`` — resolves I/O primitives (frag, query)
before rendering.
Use when the s-expression contains I/O nodes::
{{ sx_async('(frag "blog" "card" :slug "apple")') | safe }}
"""
from .resolver import resolve, RequestContext
env = dict(_COMPONENT_ENV)
env.update(kwargs)
expr = parse(source)
# Try to get request context from Quart
ctx = _get_request_context()
return await resolve(expr, ctx=ctx, env=env)
def _get_request_context():
"""Build RequestContext from current Quart request, if available."""
from .primitives_io import RequestContext
try:
from quart import g, request
user = getattr(g, "user", None)
is_htmx = bool(request.headers.get("SX-Request") or request.headers.get("HX-Request"))
return RequestContext(user=user, is_htmx=is_htmx)
except Exception:
return RequestContext()
# ---------------------------------------------------------------------------
# Quart integration
# ---------------------------------------------------------------------------
def client_components_tag(*names: str) -> str:
"""Emit a <script type="text/sx"> tag with component definitions.
Reads the source definitions from loaded .sx files and sends them
to the client so sx.js can render them identically.
Usage in Python::
body_end_html = client_components_tag("test-filter-card", "test-row")
Or send all loaded components::
body_end_html = client_components_tag()
"""
from .parser import serialize
parts = []
for key, val in _COMPONENT_ENV.items():
if not isinstance(val, Component):
continue
if names and val.name not in names and key.lstrip("~") not in names:
continue
# Reconstruct defcomp source from the Component object
param_strs = ["&key"] + list(val.params)
if val.has_children:
param_strs.extend(["&rest", "children"])
params_sx = "(" + " ".join(param_strs) + ")"
body_sx = serialize(val.body, pretty=True)
parts.append(f"(defcomp ~{val.name} {params_sx} {body_sx})")
if not parts:
return ""
source = "\n".join(parts)
return f'<script type="text/sx" data-components>{source}</script>'
def setup_sx_bridge(app: Any) -> None:
"""Register s-expression helpers with a Quart app's Jinja environment.
Call this in your app factory after ``setup_jinja(app)``::
from shared.sx.jinja_bridge import setup_sx_bridge
setup_sx_bridge(app)
This registers:
- ``sx(source, **kwargs)`` — sync render (components, pure HTML)
- ``sx_async(source, **kwargs)`` — async render (with I/O resolution)
"""
app.jinja_env.globals["sx"] = sx
app.jinja_env.globals["render"] = render
app.jinja_env.globals["sx_async"] = sx_async

97
shared/sx/page.py Normal file
View File

@@ -0,0 +1,97 @@
"""
Full-page s-expression rendering.
Provides ``render_page()`` for rendering a complete HTML page from an
s-expression, bypassing Jinja entirely. Used by error handlers and
(eventually) by route handlers for fully-migrated pages.
``render_sx_response()`` is the main entry point for GET route handlers:
it calls the app's context processor, merges in route-specific kwargs,
renders the s-expression to HTML, and returns a Quart ``Response``.
Usage::
from shared.sx.page import render_page, render_sx_response
# Error pages (no context needed)
html = render_page(
'(~error-page :title "Not Found" :message "NOT FOUND" :image img :asset-url aurl)',
image="/static/errors/404.gif",
asset_url="/static",
)
# GET route handlers (auto-injects app context)
resp = await render_sx_response('(~orders-page :orders orders)', orders=orders)
"""
from __future__ import annotations
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"}'
def render_page(source: str, **kwargs: Any) -> str:
"""Render a full HTML page from an s-expression string.
This is a thin wrapper around ``sx()`` — it exists to make the
intent explicit in call sites (rendering a whole page, not a fragment).
"""
return sx(source, **kwargs)
async def get_template_context(**kwargs: Any) -> dict[str, Any]:
"""Gather the full template context from all registered context processors.
Returns a dict with all context variables that would normally be
available in a Jinja template, merged with any extra kwargs.
"""
import asyncio
from quart import current_app, request
ctx: dict[str, Any] = {}
# App-level context processors
for proc in current_app.template_context_processors.get(None, []):
rv = proc()
if asyncio.iscoroutine(rv):
rv = await rv
ctx.update(rv)
# Blueprint-scoped context processors
for bp_name in (request.blueprints or []):
for proc in current_app.template_context_processors.get(bp_name, []):
rv = proc()
if asyncio.iscoroutine(rv):
rv = await rv
ctx.update(rv)
# Inject Jinja globals that s-expression components need (URL helpers,
# asset_url, styles, etc.) — these aren't provided by context processors.
for key, val in current_app.jinja_env.globals.items():
if key not in ctx:
ctx[key] = val
# Expose request-scoped values that sx components need
from quart import g
if "rights" not in ctx:
ctx["rights"] = getattr(g, "rights", {})
ctx.update(kwargs)
return ctx
async def render_sx_response(source: str, **kwargs: Any) -> str:
"""Render an s-expression with the full app template context.
Calls the app's registered context processors (which provide
cart_mini, auth_menu, nav_tree, asset_url, etc.)
and merges them with the caller's kwargs before rendering.
Returns the rendered HTML string (caller wraps in Response as needed).
"""
ctx = await get_template_context(**kwargs)
return sx(source, **ctx)

347
shared/sx/parser.py Normal file
View File

@@ -0,0 +1,347 @@
"""
S-expression parser.
Supports:
- Lists: (a b c)
- Vectors: [a b c] (sugar for lists)
- Maps: {:key1 val1 :key2 val2}
- Symbols: foo, bar-baz, ->, ~card
- Keywords: :class, :id
- Strings: "hello world" (with \\n, \\t, \\", \\\\ escapes)
- Numbers: 42, 3.14, -1.5, 1e-3
- Comments: ; to end of line
- Fragment: <> (empty-tag symbol for fragment groups)
"""
from __future__ import annotations
import re
from typing import Any
from .types import Keyword, Symbol, NIL
# ---------------------------------------------------------------------------
# SxExpr — pre-built sx source marker
# ---------------------------------------------------------------------------
class SxExpr:
"""Pre-built sx source that serialize() outputs unquoted.
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)))
# => (~parent :child (~child :x 1))
"""
__slots__ = ("source",)
def __init__(self, source: str):
self.source = source
def __repr__(self) -> str:
return f"SxExpr({self.source!r})"
def __str__(self) -> str:
return self.source
def __add__(self, other: object) -> "SxExpr":
return SxExpr(self.source + str(other))
def __radd__(self, other: object) -> "SxExpr":
return SxExpr(str(other) + self.source)
# ---------------------------------------------------------------------------
# Errors
# ---------------------------------------------------------------------------
class ParseError(Exception):
"""Error during s-expression parsing."""
def __init__(self, message: str, position: int = 0, line: int = 1, col: int = 1):
self.position = position
self.line = line
self.col = col
super().__init__(f"{message} at line {line}, column {col}")
# ---------------------------------------------------------------------------
# Tokenizer
# ---------------------------------------------------------------------------
class Tokenizer:
"""Stateful tokenizer that walks an s-expression string."""
WHITESPACE = re.compile(r"\s+")
COMMENT = re.compile(r";[^\n]*")
STRING = re.compile(r'"(?:[^"\\]|\\.)*"')
NUMBER = re.compile(r"-?(?:\d+\.?\d*|\.\d+)(?:[eE][+-]?\d+)?")
KEYWORD = re.compile(r":[a-zA-Z_][a-zA-Z0-9_>:-]*")
# Symbols may start with alpha, _, or common operator chars, plus ~ for components,
# <> for the fragment symbol, and & for &key/&rest.
SYMBOL = re.compile(r"[a-zA-Z_~*+\-><=/!?&][a-zA-Z0-9_~*+\-><=/!?.:&]*")
def __init__(self, text: str):
self.text = text
self.pos = 0
self.line = 1
self.col = 1
def _advance(self, count: int = 1):
for _ in range(count):
if self.pos < len(self.text):
if self.text[self.pos] == "\n":
self.line += 1
self.col = 1
else:
self.col += 1
self.pos += 1
def _skip_whitespace_and_comments(self):
while self.pos < len(self.text):
m = self.WHITESPACE.match(self.text, self.pos)
if m:
self._advance(m.end() - self.pos)
continue
m = self.COMMENT.match(self.text, self.pos)
if m:
self._advance(m.end() - self.pos)
continue
break
def peek(self) -> str | None:
self._skip_whitespace_and_comments()
if self.pos >= len(self.text):
return None
return self.text[self.pos]
def next_token(self) -> Any:
self._skip_whitespace_and_comments()
if self.pos >= len(self.text):
return None
char = self.text[self.pos]
# Delimiters
if char in "()[]{}":
self._advance()
return char
# String
if char == '"':
m = self.STRING.match(self.text, self.pos)
if not m:
raise ParseError("Unterminated string", self.pos, self.line, self.col)
self._advance(m.end() - self.pos)
content = m.group()[1:-1]
content = content.replace("\\n", "\n")
content = content.replace("\\t", "\t")
content = content.replace('\\"', '"')
content = content.replace("\\\\", "\\")
return content
# Keyword
if char == ":":
m = self.KEYWORD.match(self.text, self.pos)
if m:
self._advance(m.end() - self.pos)
return Keyword(m.group()[1:])
raise ParseError("Invalid keyword", self.pos, self.line, self.col)
# Number (check before symbol because of leading -)
if char.isdigit() or (
char == "-"
and self.pos + 1 < len(self.text)
and (self.text[self.pos + 1].isdigit() or self.text[self.pos + 1] == ".")
):
m = self.NUMBER.match(self.text, self.pos)
if m:
self._advance(m.end() - self.pos)
num_str = m.group()
if "." in num_str or "e" in num_str or "E" in num_str:
return float(num_str)
return int(num_str)
# Symbol
m = self.SYMBOL.match(self.text, self.pos)
if m:
self._advance(m.end() - self.pos)
name = m.group()
# Built-in literal symbols
if name == "true":
return True
if name == "false":
return False
if name == "nil":
return NIL
return Symbol(name)
raise ParseError(f"Unexpected character: {char!r}", self.pos, self.line, self.col)
# ---------------------------------------------------------------------------
# Parsing
# ---------------------------------------------------------------------------
def parse(text: str) -> Any:
"""Parse a single s-expression from *text*.
>>> parse('(div :class "main" (p "hello"))')
[Symbol('div'), Keyword('class'), 'main', [Symbol('p'), 'hello']]
"""
tok = Tokenizer(text)
result = _parse_expr(tok)
if tok.peek() is not None:
raise ParseError("Unexpected content after expression", tok.pos, tok.line, tok.col)
return result
def parse_all(text: str) -> list[Any]:
"""Parse zero or more s-expressions from *text*."""
tok = Tokenizer(text)
results: list[Any] = []
while tok.peek() is not None:
results.append(_parse_expr(tok))
return results
def _parse_expr(tok: Tokenizer) -> Any:
token = tok.next_token()
if token is None:
raise ParseError("Unexpected end of input", tok.pos, tok.line, tok.col)
if token == "(":
return _parse_list(tok, ")")
if token == "[":
return _parse_list(tok, "]")
if token == "{":
return _parse_map(tok)
if token in (")", "]", "}"):
raise ParseError(f"Unexpected {token!r}", tok.pos, tok.line, tok.col)
return token
def _parse_list(tok: Tokenizer, closer: str) -> list[Any]:
items: list[Any] = []
while True:
c = tok.peek()
if c is None:
raise ParseError(f"Unterminated list, expected {closer!r}", tok.pos, tok.line, tok.col)
if c == closer:
tok.next_token()
return items
items.append(_parse_expr(tok))
def _parse_map(tok: Tokenizer) -> dict[str, Any]:
result: dict[str, Any] = {}
while True:
c = tok.peek()
if c is None:
raise ParseError("Unterminated map, expected '}'", tok.pos, tok.line, tok.col)
if c == "}":
tok.next_token()
return result
key_token = _parse_expr(tok)
if isinstance(key_token, Keyword):
key = key_token.name
elif isinstance(key_token, str):
key = key_token
else:
raise ParseError(
f"Map key must be keyword or string, got {type(key_token).__name__}",
tok.pos, tok.line, tok.col,
)
result[key] = _parse_expr(tok)
# ---------------------------------------------------------------------------
# Serialization
# ---------------------------------------------------------------------------
def serialize(expr: Any, indent: int = 0, pretty: bool = False) -> str:
"""Serialize a value back to s-expression text."""
if isinstance(expr, SxExpr):
return expr.source
if isinstance(expr, list):
if not expr:
return "()"
if pretty:
return _serialize_pretty(expr, indent)
items = [serialize(item, indent, False) for item in expr]
return "(" + " ".join(items) + ")"
if isinstance(expr, Symbol):
return expr.name
if isinstance(expr, Keyword):
return f":{expr.name}"
if isinstance(expr, str):
escaped = (
expr.replace("\\", "\\\\")
.replace('"', '\\"')
.replace("\n", "\\n")
.replace("\t", "\\t")
)
return f'"{escaped}"'
if isinstance(expr, bool):
return "true" if expr else "false"
if isinstance(expr, (int, float)):
return str(expr)
if expr is None or isinstance(expr, type(NIL)):
return "nil"
if isinstance(expr, dict):
items: list[str] = []
for k, v in expr.items():
items.append(f":{k}")
items.append(serialize(v, indent, pretty))
return "{" + " ".join(items) + "}"
# Catch callables (Python functions leaked into sx data)
if callable(expr):
import logging
logging.getLogger("sx").error(
"serialize: callable leaked into sx data: %r", expr)
return "nil"
# Fallback for Lambda/Component — show repr
return repr(expr)
def _serialize_pretty(expr: list, indent: int) -> str:
if not expr:
return "()"
inner_prefix = " " * (indent + 1)
# Try compact first
compact = serialize(expr, indent, False)
if len(compact) < 72 and "\n" not in compact:
return compact
head = serialize(expr[0], indent + 1, False)
parts = [f"({head}"]
i = 1
while i < len(expr):
item = expr[i]
if isinstance(item, Keyword) and i + 1 < len(expr):
key = serialize(item, 0, False)
val = serialize(expr[i + 1], indent + 1, False)
if len(val) < 50 and "\n" not in val:
parts.append(f"{inner_prefix}{key} {val}")
else:
val_p = serialize(expr[i + 1], indent + 1, True)
parts.append(f"{inner_prefix}{key} {val_p}")
i += 2
else:
item_str = serialize(item, indent + 1, True)
parts.append(f"{inner_prefix}{item_str}")
i += 1
return "\n".join(parts) + ")"

419
shared/sx/primitives.py Normal file
View File

@@ -0,0 +1,419 @@
"""
Primitive registry and built-in pure functions.
All primitives here are pure (no I/O). Async / I/O primitives live in
separate modules and are registered at app startup.
"""
from __future__ import annotations
import math
from typing import Any, Callable
from .types import Keyword, Lambda, NIL
# ---------------------------------------------------------------------------
# Registry
# ---------------------------------------------------------------------------
_PRIMITIVES: dict[str, Callable] = {}
def register_primitive(name: str):
"""Decorator that registers a callable as a named primitive.
Usage::
@register_primitive("str")
def prim_str(*args):
return "".join(str(a) for a in args)
"""
def decorator(fn: Callable) -> Callable:
_PRIMITIVES[name] = fn
return fn
return decorator
def get_primitive(name: str) -> Callable | None:
return _PRIMITIVES.get(name)
def all_primitives() -> dict[str, Callable]:
"""Return a snapshot of the registry (name → callable)."""
return dict(_PRIMITIVES)
# ---------------------------------------------------------------------------
# Arithmetic
# ---------------------------------------------------------------------------
@register_primitive("+")
def prim_add(*args: Any) -> Any:
return sum(args)
@register_primitive("-")
def prim_sub(a: Any, b: Any = None) -> Any:
return -a if b is None else a - b
@register_primitive("*")
def prim_mul(*args: Any) -> Any:
r = 1
for a in args:
r *= a
return r
@register_primitive("/")
def prim_div(a: Any, b: Any) -> Any:
return a / b
@register_primitive("mod")
def prim_mod(a: Any, b: Any) -> Any:
return a % b
@register_primitive("sqrt")
def prim_sqrt(x: Any) -> float:
return math.sqrt(x)
@register_primitive("pow")
def prim_pow(x: Any, n: Any) -> Any:
return x ** n
@register_primitive("abs")
def prim_abs(x: Any) -> Any:
return abs(x)
@register_primitive("floor")
def prim_floor(x: Any) -> int:
return math.floor(x)
@register_primitive("ceil")
def prim_ceil(x: Any) -> int:
return math.ceil(x)
@register_primitive("round")
def prim_round(x: Any, ndigits: Any = 0) -> Any:
return round(x, int(ndigits))
@register_primitive("min")
def prim_min(*args: Any) -> Any:
if len(args) == 1 and isinstance(args[0], (list, tuple)):
return min(args[0])
return min(args)
@register_primitive("max")
def prim_max(*args: Any) -> Any:
if len(args) == 1 and isinstance(args[0], (list, tuple)):
return max(args[0])
return max(args)
@register_primitive("clamp")
def prim_clamp(x: Any, lo: Any, hi: Any) -> Any:
return max(lo, min(hi, x))
@register_primitive("inc")
def prim_inc(n: Any) -> Any:
return n + 1
@register_primitive("dec")
def prim_dec(n: Any) -> Any:
return n - 1
# ---------------------------------------------------------------------------
# Comparison
# ---------------------------------------------------------------------------
@register_primitive("=")
def prim_eq(a: Any, b: Any) -> bool:
return a == b
@register_primitive("!=")
def prim_neq(a: Any, b: Any) -> bool:
return a != b
@register_primitive("<")
def prim_lt(a: Any, b: Any) -> bool:
return a < b
@register_primitive(">")
def prim_gt(a: Any, b: Any) -> bool:
return a > b
@register_primitive("<=")
def prim_lte(a: Any, b: Any) -> bool:
return a <= b
@register_primitive(">=")
def prim_gte(a: Any, b: Any) -> bool:
return a >= b
# ---------------------------------------------------------------------------
# Predicates
# ---------------------------------------------------------------------------
@register_primitive("odd?")
def prim_is_odd(n: Any) -> bool:
return n % 2 == 1
@register_primitive("even?")
def prim_is_even(n: Any) -> bool:
return n % 2 == 0
@register_primitive("zero?")
def prim_is_zero(n: Any) -> bool:
return n == 0
@register_primitive("nil?")
def prim_is_nil(x: Any) -> bool:
return x is None or x is NIL
@register_primitive("number?")
def prim_is_number(x: Any) -> bool:
return isinstance(x, (int, float))
@register_primitive("string?")
def prim_is_string(x: Any) -> bool:
return isinstance(x, str)
@register_primitive("list?")
def prim_is_list(x: Any) -> bool:
return isinstance(x, list)
@register_primitive("dict?")
def prim_is_dict(x: Any) -> bool:
return isinstance(x, dict)
@register_primitive("empty?")
def prim_is_empty(coll: Any) -> bool:
if coll is None or coll is NIL:
return True
return len(coll) == 0
@register_primitive("contains?")
def prim_contains(coll: Any, key: Any) -> bool:
if isinstance(coll, dict):
k = key.name if isinstance(key, Keyword) else key
return k in coll
if isinstance(coll, (list, tuple)):
return key in coll
return False
# ---------------------------------------------------------------------------
# Logic (non-short-circuit versions; and/or are special forms)
# ---------------------------------------------------------------------------
@register_primitive("not")
def prim_not(x: Any) -> bool:
return not x
# ---------------------------------------------------------------------------
# Strings
# ---------------------------------------------------------------------------
@register_primitive("str")
def prim_str(*args: Any) -> str:
parts: list[str] = []
for a in args:
if a is None or a is NIL:
parts.append("")
elif isinstance(a, bool):
parts.append("true" if a else "false")
else:
parts.append(str(a))
return "".join(parts)
@register_primitive("concat")
def prim_concat(*colls: Any) -> list:
result: list[Any] = []
for c in colls:
if c is not None and c is not NIL:
result.extend(c)
return result
@register_primitive("upper")
def prim_upper(s: str) -> str:
return s.upper()
@register_primitive("lower")
def prim_lower(s: str) -> str:
return s.lower()
@register_primitive("trim")
def prim_trim(s: str) -> str:
return s.strip()
@register_primitive("split")
def prim_split(s: str, sep: str = " ") -> list[str]:
return s.split(sep)
@register_primitive("join")
def prim_join(sep: str, coll: list) -> str:
return sep.join(str(x) for x in coll)
@register_primitive("starts-with?")
def prim_starts_with(s, prefix: str) -> bool:
if not isinstance(s, str):
return False
return s.startswith(prefix)
@register_primitive("ends-with?")
def prim_ends_with(s: str, suffix: str) -> bool:
return s.endswith(suffix)
# ---------------------------------------------------------------------------
# Collections — construction
# ---------------------------------------------------------------------------
@register_primitive("list")
def prim_list(*args: Any) -> list:
return list(args)
@register_primitive("dict")
def prim_dict(*pairs: Any) -> dict:
result: dict[str, Any] = {}
i = 0
while i < len(pairs) - 1:
key = pairs[i]
if isinstance(key, Keyword):
key = key.name
result[key] = pairs[i + 1]
i += 2
return result
@register_primitive("range")
def prim_range(start: Any, end: Any, step: Any = 1) -> list[int]:
return list(range(int(start), int(end), int(step)))
# ---------------------------------------------------------------------------
# Collections — access
# ---------------------------------------------------------------------------
@register_primitive("get")
def prim_get(coll: Any, key: Any, default: Any = None) -> Any:
if isinstance(coll, dict):
result = coll.get(key)
if result is not None:
return result
if isinstance(key, Keyword):
result = coll.get(key.name)
if result is not None:
return result
return default
if isinstance(coll, list):
return coll[key] if 0 <= key < len(coll) else default
return default
@register_primitive("len")
def prim_len(coll: Any) -> int:
return len(coll)
@register_primitive("first")
def prim_first(coll: Any) -> Any:
return coll[0] if coll else NIL
@register_primitive("last")
def prim_last(coll: Any) -> Any:
return coll[-1] if coll else NIL
@register_primitive("rest")
def prim_rest(coll: Any) -> list:
return coll[1:] if coll else []
@register_primitive("nth")
def prim_nth(coll: Any, n: Any) -> Any:
return coll[n] if 0 <= n < len(coll) else NIL
@register_primitive("cons")
def prim_cons(x: Any, coll: Any) -> list:
return [x] + list(coll) if coll else [x]
@register_primitive("append")
def prim_append(coll: Any, x: Any) -> list:
return list(coll) + [x] if coll else [x]
@register_primitive("chunk-every")
def prim_chunk_every(coll: Any, n: Any) -> list:
n = int(n)
return [coll[i : i + n] for i in range(0, len(coll), n)]
@register_primitive("zip-pairs")
def prim_zip_pairs(coll: Any) -> list:
if not coll or len(coll) < 2:
return []
return [[coll[i], coll[i + 1]] for i in range(len(coll) - 1)]
# ---------------------------------------------------------------------------
# Collections — dict operations
# ---------------------------------------------------------------------------
@register_primitive("keys")
def prim_keys(d: dict) -> list:
return list(d.keys())
@register_primitive("vals")
def prim_vals(d: dict) -> list:
return list(d.values())
@register_primitive("merge")
def prim_merge(*dicts: Any) -> dict:
result: dict[str, Any] = {}
for d in dicts:
if d is not None and d is not NIL:
result.update(d)
return result
@register_primitive("assoc")
def prim_assoc(d: Any, *pairs: Any) -> dict:
result = dict(d) if d and d is not NIL else {}
i = 0
while i < len(pairs) - 1:
key = pairs[i]
if isinstance(key, Keyword):
key = key.name
result[key] = pairs[i + 1]
i += 2
return result
@register_primitive("dissoc")
def prim_dissoc(d: Any, *keys_to_remove: Any) -> dict:
result = dict(d) if d and d is not NIL else {}
for key in keys_to_remove:
if isinstance(key, Keyword):
key = key.name
result.pop(key, None)
return result
@register_primitive("into")
def prim_into(target: Any, coll: Any) -> Any:
if isinstance(target, list):
if isinstance(coll, dict):
return [[k, v] for k, v in coll.items()]
return list(coll)
if isinstance(target, dict):
if isinstance(coll, dict):
return dict(coll)
result: dict[str, Any] = {}
for item in coll:
if isinstance(item, (list, tuple)) and len(item) >= 2:
key = item[0].name if isinstance(item[0], Keyword) else item[0]
result[key] = item[1]
return result
raise ValueError(f"into: unsupported target type {type(target).__name__}")
# ---------------------------------------------------------------------------
# Assertions
# ---------------------------------------------------------------------------
@register_primitive("assert")
def prim_assert(condition: Any, message: str = "Assertion failed") -> bool:
if not condition:
raise RuntimeError(f"Assertion error: {message}")
return True

153
shared/sx/primitives_io.py Normal file
View File

@@ -0,0 +1,153 @@
"""
Async I/O primitives for the s-expression resolver.
These wrap rose-ash's inter-service communication layer so that s-expressions
can fetch fragments, query data, call actions, and access request context.
Unlike pure primitives (primitives.py), these are **async** and are executed
by the resolver rather than the evaluator. They are identified by name
during the tree-walk phase and dispatched via ``asyncio.gather()``.
Usage in s-expressions::
(frag "blog" "link-card" :slug "apple")
(query "market" "products-by-ids" :ids "1,2,3")
(action "market" "create-marketplace" :name "Farm Shop" :slug "farm")
(current-user)
(htmx-request?)
"""
from __future__ import annotations
from typing import Any
# ---------------------------------------------------------------------------
# Registry of async primitives (name → metadata)
# ---------------------------------------------------------------------------
# Names that the resolver recognises as I/O nodes requiring async resolution.
# The resolver collects these during tree-walk, groups them, and dispatches
# them in parallel.
IO_PRIMITIVES: frozenset[str] = frozenset({
"frag",
"query",
"action",
"current-user",
"htmx-request?",
})
# ---------------------------------------------------------------------------
# Request context (set per-request by the resolver)
# ---------------------------------------------------------------------------
class RequestContext:
"""Per-request context provided to I/O primitives.
Populated by the resolver from the Quart request before resolution begins.
"""
__slots__ = ("user", "is_htmx", "extras")
def __init__(
self,
user: dict[str, Any] | None = None,
is_htmx: bool = False,
extras: dict[str, Any] | None = None,
):
self.user = user
self.is_htmx = is_htmx
self.extras = extras or {}
# ---------------------------------------------------------------------------
# I/O dispatch
# ---------------------------------------------------------------------------
async def execute_io(
name: str,
args: list[Any],
kwargs: dict[str, Any],
ctx: RequestContext,
) -> Any:
"""Execute an I/O primitive by name.
Called by the resolver after collecting and grouping I/O nodes.
Returns the result to be substituted back into the tree.
"""
handler = _IO_HANDLERS.get(name)
if handler is None:
raise RuntimeError(f"Unknown I/O primitive: {name}")
return await handler(args, kwargs, ctx)
# ---------------------------------------------------------------------------
# Individual handlers
# ---------------------------------------------------------------------------
async def _io_frag(
args: list[Any], kwargs: dict[str, Any], ctx: RequestContext
) -> str:
"""``(frag "service" "type" :key val ...)`` → fetch_fragment."""
if len(args) < 2:
raise ValueError("frag requires service and fragment type")
service = str(args[0])
frag_type = str(args[1])
params = {k: v for k, v in kwargs.items() if v is not None}
from shared.infrastructure.fragments import fetch_fragment
return await fetch_fragment(service, frag_type, params=params or None)
async def _io_query(
args: list[Any], kwargs: dict[str, Any], ctx: RequestContext
) -> Any:
"""``(query "service" "query-name" :key val ...)`` → fetch_data."""
if len(args) < 2:
raise ValueError("query requires service and query name")
service = str(args[0])
query_name = str(args[1])
params = {k: v for k, v in kwargs.items() if v is not None}
from shared.infrastructure.data_client import fetch_data
return await fetch_data(service, query_name, params=params or None)
async def _io_action(
args: list[Any], kwargs: dict[str, Any], ctx: RequestContext
) -> Any:
"""``(action "service" "action-name" :key val ...)`` → call_action."""
if len(args) < 2:
raise ValueError("action requires service and action name")
service = str(args[0])
action_name = str(args[1])
payload = {k: v for k, v in kwargs.items() if v is not None}
from shared.infrastructure.actions import call_action
return await call_action(service, action_name, payload=payload or None)
async def _io_current_user(
args: list[Any], kwargs: dict[str, Any], ctx: RequestContext
) -> dict[str, Any] | None:
"""``(current-user)`` → user dict from request context."""
return ctx.user
async def _io_htmx_request(
args: list[Any], kwargs: dict[str, Any], ctx: RequestContext
) -> bool:
"""``(htmx-request?)`` → True if HX-Request header present."""
return ctx.is_htmx
# ---------------------------------------------------------------------------
# Handler registry
# ---------------------------------------------------------------------------
_IO_HANDLERS: dict[str, Any] = {
"frag": _io_frag,
"query": _io_query,
"action": _io_action,
"current-user": _io_current_user,
"htmx-request?": _io_htmx_request,
}

101
shared/sx/relations.py Normal file
View File

@@ -0,0 +1,101 @@
"""
Relation registry — declarative entity relationship definitions.
Relations are defined as s-expressions using ``defrelation`` and stored
in a global registry. All services load the same definitions at startup
via ``load_relation_registry()``.
"""
from __future__ import annotations
from shared.sx.types import RelationDef
# ---------------------------------------------------------------------------
# Registry
# ---------------------------------------------------------------------------
_RELATION_REGISTRY: dict[str, RelationDef] = {}
def register_relation(defn: RelationDef) -> None:
"""Add a RelationDef to the global registry."""
_RELATION_REGISTRY[defn.name] = defn
def get_relation(name: str) -> RelationDef | None:
"""Look up a relation by name (e.g. ``"page->market"``)."""
return _RELATION_REGISTRY.get(name)
def relations_from(entity_type: str) -> list[RelationDef]:
"""All relations where *entity_type* is the ``from`` side."""
return [d for d in _RELATION_REGISTRY.values() if d.from_type == entity_type]
def relations_to(entity_type: str) -> list[RelationDef]:
"""All relations where *entity_type* is the ``to`` side."""
return [d for d in _RELATION_REGISTRY.values() if d.to_type == entity_type]
def all_relations() -> list[RelationDef]:
"""Return all registered relations."""
return list(_RELATION_REGISTRY.values())
def clear_registry() -> None:
"""Clear all registered relations (for testing)."""
_RELATION_REGISTRY.clear()
# ---------------------------------------------------------------------------
# Built-in relation definitions (s-expression source)
# ---------------------------------------------------------------------------
_BUILTIN_RELATIONS = '''
(begin
(defrelation :page->market
:from "page"
:to "market"
:cardinality :one-to-many
:inverse :market->page
:nav :submenu
:nav-icon "fa fa-shopping-bag"
:nav-label "markets")
(defrelation :page->calendar
:from "page"
:to "calendar"
:cardinality :one-to-many
:inverse :calendar->page
:nav :submenu
:nav-icon "fa fa-calendar"
:nav-label "calendars")
(defrelation :post->calendar_entry
:from "post"
:to "calendar_entry"
:cardinality :many-to-many
:inverse :calendar_entry->post
:nav :inline
:nav-icon "fa fa-file-alt"
:nav-label "events")
(defrelation :page->menu_node
:from "page"
:to "menu_node"
:cardinality :one-to-one
:nav :hidden)
)
'''
def load_relation_registry() -> None:
"""Parse built-in defrelation s-expressions and populate the registry."""
from shared.sx.evaluator import evaluate
from shared.sx.parser import parse
tree = parse(_BUILTIN_RELATIONS)
evaluate(tree)

196
shared/sx/resolver.py Normal file
View File

@@ -0,0 +1,196 @@
"""
Async resolver — walks an s-expression tree, fetches I/O in parallel,
substitutes results, and renders to HTML.
This is the DAG execution engine applied to page rendering. The strategy:
1. **Walk** the parsed tree and identify I/O nodes (``frag``, ``query``,
``action``, ``current-user``, ``htmx-request?``).
2. **Group** independent fetches.
3. **Dispatch** via ``asyncio.gather()`` for maximum parallelism.
4. **Substitute** resolved values back into the tree.
5. **Render** the fully-resolved tree to HTML via the HTML renderer.
Usage::
from shared.sx import parse
from shared.sx.resolver import resolve, RequestContext
expr = parse('''
(div :class "page"
(h1 "Blog")
(raw! (frag "blog" "link-card" :slug "apple")))
''')
ctx = RequestContext(user=current_user, is_htmx=is_htmx_request())
html = await resolve(expr, ctx=ctx, env={})
"""
from __future__ import annotations
import asyncio
from typing import Any
from .types import Component, Keyword, Lambda, NIL, Symbol
from .evaluator import _eval
from .html import render as html_render, _RawHTML
from .primitives_io import (
IO_PRIMITIVES,
RequestContext,
execute_io,
)
# ---------------------------------------------------------------------------
# Public API
# ---------------------------------------------------------------------------
async def resolve(
expr: Any,
*,
ctx: RequestContext | None = None,
env: dict[str, Any] | None = None,
) -> str:
"""Resolve an s-expression tree and render to HTML.
1. Collect all I/O nodes from the tree.
2. Execute them in parallel.
3. Substitute results.
4. Render to HTML.
"""
if ctx is None:
ctx = RequestContext()
if env is None:
env = {}
# Resolve I/O nodes (may require multiple passes if I/O results
# contain further I/O references, though typically one pass suffices).
resolved = await _resolve_tree(expr, env, ctx)
# Render the fully-resolved tree to HTML
return html_render(resolved, env)
# ---------------------------------------------------------------------------
# Tree walker — collect, fetch, substitute
# ---------------------------------------------------------------------------
async def _resolve_tree(
expr: Any,
env: dict[str, Any],
ctx: RequestContext,
max_depth: int = 5,
) -> Any:
"""Resolve I/O nodes in the tree. Loops up to *max_depth* passes
in case resolved values introduce new I/O nodes."""
resolved = expr
for _ in range(max_depth):
# Collect I/O nodes
io_nodes: list[_IONode] = []
_collect_io(resolved, env, io_nodes)
if not io_nodes:
break # nothing to fetch
# Execute all I/O in parallel
results = await asyncio.gather(
*[_execute_node(node, ctx) for node in io_nodes],
return_exceptions=True,
)
# Build substitution map (node id → result)
for node, result in zip(io_nodes, results):
if isinstance(result, BaseException):
# On error, substitute empty string (graceful degradation)
node.result = ""
else:
node.result = result
# Substitute results back into tree
resolved = _substitute(resolved, env, {id(n.expr): n for n in io_nodes})
return resolved
class _IONode:
"""A collected I/O node from the tree."""
__slots__ = ("name", "args", "kwargs", "expr", "result")
def __init__(self, name: str, args: list[Any], kwargs: dict[str, Any], expr: list):
self.name = name
self.args = args
self.kwargs = kwargs
self.expr = expr # original list reference for identity-based substitution
self.result: Any = None
def _collect_io(
expr: Any,
env: dict[str, Any],
out: list[_IONode],
) -> None:
"""Walk the tree and collect I/O nodes into *out*."""
if not isinstance(expr, list) or not expr:
return
head = expr[0]
if isinstance(head, Symbol) and head.name in IO_PRIMITIVES:
# Parse args and kwargs from the rest of the expression
args, kwargs = _parse_io_args(expr[1:], env)
out.append(_IONode(head.name, args, kwargs, expr))
return # don't recurse into I/O node children
# Recurse into children
for child in expr:
if isinstance(child, list):
_collect_io(child, env, out)
def _parse_io_args(
exprs: list[Any],
env: dict[str, Any],
) -> tuple[list[Any], dict[str, Any]]:
"""Split I/O node arguments into positional args and keyword kwargs.
Evaluates each argument value so variables/expressions are resolved
before the I/O call.
"""
args: list[Any] = []
kwargs: dict[str, Any] = {}
i = 0
while i < len(exprs):
item = exprs[i]
if isinstance(item, Keyword) and i + 1 < len(exprs):
kwargs[item.name] = _eval(exprs[i + 1], env)
i += 2
else:
args.append(_eval(item, env))
i += 1
return args, kwargs
async def _execute_node(node: _IONode, ctx: RequestContext) -> Any:
"""Execute a single I/O node."""
return await execute_io(node.name, node.args, node.kwargs, ctx)
def _substitute(
expr: Any,
env: dict[str, Any],
node_map: dict[int, _IONode],
) -> Any:
"""Replace I/O nodes in the tree with their resolved results."""
if not isinstance(expr, list) or not expr:
return expr
# Check if this exact list is an I/O node
node = node_map.get(id(expr))
if node is not None:
result = node.result
# Fragment results are HTML strings — wrap as _RawHTML to prevent escaping
if node.name == "frag" and isinstance(result, str):
return _RawHTML(result)
return result
# Recurse into children
return [_substitute(child, env, node_map) for child in expr]

View File

@@ -0,0 +1,44 @@
(defcomp ~post-card (&key title slug href feature-image excerpt
status published-at updated-at publish-requested
hx-select like widgets at-bar)
(article :class "border-b pb-6 last:border-b-0 relative"
(when like like)
(a :href href
:sx-get href
:sx-target "#main-panel"
:sx-select hx-select
:sx-swap "outerHTML"
:sx-push-url "true"
:class "block rounded-xl bg-white shadow hover:shadow-md transition overflow-hidden"
(header :class "mb-2 text-center"
(h2 :class "text-4xl font-bold text-stone-900" title)
(cond
(= status "draft")
(begin
(div :class "flex justify-center gap-2 mt-1"
(span :class "inline-block px-2 py-0.5 rounded-full text-xs font-semibold bg-amber-100 text-amber-800" "Draft")
(when publish-requested
(span :class "inline-block px-2 py-0.5 rounded-full text-xs font-semibold bg-blue-100 text-blue-800" "Publish requested")))
(when updated-at
(p :class "text-sm text-stone-500" (str "Updated: " updated-at))))
published-at
(p :class "text-sm text-stone-500" (str "Published: " published-at))))
(when feature-image
(div :class "mb-4"
(img :src feature-image :alt "" :class "rounded-lg w-full object-cover")))
(when excerpt
(p :class "text-stone-700 text-lg leading-relaxed text-center" excerpt)))
(when widgets widgets)
(when at-bar at-bar)))
(defcomp ~order-summary-card (&key order-id created-at description status currency total-amount)
(div :class "rounded-2xl border border-stone-200 bg-white/80 p-4 sm:p-6 space-y-2 text-xs sm:text-sm text-stone-800"
(p (span :class "font-medium" "Order ID:") " " (span :class "font-mono" (str "#" order-id)))
(p (span :class "font-medium" "Created:") " " (or created-at "\u2014"))
(p (span :class "font-medium" "Description:") " " (or description "\u2013"))
(p (span :class "font-medium" "Status:") " " (~status-pill :status (or status "pending") :size "[11px]"))
(p (span :class "font-medium" "Currency:") " " (or currency "GBP"))
(p (span :class "font-medium" "Total:") " "
(if total-amount
(str (or currency "GBP") " " total-amount)
"\u2013"))))

View File

@@ -0,0 +1,98 @@
(defcomp ~search-mobile (&key current-local-href search search-count hx-select search-headers-mobile)
(div :id "search-mobile-wrapper"
:class "flex flex-row gap-2 items-center flex-1 min-w-0 pr-2"
(input :id "search-mobile"
:type "text" :name "search" :aria-label "search"
:class "text-base md:text-sm col-span-5 rounded-md px-3 py-2 mb-2 w-full min-w-0 max-w-full border-2 border-stone-200 placeholder-shown:border-stone-200 [&:not(:placeholder-shown)]:border-yellow-200"
:sx-preserve true
:value (or search "")
:placeholder "search"
:sx-trigger "input changed delay:300ms"
:sx-target "#main-panel"
:sx-select (str (or hx-select "#main-panel") ", #search-mobile-wrapper, #search-desktop-wrapper")
:sx-get current-local-href
:sx-swap "outerHTML"
:sx-push-url "true"
:sx-headers search-headers-mobile
:sx-sync "this:replace"
:autocomplete "off")
(div :id "search-count-mobile" :aria-label "search count"
:class (if (not search-count) "text-xl text-red-500" "")
(when search (str search-count)))))
(defcomp ~search-desktop (&key current-local-href search search-count hx-select search-headers-desktop)
(div :id "search-desktop-wrapper"
:class "flex flex-row gap-2 items-center"
(input :id "search-desktop"
:type "text" :name "search" :aria-label "search"
:class "w-full mx-1 my-3 px-3 py-2 text-md rounded-xl border-2 shadow-sm border-white placeholder-shown:border-white [&:not(:placeholder-shown)]:border-yellow-200"
:sx-preserve true
:value (or search "")
:placeholder "search"
:sx-trigger "input changed delay:300ms"
:sx-target "#main-panel"
:sx-select (str (or hx-select "#main-panel") ", #search-mobile-wrapper, #search-desktop-wrapper")
:sx-get current-local-href
:sx-swap "outerHTML"
:sx-push-url "true"
:sx-headers search-headers-desktop
:sx-sync "this:replace"
:autocomplete "off")
(div :id "search-count-desktop" :aria-label "search count"
:class (if (not search-count) "text-xl text-red-500" "")
(when search (str search-count)))))
(defcomp ~mobile-filter (&key filter-summary action-buttons filter-details)
(details :class "group/filter p-2 md:hidden" :data-toggle-group "mobile-panels"
(summary :class "bg-white/90"
(div :class "flex flex-row items-start"
(div
(div :class "md:hidden mx-2 bg-stone-200 rounded"
(span :class "flex items-center justify-center text-stone-600 text-lg h-12 w-12 transition-transform group-open/filter:hidden self-start"
(i :class "fa-solid fa-filter"))
(span
(svg :aria-hidden "true" :viewBox "0 0 24 24"
:class "w-12 h-12 rotate-180 transition-transform group-open/filter:block hidden self-start"
(path :d "M6 9l6 6 6-6" :fill "currentColor")))))
(div :id "filter-summary-mobile"
:class "flex-1 md:hidden grid grid-cols-12 items-center gap-3"
(div :class "flex flex-col items-start gap-2"
(when filter-summary filter-summary)))))
(when action-buttons action-buttons)
(div :id "filter-details-mobile" :style "display:contents"
(when filter-details filter-details))))
(defcomp ~infinite-scroll (&key url page total-pages id-prefix colspan)
(if (< page total-pages)
(tr :id (str id-prefix "-sentinel-" page)
:sx-get url
:sx-trigger "intersect once delay:250ms"
:sx-swap "outerHTML"
:sx-retry "exponential:1000:30000"
:role "status" :aria-live "polite" :aria-hidden "true"
(td :colspan colspan :class "px-3 py-4"
(div :class "block md:hidden h-[60vh] js-mobile-sentinel"
(div :class "sx-indicator js-loading text-center text-xs text-stone-400"
(str "loading\u2026 " page " / " total-pages))
(div :class "js-neterr hidden flex h-full items-center justify-center"))
(div :class "hidden md:block h-[30vh] js-desktop-sentinel"
(div :class "sx-indicator js-loading text-center text-xs text-stone-400"
(str "loading\u2026 " page " / " total-pages))
(div :class "js-neterr hidden inset-0 grid place-items-center p-4"))))
(tr (td :colspan colspan :class "px-3 py-4 text-center text-xs text-stone-400"
"End of results"))))
(defcomp ~status-pill (&key status size)
(let* ((s (or status "pending"))
(lower (lower s))
(sz (or size "xs"))
(colours (cond
(= lower "paid") "border-emerald-300 bg-emerald-50 text-emerald-700"
(= lower "confirmed") "border-emerald-300 bg-emerald-50 text-emerald-700"
(= lower "checked_in") "border-blue-300 bg-blue-50 text-blue-700"
(or (= lower "failed") (= lower "cancelled")) "border-rose-300 bg-rose-50 text-rose-700"
(= lower "provisional") "border-amber-300 bg-amber-50 text-amber-700"
(= lower "ordered") "border-blue-300 bg-blue-50 text-blue-700"
true "border-stone-300 bg-stone-50 text-stone-700")))
(span :class (str "inline-flex items-center rounded-full border px-2 py-0.5 text-" sz " font-medium " colours)
s)))

View File

@@ -0,0 +1,73 @@
(defcomp ~link-card (&key link title image icon subtitle detail data-app)
(a :href link
:class "block rounded border border-stone-200 bg-white hover:bg-stone-50 transition-colors no-underline"
:data-fragment "link-card"
:data-app data-app
:sx-disable true
(div :class "flex flex-row items-start gap-3 p-3"
(if image
(img :src image :alt "" :class "flex-shrink-0 w-16 h-16 rounded object-cover")
(div :class "flex-shrink-0 w-16 h-16 rounded bg-stone-100 flex items-center justify-center text-stone-400"
(i :class icon)))
(div :class "flex-1 min-w-0"
(div :class "font-medium text-stone-900 text-sm clamp-2" title)
(when subtitle
(div :class "text-xs text-stone-500 mt-0.5" subtitle))
(when detail
(div :class "text-xs text-stone-400 mt-1" detail))))))
(defcomp ~cart-mini (&key cart-count blog-url cart-url oob)
(div :id "cart-mini"
:sx-swap-oob oob
(if (= cart-count 0)
(div :class "h-12 w-12 rounded-full overflow-hidden border border-stone-300 flex-shrink-0"
(a :href blog-url
:class "h-full w-full font-bold text-5xl flex-shrink-0 flex flex-row items-center gap-1"
(img :src (str blog-url "static/img/logo.jpg")
:class "h-full w-full rounded-full object-cover border border-stone-300 flex-shrink-0")))
(a :href cart-url
:sx-get cart-url
:sx-target "#main-panel" :sx-swap "outerHTML" :sx-push-url "true"
:class "relative inline-flex items-center justify-center text-stone-700 hover:text-emerald-700"
(i :class "fa fa-shopping-cart text-5xl" :aria-hidden "true")
(span :class "absolute top-1/2 left-1/2 -translate-x-1/2 -translate-y-1/2 inline-flex items-center justify-center rounded-full bg-emerald-600 text-white text-sm w-5 h-5"
cart-count)))))
(defcomp ~auth-menu (&key user-email account-url)
(<>
(span :id "auth-menu-desktop" :class "hidden md:inline-flex"
(if user-email
(a :href account-url
:sx-get account-url
:sx-target "#main-panel" :sx-swap "outerHTML" :sx-push-url "true"
:class "justify-center cursor-pointer flex flex-row items-center p-3 gap-2 rounded bg-stone-200 text-black"
:data-close-details true
(i :class "fa-solid fa-user")
(span user-email))
(a :href account-url
:sx-get account-url
:sx-target "#main-panel" :sx-swap "outerHTML" :sx-push-url "true"
:class "justify-center cursor-pointer flex flex-row items-center p-3 gap-2 rounded bg-stone-200 text-black"
:data-close-details true
(i :class "fa-solid fa-key")
(span "sign in or register"))))
(span :id "auth-menu-mobile" :class "block md:hidden text-md font-bold"
(if user-email
(a :href account-url
:sx-get account-url
:sx-target "#main-panel" :sx-swap "outerHTML" :sx-push-url "true"
:data-close-details true
(i :class "fa-solid fa-user")
(span user-email))
(a :href account-url
:sx-get account-url
:sx-target "#main-panel" :sx-swap "outerHTML" :sx-push-url "true"
(i :class "fa-solid fa-key")
(span "sign in or register"))))))
(defcomp ~account-nav-item (&key href label)
(div :class "relative nav-group"
(a :href href
:class "justify-center cursor-pointer flex flex-row items-center gap-2 rounded bg-stone-200 text-black p-3"
:sx-disable true
label)))

View File

@@ -0,0 +1,139 @@
(defcomp ~app-body (&key header-rows filter aside menu content)
(div :class "max-w-screen-2xl mx-auto py-1 px-1"
(div :class "w-full"
(details :class "group/root p-2" :data-toggle-group "mobile-panels"
(summary
(header :class "z-50"
(div :id "root-header-summary"
:class "flex items-start gap-2 p-1 bg-sky-500"
(div :class "flex flex-col w-full items-center"
(when header-rows header-rows)))))
(div :id "root-menu" :sx-swap-oob "outerHTML" :class "md:hidden"
(when menu menu))))
(div :id "filter"
(when filter filter))
(main :id "root-panel" :class "max-w-full"
(div :class "md:min-h-0"
(div :class "flex flex-row md:h-full md:min-h-0"
(aside :id "aside"
:class "hidden md:flex md:flex-col max-w-xs md:h-full md:min-h-0 mr-3"
(when aside aside))
(section :id "main-panel"
:class "flex-1 md:h-full md:min-h-0 overflow-y-auto overscroll-contain js-grid-viewport"
(when content content)
(div :class "pb-8")))))))
(defcomp ~oob-sx (&key oobs filter aside menu content)
(<>
(when oobs oobs)
(div :id "filter" :sx-swap-oob "outerHTML"
(when filter filter))
(aside :id "aside" :sx-swap-oob "outerHTML"
:class "hidden md:flex md:flex-col max-w-xs md:h-full md:min-h-0 mr-3"
(when aside aside))
(div :id "root-menu" :sx-swap-oob "outerHTML" :class "md:hidden"
(when menu menu))
(section :id "main-panel"
:class "flex-1 md:h-full md:min-h-0 overflow-y-auto overscroll-contain js-grid-viewport"
(when content content))))
(defcomp ~hamburger ()
(div :class "md:hidden bg-stone-200 rounded"
(svg :class "h-12 w-12 transition-transform group-open/root:hidden block self-start"
:viewBox "0 0 24 24" :fill "none" :stroke "currentColor"
(path :stroke-linecap "round" :stroke-linejoin "round" :stroke-width "2"
:d "M4 6h16M4 12h16M4 18h16"))
(svg :aria-hidden "true" :viewBox "0 0 24 24"
:class "w-12 h-12 rotate-180 transition-transform group-open/root:block hidden self-start"
(path :d "M6 9l6 6 6-6" :fill "currentColor"))))
(defcomp ~post-label (&key feature-image title)
(<> (when feature-image
(img :src feature-image :class "h-8 w-8 rounded-full object-cover border border-stone-300 flex-shrink-0"))
(span title)))
(defcomp ~page-cart-badge (&key href count)
(a :href href :class "relative inline-flex items-center gap-1.5 px-3 py-1.5 text-sm rounded-full border border-emerald-300 bg-emerald-50 text-emerald-800 hover:bg-emerald-100 transition"
(i :class "fa fa-shopping-cart" :aria-hidden "true")
(span count)))
(defcomp ~header-row-sx (&key cart-mini blog-url site-title app-label
nav-tree auth-menu nav-panel
settings-url is-admin oob)
(<>
(div :id "root-row"
:sx-swap-oob (if oob "outerHTML" nil)
:class "flex flex-col items-center md:flex-row justify-center md:justify-between w-full p-1 bg-sky-500"
(div :class "w-full flex flex-row items-top"
(when cart-mini cart-mini)
(div :class "font-bold text-5xl flex-1"
(a :href (or blog-url "/") :class "flex justify-center md:justify-start items-baseline gap-2"
(h1 (or site-title ""))))
(nav :class "hidden md:flex gap-4 text-sm ml-2 justify-end items-center flex-0"
(when nav-tree nav-tree)
(when auth-menu auth-menu)
(when nav-panel nav-panel)
(when (and is-admin settings-url)
(a :href settings-url :class "justify-center cursor-pointer flex flex-row items-center gap-2 rounded bg-stone-200 text-black p-3"
(i :class "fa fa-cog" :aria-hidden "true"))))
(~hamburger)))
(div :class "block md:hidden text-md font-bold"
(when auth-menu auth-menu))))
(defcomp ~menu-row-sx (&key id level colour link-href link-label link-label-content icon
hx-select nav child-id child oob external)
(let* ((c (or colour "sky"))
(lv (or level 1))
(shade (str (- 500 (* lv 100)))))
(<>
(div :id id
:sx-swap-oob (if oob "outerHTML" nil)
:class (str "flex flex-col items-center md:flex-row justify-center md:justify-between w-full p-1 bg-" c "-" shade)
(div :class "relative nav-group"
(a :href link-href
:sx-get (if external nil link-href)
:sx-target (if external nil "#main-panel")
:sx-select (if external nil (or hx-select "#main-panel"))
:sx-swap (if external nil "outerHTML")
:sx-push-url (if external nil "true")
:class "w-full whitespace-normal flex items-center gap-2 font-bold text-2xl px-3 py-2"
(when icon (i :class icon :aria-hidden "true"))
(if link-label-content link-label-content
(when link-label (div link-label)))))
(when nav
(nav :class "hidden md:flex gap-4 text-sm ml-2 justify-end items-center flex-0"
nav)))
(when (and child-id (not oob))
(div :id child-id :class "flex flex-col w-full items-center"
(when child child))))))
(defcomp ~oob-header-sx (&key parent-id child-id row)
(div :id parent-id :sx-swap-oob "outerHTML" :class "w-full"
(div :class "w-full" row
(div :id child-id))))
(defcomp ~header-child-sx (&key id inner)
(div :id (or id "root-header-child") :class "w-full" inner))
(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)
(div :class "text-stone-600 mb-4" message)
(when image
(div :class "flex justify-center"
(img :src image :width "300" :height "300")))))
(defcomp ~nav-link (&key href hx-select label icon aclass select-colours is-selected)
(div :class "relative nav-group"
(a :href href
:sx-get href
:sx-target "#main-panel"
:sx-select (or hx-select "#main-panel")
:sx-swap "outerHTML"
:sx-push-url "true"
:aria-selected (when is-selected "true")
:class (or aclass
(str "justify-center cursor-pointer flex flex-row items-center gap-2 rounded bg-stone-200 text-black p-3 "
(or select-colours "")))
(when icon (i :class icon :aria-hidden "true"))
(when label (span label)))))

View File

@@ -0,0 +1,35 @@
;; Miscellaneous shared components for Phase 3 conversion
;; The single place where raw! lives — for CMS content (Ghost post body,
;; product descriptions, etc.) that arrives as pre-rendered HTML.
(defcomp ~rich-text (&key html)
(raw! html))
(defcomp ~error-inline (&key message)
(div :class "text-red-600 text-sm" message))
(defcomp ~notification-badge (&key count)
(span :class "bg-red-500 text-white text-xs rounded-full px-1.5 py-0.5" count))
(defcomp ~cache-cleared (&key time-str)
(span :class "text-green-600 font-bold" "Cache cleared at " time-str))
(defcomp ~error-list (&key items)
(ul :class "list-disc pl-5 space-y-1 text-sm text-red-600"
(when items items)))
(defcomp ~error-list-item (&key message)
(li message))
(defcomp ~fragment-error (&key service)
(p :class "text-sm text-red-600" "Service " (b service) " is unavailable."))
(defcomp ~htmx-sentinel (&key id hx-get hx-trigger hx-swap class extra-attrs)
(div :id id :sx-get hx-get :sx-trigger hx-trigger :sx-swap hx-swap :class class))
(defcomp ~nav-group-link (&key href hx-select nav-class label)
(div :class "relative nav-group"
(a :href href :sx-get href :sx-target "#main-panel"
:sx-select hx-select :sx-swap "outerHTML"
:sx-push-url "true" :class nav-class
label)))

View File

@@ -0,0 +1,24 @@
(defcomp ~calendar-entry-nav (&key href name date-str nav-class)
(a :href href :class nav-class
(div :class "w-8 h-8 rounded bg-stone-200 flex-shrink-0")
(div :class "flex-1 min-w-0"
(div :class "font-medium truncate" name)
(div :class "text-xs text-stone-600 truncate" date-str))))
(defcomp ~calendar-link-nav (&key href name nav-class)
(a :href href :class nav-class
(i :class "fa fa-calendar" :aria-hidden "true")
(div name)))
(defcomp ~market-link-nav (&key href name nav-class)
(a :href href :class nav-class
(i :class "fa fa-shopping-bag" :aria-hidden "true")
(div name)))
(defcomp ~relation-nav (&key href name icon nav-class relation-type)
(a :href href :class (or nav-class "flex items-center gap-3 rounded-lg py-2 px-3 text-sm text-stone-700 hover:bg-stone-100 transition-colors")
(when icon
(div :class "w-8 h-8 rounded bg-stone-200 flex items-center justify-center flex-shrink-0"
(i :class icon :aria-hidden "true")))
(div :class "flex-1 min-w-0"
(div :class "font-medium truncate" name))))

View File

@@ -0,0 +1,25 @@
(defcomp ~base-shell (&key title asset-url &rest children)
(<>
(raw! "<!doctype html>")
(html :lang "en"
(head
(meta :charset "utf-8")
(meta :name "viewport" :content "width=device-width, initial-scale=1")
(title title)
(style
"body{margin:0;min-height:100vh;display:flex;align-items:center;"
"justify-content:center;font-family:system-ui,sans-serif;"
"background:#fafaf9;color:#1c1917}")
(script :src "https://cdn.tailwindcss.com")
(link :rel "stylesheet" :href (str asset-url "/fontawesome/css/all.min.css")))
(body :class "bg-stone-50 text-stone-900"
children))))
(defcomp ~error-page (&key title message image asset-url)
(~base-shell :title title :asset-url asset-url
(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"
(div message))
(when image
(div :class "flex justify-center"
(img :src image :width "300" :height "300"))))))

View File

@@ -0,0 +1,15 @@
(defcomp ~relation-attach (&key create-url label icon)
(a :href create-url
:sx-get create-url
:sx-target "#main-panel"
:sx-swap "outerHTML"
:sx-push-url "true"
:class "flex items-center gap-2 text-sm p-2 rounded hover:bg-stone-100 text-stone-500 hover:text-stone-700 transition-colors"
(when icon (i :class icon))
(span (or label "Add"))))
(defcomp ~relation-detach (&key detach-url name)
(button :sx-delete detach-url
:sx-confirm (str "Remove " (or name "this item") "?")
:class "text-red-500 hover:text-red-700 text-sm p-1 rounded hover:bg-red-50 transition-colors"
(i :class "fa fa-times" :aria-hidden "true")))

View File

View File

@@ -0,0 +1,350 @@
"""Tests for shared s-expression components (Phase 5)."""
import pytest
from shared.sx.jinja_bridge import sx, _COMPONENT_ENV
from shared.sx.components import load_shared_components
@pytest.fixture(autouse=True)
def _load_components():
"""Ensure all shared components are registered for every test."""
_COMPONENT_ENV.clear()
load_shared_components()
# ---------------------------------------------------------------------------
# ~cart-mini
# ---------------------------------------------------------------------------
class TestCartMini:
def test_empty_cart_shows_logo(self):
html = sx(
'(~cart-mini :cart-count cart-count :blog-url blog-url :cart-url cart-url)',
**{"cart-count": 0, "blog-url": "https://blog.example.com/", "cart-url": "https://cart.example.com/"},
)
assert 'id="cart-mini"' in html
assert "logo.jpg" in html
assert "blog.example.com/" in html
assert "fa-shopping-cart" not in html
def test_nonempty_cart_shows_badge(self):
html = sx(
'(~cart-mini :cart-count cart-count :blog-url blog-url :cart-url cart-url)',
**{"cart-count": 3, "blog-url": "https://blog.example.com/", "cart-url": "https://cart.example.com/"},
)
assert 'id="cart-mini"' in html
assert "fa-shopping-cart" in html
assert "bg-emerald-600" in html
assert ">3<" in html
assert "cart.example.com/" in html
def test_oob_attribute(self):
html = sx(
'(~cart-mini :cart-count 0 :blog-url "" :cart-url "" :oob "true")',
)
assert 'sx-swap-oob="true"' in html
def test_no_oob_when_nil(self):
html = sx(
'(~cart-mini :cart-count 0 :blog-url "" :cart-url "")',
)
assert "sx-swap-oob" not in html
# ---------------------------------------------------------------------------
# ~auth-menu
# ---------------------------------------------------------------------------
class TestAuthMenu:
def test_logged_in(self):
html = sx(
'(~auth-menu :user-email user-email :account-url account-url)',
**{"user-email": "alice@example.com", "account-url": "https://account.example.com/"},
)
assert 'id="auth-menu-desktop"' in html
assert 'id="auth-menu-mobile"' in html
assert "alice@example.com" in html
assert "fa-solid fa-user" in html
assert "sign in or register" not in html
def test_logged_out(self):
html = sx(
'(~auth-menu :account-url account-url)',
**{"account-url": "https://account.example.com/"},
)
assert "fa-solid fa-key" in html
assert "sign in or register" in html
def test_desktop_has_data_close_details(self):
html = sx(
'(~auth-menu :user-email "x@y.com" :account-url "http://a")',
)
assert "data-close-details" in html
def test_two_spans_always_present(self):
"""Both desktop and mobile spans are always rendered."""
for email in ["user@test.com", None]:
html = sx(
'(~auth-menu :user-email user-email :account-url account-url)',
**{"user-email": email, "account-url": "http://a"},
)
assert 'id="auth-menu-desktop"' in html
assert 'id="auth-menu-mobile"' in html
# ---------------------------------------------------------------------------
# ~account-nav-item
# ---------------------------------------------------------------------------
class TestAccountNavItem:
def test_renders_link(self):
html = sx(
'(~account-nav-item :href "/orders/" :label "orders")',
)
assert 'href="/orders/"' in html
assert ">orders<" in html
assert "nav-group" in html
assert "sx-disable" in html
def test_custom_label(self):
html = sx(
'(~account-nav-item :href "/cart/orders/" :label "my orders")',
)
assert ">my orders<" in html
# ---------------------------------------------------------------------------
# ~calendar-entry-nav
# ---------------------------------------------------------------------------
class TestCalendarEntryNav:
def test_renders_entry(self):
html = sx(
'(~calendar-entry-nav :href "/events/entry/1/" :name "Workshop" :date-str "Jan 15, 2026 at 14:00" :nav-class "btn")',
**{"date-str": "Jan 15, 2026 at 14:00", "nav-class": "btn"},
)
assert 'href="/events/entry/1/"' in html
assert "Workshop" in html
assert "Jan 15, 2026 at 14:00" in html
# ---------------------------------------------------------------------------
# ~calendar-link-nav
# ---------------------------------------------------------------------------
class TestCalendarLinkNav:
def test_renders_calendar_link(self):
html = sx(
'(~calendar-link-nav :href "/events/cal/" :name "Art Events" :nav-class "btn")',
**{"nav-class": "btn"},
)
assert 'href="/events/cal/"' in html
assert "fa fa-calendar" in html
assert "Art Events" in html
# ---------------------------------------------------------------------------
# ~market-link-nav
# ---------------------------------------------------------------------------
class TestMarketLinkNav:
def test_renders_market_link(self):
html = sx(
'(~market-link-nav :href "/market/farm/" :name "Farm Shop" :nav-class "btn")',
**{"nav-class": "btn"},
)
assert 'href="/market/farm/"' in html
assert "fa fa-shopping-bag" in html
assert "Farm Shop" in html
# ---------------------------------------------------------------------------
# ~post-card
# ---------------------------------------------------------------------------
class TestPostCard:
def test_basic_card(self):
html = sx(
'(~post-card :title "Hello World" :slug "hello" :href "/hello/"'
' :feature-image "/img/hello.jpg" :excerpt "A test post"'
' :status "published" :published-at "15 Jan 2026"'
' :hx-select "#main-panel")',
**{
"feature-image": "/img/hello.jpg",
"hx-select": "#main-panel",
"published-at": "15 Jan 2026",
},
)
assert "<article" in html
assert "Hello World" in html
assert 'href="/hello/"' in html
assert '<img src="/img/hello.jpg"' in html
assert "A test post" in html
def test_draft_status(self):
html = sx(
'(~post-card :title "Draft" :slug "draft" :href "/draft/"'
' :status "draft" :updated-at "15 Jan 2026"'
' :hx-select "#main-panel")',
**{"hx-select": "#main-panel", "updated-at": "15 Jan 2026"},
)
assert "Draft" in html
assert "bg-amber-100" in html
assert "Updated:" in html
def test_draft_with_publish_requested(self):
html = sx(
'(~post-card :title "Pending" :slug "pending" :href "/pending/"'
' :status "draft" :publish-requested true'
' :hx-select "#main-panel")',
**{"hx-select": "#main-panel", "publish-requested": True},
)
assert "Publish requested" in html
assert "bg-blue-100" in html
def test_no_image(self):
html = sx(
'(~post-card :title "No Img" :slug "no-img" :href "/no-img/"'
' :status "published" :hx-select "#main-panel")',
**{"hx-select": "#main-panel"},
)
assert "<img" not in html
def test_widgets_and_at_bar(self):
"""Widgets and at-bar are sx kwarg slots rendered by the client."""
html = sx(
'(~post-card :title "T" :slug "s" :href "/"'
' :status "published" :hx-select "#mp")',
**{"hx-select": "#mp"},
)
# Basic render without widgets/at-bar should still work
assert "<article" in html
assert "T" in html
# ---------------------------------------------------------------------------
# ~base-shell and ~error-page
# ---------------------------------------------------------------------------
class TestBaseShell:
def test_renders_full_page(self):
html = sx(
'(~base-shell :title "Test" :asset-url "/static" (p "Hello"))',
**{"asset-url": "/static"},
)
assert "<!doctype html>" in html
assert "<html" in html
assert "<title>Test</title>" in html
assert "<p>Hello</p>" in html
assert "tailwindcss" in html
class TestErrorPage:
def test_404_page(self):
html = sx(
'(~error-page :title "404 Error" :message "NOT FOUND" :image "/static/errors/404.gif" :asset-url "/static")',
**{"asset-url": "/static"},
)
assert "<!doctype html>" in html
assert "NOT FOUND" in html
assert "text-red-500" in html
assert "/static/errors/404.gif" in html
def test_error_page_no_image(self):
html = sx(
'(~error-page :title "500 Error" :message "SERVER ERROR" :asset-url "/static")',
**{"asset-url": "/static"},
)
assert "SERVER ERROR" in html
assert "<img" not in html
# ---------------------------------------------------------------------------
# ~relation-nav
# ---------------------------------------------------------------------------
class TestRelationNav:
def test_renders_link(self):
html = sx(
'(~relation-nav :href "/market/farm/" :name "Farm Shop" :icon "fa fa-shopping-bag")',
)
assert 'href="/market/farm/"' in html
assert "Farm Shop" in html
assert "fa fa-shopping-bag" in html
def test_no_icon(self):
html = sx(
'(~relation-nav :href "/cal/" :name "Events")',
)
assert 'href="/cal/"' in html
assert "Events" in html
assert "fa " not in html
def test_custom_nav_class(self):
html = sx(
'(~relation-nav :href "/" :name "X" :nav-class "custom-class")',
**{"nav-class": "custom-class"},
)
assert 'class="custom-class"' in html
# ---------------------------------------------------------------------------
# ~relation-attach
# ---------------------------------------------------------------------------
class TestRelationAttach:
def test_renders_button(self):
html = sx(
'(~relation-attach :create-url "/market/create/" :label "Add Market" :icon "fa fa-plus")',
**{"create-url": "/market/create/"},
)
assert 'href="/market/create/"' in html
assert 'sx-get="/market/create/"' in html
assert "Add Market" in html
assert "fa fa-plus" in html
def test_default_label(self):
html = sx(
'(~relation-attach :create-url "/create/")',
**{"create-url": "/create/"},
)
assert "Add" in html
# ---------------------------------------------------------------------------
# ~relation-detach
# ---------------------------------------------------------------------------
class TestRelationDetach:
def test_renders_button(self):
html = sx(
'(~relation-detach :detach-url "/api/unrelate" :name "Farm Shop")',
**{"detach-url": "/api/unrelate"},
)
assert 'sx-delete="/api/unrelate"' in html
assert 'sx-confirm="Remove Farm Shop?"' in html
assert "fa fa-times" in html
def test_default_name(self):
html = sx(
'(~relation-detach :detach-url "/api/unrelate")',
**{"detach-url": "/api/unrelate"},
)
assert "this item" in html
# ---------------------------------------------------------------------------
# render_page() helper
# ---------------------------------------------------------------------------
class TestRenderPage:
def test_render_page(self):
from shared.sx.page import render_page
html = render_page(
'(~error-page :title "Test" :message "MSG" :asset-url "/s")',
**{"asset-url": "/s"},
)
assert "<!doctype html>" in html
assert "MSG" in html

View File

@@ -0,0 +1,326 @@
"""Tests for the s-expression evaluator."""
import pytest
from shared.sx import parse, evaluate, EvalError, Symbol, Keyword, NIL
from shared.sx.types import Lambda, Component
# ---------------------------------------------------------------------------
# Helpers
# ---------------------------------------------------------------------------
def ev(text, env=None):
"""Parse and evaluate a single expression."""
return evaluate(parse(text), env)
# ---------------------------------------------------------------------------
# Literals and lookups
# ---------------------------------------------------------------------------
class TestLiterals:
def test_int(self):
assert ev("42") == 42
def test_string(self):
assert ev('"hello"') == "hello"
def test_true(self):
assert ev("true") is True
def test_nil(self):
assert ev("nil") is NIL
def test_symbol_lookup(self):
assert ev("x", {"x": 10}) == 10
def test_undefined_symbol(self):
with pytest.raises(EvalError, match="Undefined symbol"):
ev("xyz")
def test_keyword_evaluates_to_name(self):
assert ev(":foo") == "foo"
# ---------------------------------------------------------------------------
# Arithmetic
# ---------------------------------------------------------------------------
class TestArithmetic:
def test_add(self):
assert ev("(+ 1 2 3)") == 6
def test_sub(self):
assert ev("(- 10 3)") == 7
def test_negate(self):
assert ev("(- 5)") == -5
def test_mul(self):
assert ev("(* 2 3 4)") == 24
def test_div(self):
assert ev("(/ 10 4)") == 2.5
def test_mod(self):
assert ev("(mod 7 3)") == 1
def test_clamp(self):
assert ev("(clamp 15 0 10)") == 10
assert ev("(clamp -5 0 10)") == 0
assert ev("(clamp 5 0 10)") == 5
# ---------------------------------------------------------------------------
# Comparison and predicates
# ---------------------------------------------------------------------------
class TestComparison:
def test_eq(self):
assert ev("(= 1 1)") is True
assert ev("(= 1 2)") is False
def test_lt_gt(self):
assert ev("(< 1 2)") is True
assert ev("(> 2 1)") is True
def test_predicates(self):
assert ev("(odd? 3)") is True
assert ev("(even? 4)") is True
assert ev("(zero? 0)") is True
assert ev("(nil? nil)") is True
assert ev('(string? "hi")') is True
assert ev("(number? 42)") is True
assert ev("(list? (list 1))") is True
assert ev("(dict? {:a 1})") is True
# ---------------------------------------------------------------------------
# Special forms
# ---------------------------------------------------------------------------
class TestSpecialForms:
def test_if_true(self):
assert ev("(if true 1 2)") == 1
def test_if_false(self):
assert ev("(if false 1 2)") == 2
def test_if_no_else(self):
assert ev("(if false 1)") is NIL
def test_when_true(self):
assert ev("(when true 42)") == 42
def test_when_false(self):
assert ev("(when false 42)") is NIL
def test_and_short_circuit(self):
assert ev("(and true true 3)") == 3
assert ev("(and true false 3)") is False
def test_or_short_circuit(self):
assert ev("(or false false 3)") == 3
assert ev("(or false 2 3)") == 2
def test_let_scheme_style(self):
assert ev("(let ((x 10) (y 20)) (+ x y))") == 30
def test_let_clojure_style(self):
assert ev("(let (x 10 y 20) (+ x y))") == 30
def test_let_sequential(self):
assert ev("(let ((x 1) (y (+ x 1))) y)") == 2
def test_begin(self):
assert ev("(begin 1 2 3)") == 3
def test_quote(self):
result = ev("(quote (a b c))")
assert result == [Symbol("a"), Symbol("b"), Symbol("c")]
def test_cond_clojure(self):
assert ev("(cond false 1 true 2 :else 3)") == 2
def test_cond_else(self):
assert ev("(cond false 1 false 2 :else 99)") == 99
def test_case(self):
assert ev('(case 2 1 "one" 2 "two" :else "other")') == "two"
def test_thread_first(self):
assert ev("(-> 5 (+ 3) (* 2))") == 16
def test_define(self):
env = {}
ev("(define x 42)", env)
assert env["x"] == 42
# ---------------------------------------------------------------------------
# Lambda
# ---------------------------------------------------------------------------
class TestLambda:
def test_create_and_call(self):
assert ev("((fn (x) (* x x)) 5)") == 25
def test_closure(self):
result = ev("(let ((a 10)) ((fn (x) (+ x a)) 5))")
assert result == 15
def test_higher_order(self):
result = ev("(let ((double (fn (x) (* x 2)))) (double 7))")
assert result == 14
# ---------------------------------------------------------------------------
# Collections
# ---------------------------------------------------------------------------
class TestCollections:
def test_list_constructor(self):
assert ev("(list 1 2 3)") == [1, 2, 3]
def test_dict_constructor(self):
assert ev("(dict :a 1 :b 2)") == {"a": 1, "b": 2}
def test_get_dict(self):
assert ev('(get {:a 1 :b 2} "a")') == 1
def test_get_list(self):
assert ev("(get (list 10 20 30) 1)") == 20
def test_first_last_rest(self):
assert ev("(first (list 1 2 3))") == 1
assert ev("(last (list 1 2 3))") == 3
assert ev("(rest (list 1 2 3))") == [2, 3]
def test_len(self):
assert ev("(len (list 1 2 3))") == 3
def test_concat(self):
assert ev("(concat (list 1 2) (list 3 4))") == [1, 2, 3, 4]
def test_cons(self):
assert ev("(cons 0 (list 1 2))") == [0, 1, 2]
def test_keys_vals(self):
assert ev("(keys {:a 1 :b 2})") == ["a", "b"]
assert ev("(vals {:a 1 :b 2})") == [1, 2]
def test_merge(self):
assert ev("(merge {:a 1} {:b 2} {:a 3})") == {"a": 3, "b": 2}
def test_assoc(self):
assert ev('(assoc {:a 1} :b 2)') == {"a": 1, "b": 2}
def test_dissoc(self):
assert ev('(dissoc {:a 1 :b 2} :a)') == {"b": 2}
def test_empty(self):
assert ev("(empty? (list))") is True
assert ev("(empty? (list 1))") is False
assert ev("(empty? nil)") is True
def test_contains(self):
assert ev('(contains? {:a 1} "a")') is True
assert ev("(contains? (list 1 2 3) 2)") is True
# ---------------------------------------------------------------------------
# Higher-order forms
# ---------------------------------------------------------------------------
class TestHigherOrder:
def test_map(self):
assert ev("(map (fn (x) (* x x)) (list 1 2 3 4))") == [1, 4, 9, 16]
def test_map_indexed(self):
result = ev("(map-indexed (fn (i x) (+ i x)) (list 10 20 30))")
assert result == [10, 21, 32]
def test_filter(self):
assert ev("(filter (fn (x) (> x 2)) (list 1 2 3 4))") == [3, 4]
def test_reduce(self):
assert ev("(reduce (fn (acc x) (+ acc x)) 0 (list 1 2 3))") == 6
def test_some(self):
assert ev("(some (fn (x) (if (> x 3) x nil)) (list 1 2 4 5))") == 4
def test_every(self):
assert ev("(every? (fn (x) (> x 0)) (list 1 2 3))") is True
assert ev("(every? (fn (x) (> x 2)) (list 1 2 3))") is False
# ---------------------------------------------------------------------------
# Strings
# ---------------------------------------------------------------------------
class TestStrings:
def test_str(self):
assert ev('(str "hello" " " "world")') == "hello world"
def test_str_numbers(self):
assert ev('(str "val=" 42)') == "val=42"
def test_upper_lower(self):
assert ev('(upper "hello")') == "HELLO"
assert ev('(lower "HELLO")') == "hello"
def test_join(self):
assert ev('(join ", " (list "a" "b" "c"))') == "a, b, c"
def test_split(self):
assert ev('(split "a,b,c" ",")') == ["a", "b", "c"]
# ---------------------------------------------------------------------------
# defcomp
# ---------------------------------------------------------------------------
class TestDefcomp:
def test_basic_component(self):
env = {}
ev("(defcomp ~card (&key title) title)", env)
assert isinstance(env["~card"], Component)
assert env["~card"].name == "card"
def test_component_call(self):
env = {}
ev("(defcomp ~greeting (&key name) (str \"Hello, \" name \"!\"))", env)
result = ev('(~greeting :name "Alice")', env)
assert result == "Hello, Alice!"
def test_component_with_children(self):
env = {}
ev("(defcomp ~wrapper (&key class &rest children) (list class children))", env)
result = ev('(~wrapper :class "box" 1 2 3)', env)
assert result == ["box", [1, 2, 3]]
def test_component_missing_kwarg_is_nil(self):
env = {}
ev("(defcomp ~opt (&key x y) (list x y))", env)
result = ev("(~opt :x 1)", env)
assert result == [1, NIL]
# ---------------------------------------------------------------------------
# Dict literal evaluation
# ---------------------------------------------------------------------------
class TestDictLiteral:
def test_dict_values_evaluated(self):
assert ev("{:a (+ 1 2) :b (* 3 4)}") == {"a": 3, "b": 12}
# ---------------------------------------------------------------------------
# set!
# ---------------------------------------------------------------------------
class TestSetBang:
def test_set_bang(self):
env = {"x": 1}
ev("(set! x 42)", env)
assert env["x"] == 42

View File

@@ -0,0 +1,365 @@
"""Tests for the HSX-style HTML renderer."""
import pytest
from shared.sx import parse, evaluate
from shared.sx.html import render, escape_text, escape_attr
# ---------------------------------------------------------------------------
# Helpers
# ---------------------------------------------------------------------------
def r(text, env=None):
"""Parse and render a single expression."""
return render(parse(text), env)
# ---------------------------------------------------------------------------
# Escaping
# ---------------------------------------------------------------------------
class TestEscaping:
def test_escape_text_ampersand(self):
assert escape_text("A & B") == "A &amp; B"
def test_escape_text_lt_gt(self):
assert escape_text("<script>") == "&lt;script&gt;"
def test_escape_attr_quotes(self):
assert escape_attr('he said "hi"') == "he said &quot;hi&quot;"
def test_escape_attr_all(self):
assert escape_attr('&<>"') == "&amp;&lt;&gt;&quot;"
# ---------------------------------------------------------------------------
# Primitives / atoms
# ---------------------------------------------------------------------------
class TestAtoms:
def test_string(self):
assert r('"Hello"') == "Hello"
def test_string_with_entities(self):
assert r('"<b>bold</b>"') == "&lt;b&gt;bold&lt;/b&gt;"
def test_integer(self):
assert r("42") == "42"
def test_float(self):
assert r("3.14") == "3.14"
def test_nil(self):
assert r("nil") == ""
def test_true(self):
assert r("true") == ""
def test_false(self):
assert r("false") == ""
def test_keyword(self):
assert r(":hello") == "hello"
# ---------------------------------------------------------------------------
# Simple HTML elements
# ---------------------------------------------------------------------------
class TestElements:
def test_div(self):
assert r('(div "Hello")') == "<div>Hello</div>"
def test_empty_div(self):
assert r("(div)") == "<div></div>"
def test_nested(self):
assert r('(div (p "Hello"))') == "<div><p>Hello</p></div>"
def test_multiple_children(self):
assert r('(ul (li "One") (li "Two"))') == \
"<ul><li>One</li><li>Two</li></ul>"
def test_mixed_text_and_elements(self):
assert r('(p "Hello " (strong "world"))') == \
"<p>Hello <strong>world</strong></p>"
def test_deep_nesting(self):
assert r('(div (div (div (span "deep"))))') == \
"<div><div><div><span>deep</span></div></div></div>"
# ---------------------------------------------------------------------------
# Attributes
# ---------------------------------------------------------------------------
class TestAttributes:
def test_single_attr(self):
assert r('(div :class "card")') == '<div class="card"></div>'
def test_multiple_attrs(self):
html = r('(div :class "card" :id "main")')
assert 'class="card"' in html
assert 'id="main"' in html
def test_attr_with_children(self):
assert r('(div :class "card" (p "Body"))') == \
'<div class="card"><p>Body</p></div>'
def test_attr_escaping(self):
assert r('(div :title "A & B")') == '<div title="A &amp; B"></div>'
def test_attr_with_quotes(self):
assert r("""(div :title "say \\"hi\\"")""") == \
'<div title="say &quot;hi&quot;"></div>'
def test_false_attr_omitted(self):
env = {"flag": False}
html = render(parse("(div :hidden flag)"), env)
assert html == "<div></div>"
def test_nil_attr_omitted(self):
env = {"flag": None}
html = render(parse("(div :data-x flag)"), env)
assert html == "<div></div>"
def test_numeric_attr(self):
assert r('(input :tabindex 1)') == '<input tabindex="1">'
# ---------------------------------------------------------------------------
# Boolean attributes
# ---------------------------------------------------------------------------
class TestBooleanAttrs:
def test_disabled_true(self):
env = {"yes": True}
html = render(parse("(button :disabled yes)"), env)
assert html == "<button disabled></button>"
def test_disabled_false(self):
env = {"no": False}
html = render(parse("(button :disabled no)"), env)
assert html == "<button></button>"
def test_checked(self):
env = {"c": True}
html = render(parse('(input :type "checkbox" :checked c)'), env)
assert 'checked' in html
assert 'type="checkbox"' in html
def test_required(self):
env = {"r": True}
html = render(parse("(input :required r)"), env)
assert "required" in html
# ---------------------------------------------------------------------------
# Void elements
# ---------------------------------------------------------------------------
class TestVoidElements:
def test_br(self):
assert r("(br)") == "<br>"
def test_img(self):
html = r('(img :src "/photo.jpg" :alt "Photo")')
assert html == '<img src="/photo.jpg" alt="Photo">'
def test_input(self):
html = r('(input :type "text" :name "q")')
assert html == '<input type="text" name="q">'
def test_hr(self):
assert r("(hr)") == "<hr>"
def test_meta(self):
html = r('(meta :charset "utf-8")')
assert html == '<meta charset="utf-8">'
def test_link(self):
html = r('(link :rel "stylesheet" :href "/s.css")')
assert html == '<link rel="stylesheet" href="/s.css">'
def test_void_no_closing_tag(self):
# Void elements must not have a closing tag
html = r("(br)")
assert "</br>" not in html
# ---------------------------------------------------------------------------
# Fragment (<>)
# ---------------------------------------------------------------------------
class TestFragments:
def test_basic_fragment(self):
assert r('(<> (li "A") (li "B"))') == "<li>A</li><li>B</li>"
def test_empty_fragment(self):
assert r("(<>)") == ""
def test_nested_fragment(self):
html = r('(ul (<> (li "1") (li "2")))')
assert html == "<ul><li>1</li><li>2</li></ul>"
# ---------------------------------------------------------------------------
# raw!
# ---------------------------------------------------------------------------
class TestRawHtml:
def test_raw_string(self):
assert r('(raw! "<b>bold</b>")') == "<b>bold</b>"
def test_raw_no_escaping(self):
html = r('(raw! "<script>alert(1)</script>")')
assert "<script>" in html
def test_raw_with_variable(self):
env = {"content": "<em>hi</em>"}
html = render(parse("(raw! content)"), env)
assert html == "<em>hi</em>"
# ---------------------------------------------------------------------------
# Components (defcomp / ~prefix)
# ---------------------------------------------------------------------------
class TestComponents:
def test_basic_component(self):
env = {}
evaluate(parse('(defcomp ~badge (&key label) (span :class "badge" label))'), env)
html = render(parse('(~badge :label "New")'), env)
assert html == '<span class="badge">New</span>'
def test_component_with_children(self):
env = {}
evaluate(parse(
'(defcomp ~card (&key title &rest children)'
' (div :class "card" (h2 title) children))'
), env)
html = render(parse('(~card :title "Hi" (p "Body"))'), env)
assert html == '<div class="card"><h2>Hi</h2><p>Body</p></div>'
def test_nested_components(self):
env = {}
evaluate(parse('(defcomp ~inner (&key text) (em text))'), env)
evaluate(parse(
'(defcomp ~outer (&key label)'
' (div (~inner :text label)))'
), env)
html = render(parse('(~outer :label "Hello")'), env)
assert html == "<div><em>Hello</em></div>"
def test_component_with_conditional(self):
env = {}
evaluate(parse(
'(defcomp ~maybe (&key show text)'
' (when show (span text)))'
), env)
assert render(parse('(~maybe :show true :text "Yes")'), env) == "<span>Yes</span>"
assert render(parse('(~maybe :show false :text "No")'), env) == ""
# ---------------------------------------------------------------------------
# Expressions in render context
# ---------------------------------------------------------------------------
class TestExpressions:
def test_let_in_render(self):
html = r('(let ((x "Hello")) (p x))')
assert html == "<p>Hello</p>"
def test_if_true_branch(self):
html = r('(if true (p "Yes") (p "No"))')
assert html == "<p>Yes</p>"
def test_if_false_branch(self):
html = r('(if false (p "Yes") (p "No"))')
assert html == "<p>No</p>"
def test_when_true(self):
html = r('(when true (p "Shown"))')
assert html == "<p>Shown</p>"
def test_when_false(self):
html = r('(when false (p "Hidden"))')
assert html == ""
def test_map_rendering(self):
html = r('(map (lambda (x) (li x)) (list "A" "B" "C"))')
assert html == "<li>A</li><li>B</li><li>C</li>"
def test_variable_in_element(self):
env = {"name": "World"}
html = render(parse('(p (str "Hello " name))'), env)
assert html == "<p>Hello World</p>"
def test_str_in_attr(self):
env = {"slug": "apple"}
html = render(parse('(a :href (str "/p/" slug) "Link")'), env)
assert html == '<a href="/p/apple">Link</a>'
# ---------------------------------------------------------------------------
# Full page structure
# ---------------------------------------------------------------------------
class TestFullPage:
def test_simple_page(self):
html = r('''
(html :lang "en"
(head (title "Test"))
(body (h1 "Hello")))
''')
assert '<html lang="en">' in html
assert "<title>Test</title>" in html
assert "<h1>Hello</h1>" in html
assert "</body>" in html
assert "</html>" in html
def test_form(self):
html = r('''
(form :method "post" :action "/login"
(label :for "user" "Username")
(input :type "text" :name "user" :required true)
(button :type "submit" "Login"))
''')
assert '<form method="post" action="/login">' in html
assert '<label for="user">Username</label>' in html
assert 'required' in html
assert "<button" in html
def test_table(self):
html = r('''
(table
(thead (tr (th "Name") (th "Price")))
(tbody
(tr (td "Apple") (td "1.50"))
(tr (td "Banana") (td "0.75"))))
''')
assert "<thead>" in html
assert "<th>Name</th>" in html
assert "<td>Apple</td>" in html
# ---------------------------------------------------------------------------
# Edge cases
# ---------------------------------------------------------------------------
class TestEdgeCases:
def test_empty_list(self):
assert render([], {}) == ""
def test_none(self):
assert render(None, {}) == ""
def test_dict_not_rendered(self):
assert render({"a": 1}, {}) == ""
def test_number_list(self):
# A list of plain numbers (not a call) renders each
assert render([1, 2, 3], {}) == "123"
def test_string_list(self):
assert render(["a", "b"], {}) == "ab"

View File

@@ -0,0 +1,201 @@
"""Tests for the Jinja ↔ s-expression bridge."""
import asyncio
from shared.sx.jinja_bridge import (
sx,
sx_async,
register_components,
get_component_env,
_COMPONENT_ENV,
)
# ---------------------------------------------------------------------------
# Helpers
# ---------------------------------------------------------------------------
def run(coro):
return asyncio.run(coro)
def setup_function():
"""Clear component env before each test."""
_COMPONENT_ENV.clear()
# ---------------------------------------------------------------------------
# sx() — synchronous rendering
# ---------------------------------------------------------------------------
class TestSx:
def test_simple_html(self):
assert sx('(div "Hello")') == "<div>Hello</div>"
def test_with_kwargs(self):
html = sx('(p name)', name="Alice")
assert html == "<p>Alice</p>"
def test_multiple_kwargs(self):
html = sx('(a :href url title)', url="/about", title="About")
assert html == '<a href="/about">About</a>'
def test_escaping(self):
html = sx('(p text)', text="<script>alert(1)</script>")
assert "&lt;script&gt;" in html
assert "<script>" not in html
def test_nested(self):
html = sx('(div :class "card" (h1 title))', title="Hi")
assert html == '<div class="card"><h1>Hi</h1></div>'
# ---------------------------------------------------------------------------
# register_components() + sx()
# ---------------------------------------------------------------------------
class TestComponents:
def test_register_and_use(self):
register_components('''
(defcomp ~badge (&key label)
(span :class "badge" label))
''')
html = sx('(~badge :label "New")')
assert html == '<span class="badge">New</span>'
def test_multiple_components(self):
register_components('''
(defcomp ~tag (&key text)
(span :class "tag" text))
(defcomp ~pill (&key text)
(span :class "pill" text))
''')
assert '<span class="tag">A</span>' == sx('(~tag :text "A")')
assert '<span class="pill">B</span>' == sx('(~pill :text "B")')
def test_component_with_children(self):
register_components('''
(defcomp ~box (&key title &rest children)
(div :class "box" (h2 title) children))
''')
html = sx('(~box :title "Box" (p "Content"))')
assert '<div class="box">' in html
assert "<h2>Box</h2>" in html
assert "<p>Content</p>" in html
def test_component_with_kwargs_override(self):
"""Kwargs passed to sx() are available alongside components."""
register_components('''
(defcomp ~greeting (&key name)
(p (str "Hello " name)))
''')
html = sx('(~greeting :name user)', user="Bob")
assert html == "<p>Hello Bob</p>"
def test_component_env_persists(self):
"""Components registered once are available in subsequent calls."""
register_components('(defcomp ~x (&key v) (b v))')
assert sx('(~x :v "1")') == "<b>1</b>"
assert sx('(~x :v "2")') == "<b>2</b>"
def test_get_component_env(self):
register_components('(defcomp ~foo (&key x) (span x))')
env = get_component_env()
assert "~foo" in env
# ---------------------------------------------------------------------------
# Link card example — the first migration target
# ---------------------------------------------------------------------------
class TestLinkCard:
def setup_method(self):
_COMPONENT_ENV.clear()
register_components('''
(defcomp ~link-card (&key link title image icon brand)
(a :href link
:class "block rounded border border-stone-200 bg-white hover:bg-stone-50 transition-colors no-underline"
(div :class "flex flex-row items-start gap-3 p-3"
(if image
(img :src image :alt "" :class "flex-shrink-0 w-16 h-16 rounded object-cover")
(div :class "flex-shrink-0 w-16 h-16 rounded bg-stone-100 flex items-center justify-center text-stone-400"
(i :class icon)))
(div :class "flex-1 min-w-0"
(div :class "font-medium text-stone-900 text-sm clamp-2" title)
(when brand
(div :class "text-xs text-stone-500 mt-0.5" brand))))))
''')
def test_with_image(self):
html = sx('''
(~link-card
:link "/products/apple/"
:title "Apple"
:image "/img/apple.jpg"
:icon "fas fa-shopping-bag")
''')
assert 'href="/products/apple/"' in html
assert '<img src="/img/apple.jpg"' in html
assert "Apple" in html
def test_without_image(self):
html = sx('''
(~link-card
:link "/posts/hello/"
:title "Hello World"
:icon "fas fa-file-alt")
''')
assert 'href="/posts/hello/"' in html
assert "<img" not in html
assert "fas fa-file-alt" in html
assert "Hello World" in html
def test_with_brand(self):
html = sx('''
(~link-card
:link "/p/x/"
:title "Widget"
:image "/img/w.jpg"
:brand "Acme Corp")
''')
assert "Acme Corp" in html
def test_without_brand(self):
html = sx('''
(~link-card
:link "/p/x/"
:title "Widget"
:image "/img/w.jpg")
''')
# brand div should not appear
assert "mt-0.5" not in html
def test_kwargs_from_python(self):
"""Pass data from Python (like a route handler would)."""
html = sx(
'(~link-card :link link :title title :image image :icon "fas fa-box")',
link="/products/banana/",
title="Banana",
image="/img/banana.jpg",
)
assert 'href="/products/banana/"' in html
assert "Banana" in html
# ---------------------------------------------------------------------------
# sx_async() — async rendering (no real I/O, just passthrough)
# ---------------------------------------------------------------------------
class TestSxAsync:
def test_simple(self):
html = run(sx_async('(div "Async")'))
assert html == "<div>Async</div>"
def test_with_kwargs(self):
html = run(sx_async('(p name)', name="Alice"))
assert html == "<p>Alice</p>"
def test_with_component(self):
register_components('(defcomp ~x (&key v) (b v))')
html = run(sx_async('(~x :v "OK")'))
assert html == "<b>OK</b>"

View File

@@ -0,0 +1,35 @@
"""Verify every .sx and .sx file in the repo parses without errors."""
import os
import pytest
from shared.sx.parser import parse_all
def _collect_sx_files():
"""Find all .sx and .sx files under the repo root."""
repo = os.path.dirname(os.path.dirname(os.path.dirname(os.path.dirname(
os.path.abspath(__file__)
))))
files = []
for dirpath, _dirs, filenames in os.walk(repo):
if "node_modules" in dirpath or ".git" in dirpath or "artdag" in dirpath:
continue
for fn in filenames:
if fn.endswith((".sx", ".sx")):
files.append(os.path.join(dirpath, fn))
return sorted(files)
_SX_FILES = _collect_sx_files()
@pytest.mark.parametrize("path", _SX_FILES, ids=[
os.path.relpath(p) for p in _SX_FILES
])
def test_parse(path):
"""Each sx file should parse without errors."""
with open(path) as f:
source = f.read()
exprs = parse_all(source)
assert len(exprs) > 0, f"{path} produced no expressions"

View File

@@ -0,0 +1,191 @@
"""Tests for the s-expression parser."""
import pytest
from shared.sx.parser import parse, parse_all, serialize, ParseError
from shared.sx.types import Symbol, Keyword, NIL
# ---------------------------------------------------------------------------
# Atoms
# ---------------------------------------------------------------------------
class TestAtoms:
def test_integer(self):
assert parse("42") == 42
def test_negative_integer(self):
assert parse("-7") == -7
def test_float(self):
assert parse("3.14") == 3.14
def test_scientific(self):
assert parse("1e-3") == 0.001
def test_string(self):
assert parse('"hello world"') == "hello world"
def test_string_escapes(self):
assert parse(r'"line1\nline2"') == "line1\nline2"
assert parse(r'"tab\there"') == "tab\there"
assert parse(r'"say \"hi\""') == 'say "hi"'
def test_symbol(self):
assert parse("foo") == Symbol("foo")
def test_component_symbol(self):
s = parse("~card")
assert s == Symbol("~card")
assert s.is_component
def test_keyword(self):
assert parse(":class") == Keyword("class")
def test_true(self):
assert parse("true") is True
def test_false(self):
assert parse("false") is False
def test_nil(self):
assert parse("nil") is NIL
# ---------------------------------------------------------------------------
# Lists
# ---------------------------------------------------------------------------
class TestLists:
def test_empty_list(self):
assert parse("()") == []
def test_simple_list(self):
assert parse("(1 2 3)") == [1, 2, 3]
def test_mixed_list(self):
result = parse('(div :class "main")')
assert result == [Symbol("div"), Keyword("class"), "main"]
def test_nested_list(self):
result = parse("(a (b c) d)")
assert result == [Symbol("a"), [Symbol("b"), Symbol("c")], Symbol("d")]
def test_vector_sugar(self):
assert parse("[1 2 3]") == [1, 2, 3]
# ---------------------------------------------------------------------------
# Maps
# ---------------------------------------------------------------------------
class TestMaps:
def test_simple_map(self):
result = parse('{:a 1 :b 2}')
assert result == {"a": 1, "b": 2}
def test_nested_map(self):
result = parse('{:x {:y 3}}')
assert result == {"x": {"y": 3}}
def test_string_keys(self):
result = parse('{"name" "alice"}')
assert result == {"name": "alice"}
# ---------------------------------------------------------------------------
# Comments
# ---------------------------------------------------------------------------
class TestComments:
def test_line_comment(self):
assert parse("; comment\n42") == 42
def test_inline_comment(self):
result = parse("(a ; stuff\nb)")
assert result == [Symbol("a"), Symbol("b")]
# ---------------------------------------------------------------------------
# parse_all
# ---------------------------------------------------------------------------
class TestParseAll:
def test_multiple(self):
results = parse_all("1 2 3")
assert results == [1, 2, 3]
def test_multiple_lists(self):
results = parse_all("(a) (b)")
assert results == [[Symbol("a")], [Symbol("b")]]
def test_empty(self):
assert parse_all("") == []
assert parse_all(" ; only comments\n") == []
# ---------------------------------------------------------------------------
# Errors
# ---------------------------------------------------------------------------
class TestErrors:
def test_unterminated_list(self):
with pytest.raises(ParseError):
parse("(a b")
def test_unterminated_string(self):
with pytest.raises(ParseError):
parse('"hello')
def test_unexpected_closer(self):
with pytest.raises(ParseError):
parse(")")
def test_trailing_content(self):
with pytest.raises(ParseError):
parse("1 2")
# ---------------------------------------------------------------------------
# Serialization
# ---------------------------------------------------------------------------
class TestSerialize:
def test_int(self):
assert serialize(42) == "42"
def test_float(self):
assert serialize(3.14) == "3.14"
def test_string(self):
assert serialize("hello") == '"hello"'
def test_string_escapes(self):
assert serialize('say "hi"') == '"say \\"hi\\""'
def test_symbol(self):
assert serialize(Symbol("foo")) == "foo"
def test_keyword(self):
assert serialize(Keyword("class")) == ":class"
def test_bool(self):
assert serialize(True) == "true"
assert serialize(False) == "false"
def test_nil(self):
assert serialize(None) == "nil"
assert serialize(NIL) == "nil"
def test_list(self):
assert serialize([Symbol("a"), 1, 2]) == "(a 1 2)"
def test_empty_list(self):
assert serialize([]) == "()"
def test_dict(self):
result = serialize({"a": 1, "b": 2})
assert result == "{:a 1 :b 2}"
def test_roundtrip(self):
original = '(div :class "main" (p "hello") (span 42))'
assert serialize(parse(original)) == original

View File

@@ -0,0 +1,245 @@
"""Tests for the relation registry (Phase A)."""
import pytest
from shared.sx.evaluator import evaluate, EvalError
from shared.sx.parser import parse
from shared.sx.relations import (
_RELATION_REGISTRY,
clear_registry,
get_relation,
load_relation_registry,
relations_from,
relations_to,
all_relations,
)
from shared.sx.types import RelationDef
@pytest.fixture(autouse=True)
def _clean_registry():
"""Clear registry before each test."""
clear_registry()
# ---------------------------------------------------------------------------
# defrelation parsing
# ---------------------------------------------------------------------------
class TestDefrelation:
def test_basic_defrelation(self):
tree = parse('''
(defrelation :page->market
:from "page"
:to "market"
:cardinality :one-to-many
:inverse :market->page
:nav :submenu
:nav-icon "fa fa-shopping-bag"
:nav-label "markets")
''')
result = evaluate(tree)
assert isinstance(result, RelationDef)
assert result.name == "page->market"
assert result.from_type == "page"
assert result.to_type == "market"
assert result.cardinality == "one-to-many"
assert result.inverse == "market->page"
assert result.nav == "submenu"
assert result.nav_icon == "fa fa-shopping-bag"
assert result.nav_label == "markets"
def test_defrelation_registered(self):
tree = parse('''
(defrelation :a->b
:from "a" :to "b" :cardinality :one-to-one :nav :hidden)
''')
evaluate(tree)
assert get_relation("a->b") is not None
assert get_relation("a->b").cardinality == "one-to-one"
def test_defrelation_one_to_one(self):
tree = parse('''
(defrelation :page->menu_node
:from "page" :to "menu_node"
:cardinality :one-to-one :nav :hidden)
''')
result = evaluate(tree)
assert result.cardinality == "one-to-one"
assert result.inverse is None
assert result.nav == "hidden"
def test_defrelation_many_to_many(self):
tree = parse('''
(defrelation :post->entry
:from "post" :to "calendar_entry"
:cardinality :many-to-many
:inverse :entry->post
:nav :inline
:nav-icon "fa fa-file-alt"
:nav-label "events")
''')
result = evaluate(tree)
assert result.cardinality == "many-to-many"
def test_default_nav_is_hidden(self):
tree = parse('''
(defrelation :x->y
:from "x" :to "y" :cardinality :one-to-many)
''')
result = evaluate(tree)
assert result.nav == "hidden"
def test_invalid_cardinality_raises(self):
tree = parse('''
(defrelation :bad
:from "a" :to "b" :cardinality :wrong)
''')
with pytest.raises(EvalError, match="invalid cardinality"):
evaluate(tree)
def test_invalid_nav_raises(self):
tree = parse('''
(defrelation :bad
:from "a" :to "b" :cardinality :one-to-one :nav :bogus)
''')
with pytest.raises(EvalError, match="invalid nav"):
evaluate(tree)
def test_missing_from_raises(self):
tree = parse('''
(defrelation :bad :to "b" :cardinality :one-to-one)
''')
with pytest.raises(EvalError, match="missing required :from"):
evaluate(tree)
def test_missing_to_raises(self):
tree = parse('''
(defrelation :bad :from "a" :cardinality :one-to-one)
''')
with pytest.raises(EvalError, match="missing required :to"):
evaluate(tree)
def test_missing_cardinality_raises(self):
tree = parse('''
(defrelation :bad :from "a" :to "b")
''')
with pytest.raises(EvalError, match="missing required :cardinality"):
evaluate(tree)
def test_name_must_be_keyword(self):
tree = parse('(defrelation "not-keyword" :from "a" :to "b" :cardinality :one-to-one)')
with pytest.raises(EvalError, match="must be a keyword"):
evaluate(tree)
# ---------------------------------------------------------------------------
# Registry queries
# ---------------------------------------------------------------------------
class TestRegistry:
def _load_sample(self):
tree = parse('''
(begin
(defrelation :page->market
:from "page" :to "market" :cardinality :one-to-many
:nav :submenu :nav-icon "fa fa-shopping-bag" :nav-label "markets")
(defrelation :page->calendar
:from "page" :to "calendar" :cardinality :one-to-many
:nav :submenu :nav-icon "fa fa-calendar" :nav-label "calendars")
(defrelation :post->entry
:from "post" :to "calendar_entry" :cardinality :many-to-many
:nav :inline)
(defrelation :page->menu_node
:from "page" :to "menu_node" :cardinality :one-to-one
:nav :hidden))
''')
evaluate(tree)
def test_get_relation(self):
self._load_sample()
rel = get_relation("page->market")
assert rel is not None
assert rel.to_type == "market"
def test_get_relation_not_found(self):
assert get_relation("nonexistent") is None
def test_relations_from_page(self):
self._load_sample()
rels = relations_from("page")
names = {r.name for r in rels}
assert names == {"page->market", "page->calendar", "page->menu_node"}
def test_relations_from_post(self):
self._load_sample()
rels = relations_from("post")
assert len(rels) == 1
assert rels[0].name == "post->entry"
def test_relations_from_empty(self):
self._load_sample()
assert relations_from("nonexistent") == []
def test_relations_to_market(self):
self._load_sample()
rels = relations_to("market")
assert len(rels) == 1
assert rels[0].name == "page->market"
def test_relations_to_calendar_entry(self):
self._load_sample()
rels = relations_to("calendar_entry")
assert len(rels) == 1
assert rels[0].name == "post->entry"
def test_all_relations(self):
self._load_sample()
assert len(all_relations()) == 4
def test_clear_registry(self):
self._load_sample()
assert len(all_relations()) == 4
clear_registry()
assert len(all_relations()) == 0
# ---------------------------------------------------------------------------
# load_relation_registry() — built-in definitions
# ---------------------------------------------------------------------------
class TestLoadBuiltins:
def test_loads_builtin_relations(self):
load_relation_registry()
assert get_relation("page->market") is not None
assert get_relation("page->calendar") is not None
assert get_relation("post->calendar_entry") is not None
assert get_relation("page->menu_node") is not None
def test_builtin_page_market(self):
load_relation_registry()
rel = get_relation("page->market")
assert rel.from_type == "page"
assert rel.to_type == "market"
assert rel.cardinality == "one-to-many"
assert rel.inverse == "market->page"
assert rel.nav == "submenu"
assert rel.nav_icon == "fa fa-shopping-bag"
def test_builtin_post_entry(self):
load_relation_registry()
rel = get_relation("post->calendar_entry")
assert rel.cardinality == "many-to-many"
assert rel.nav == "inline"
def test_builtin_page_menu_node(self):
load_relation_registry()
rel = get_relation("page->menu_node")
assert rel.cardinality == "one-to-one"
assert rel.nav == "hidden"
def test_frozen_dataclass(self):
load_relation_registry()
rel = get_relation("page->market")
with pytest.raises(AttributeError):
rel.name = "changed"

View File

@@ -0,0 +1,320 @@
"""Tests for the async resolver.
Uses asyncio.run() directly — no pytest-asyncio dependency needed.
Mocks execute_io at the resolver boundary to avoid infrastructure imports.
"""
import asyncio
from unittest.mock import AsyncMock, patch
import pytest
from shared.sx import parse, evaluate
from shared.sx.resolver import resolve, _collect_io, _IONode
from shared.sx.primitives_io import RequestContext, execute_io
# ---------------------------------------------------------------------------
# Helpers
# ---------------------------------------------------------------------------
def run(coro):
"""Run an async coroutine synchronously."""
return asyncio.run(coro)
async def r(text, env=None, ctx=None):
"""Parse and resolve a single expression."""
return await resolve(parse(text), ctx=ctx, env=env)
def mock_io(**responses):
"""Patch execute_io to return canned responses by primitive name.
Usage::
with mock_io(frag='<b>Card</b>', query={"title": "Apple"}):
html = run(r('...'))
For dynamic responses, pass a callable::
async def frag_handler(args, kwargs, ctx):
return f"<b>{args[1]}</b>"
with mock_io(frag=frag_handler):
...
"""
async def side_effect(name, args, kwargs, ctx):
val = responses.get(name)
if val is None:
# Delegate to real handler for context primitives
if name == "current-user":
return ctx.user
if name == "htmx-request?":
return ctx.is_htmx
return None
if callable(val) and asyncio.iscoroutinefunction(val):
return await val(args, kwargs, ctx)
if callable(val):
return val(args, kwargs, ctx)
return val
return patch("shared.sx.resolver.execute_io", side_effect=side_effect)
# ---------------------------------------------------------------------------
# Basic rendering (no I/O) — resolver should pass through to HTML renderer
# ---------------------------------------------------------------------------
class TestPassthrough:
def test_simple_html(self):
assert run(r('(div "Hello")')) == "<div>Hello</div>"
def test_nested_html(self):
assert run(r('(div (p "World"))')) == "<div><p>World</p></div>"
def test_with_env(self):
assert run(r('(p name)', env={"name": "Alice"})) == "<p>Alice</p>"
def test_component(self):
env = {}
evaluate(parse('(defcomp ~tag (&key label) (span :class "tag" label))'), env)
assert run(r('(~tag :label "New")', env=env)) == '<span class="tag">New</span>'
# ---------------------------------------------------------------------------
# I/O node collection
# ---------------------------------------------------------------------------
class TestCollectIO:
def test_finds_frag(self):
expr = parse('(div (frag "blog" "link-card" :slug "apple"))')
nodes: list[_IONode] = []
_collect_io(expr, {}, nodes)
assert len(nodes) == 1
assert nodes[0].name == "frag"
def test_finds_query(self):
expr = parse('(div (query "market" "products" :ids "1,2"))')
nodes: list[_IONode] = []
_collect_io(expr, {}, nodes)
assert len(nodes) == 1
assert nodes[0].name == "query"
def test_finds_multiple(self):
expr = parse('''
(div
(frag "blog" "card" :slug "a")
(query "market" "products" :ids "1"))
''')
nodes: list[_IONode] = []
_collect_io(expr, {}, nodes)
assert len(nodes) == 2
def test_finds_current_user(self):
expr = parse('(div (current-user))')
nodes: list[_IONode] = []
_collect_io(expr, {}, nodes)
assert len(nodes) == 1
assert nodes[0].name == "current-user"
def test_finds_htmx_request(self):
expr = parse('(div (htmx-request?))')
nodes: list[_IONode] = []
_collect_io(expr, {}, nodes)
assert len(nodes) == 1
assert nodes[0].name == "htmx-request?"
def test_no_io_nodes(self):
expr = parse('(div (p "Hello"))')
nodes: list[_IONode] = []
_collect_io(expr, {}, nodes)
assert len(nodes) == 0
def test_evaluates_kwargs(self):
expr = parse('(query "market" "products" :slug slug)')
env = {"slug": "apple"}
nodes: list[_IONode] = []
_collect_io(expr, env, nodes)
assert len(nodes) == 1
assert nodes[0].kwargs["slug"] == "apple"
def test_positional_args_evaluated(self):
expr = parse('(frag app frag_type)')
env = {"app": "blog", "frag_type": "card"}
nodes: list[_IONode] = []
_collect_io(expr, env, nodes)
assert nodes[0].args == ["blog", "card"]
# ---------------------------------------------------------------------------
# Fragment resolution (mocked)
# ---------------------------------------------------------------------------
class TestFragResolution:
def test_frag_substitution(self):
"""Fragment result is substituted as raw HTML."""
with mock_io(frag='<a href="/apple">Apple</a>'):
html = run(r('(div (frag "blog" "link-card" :slug "apple"))'))
assert '<a href="/apple">Apple</a>' in html
assert "&lt;" not in html # should NOT be escaped
def test_frag_with_surrounding(self):
"""Fragment result sits alongside static HTML."""
with mock_io(frag="<span>Card</span>"):
html = run(r('(div (h1 "Title") (frag "blog" "card" :slug "x"))'))
assert "<h1>Title</h1>" in html
assert "<span>Card</span>" in html
def test_frag_params_forwarded(self):
"""Keyword args are forwarded to the I/O handler."""
received = {}
async def capture_frag(args, kwargs, ctx):
received.update(kwargs)
return "<b>ok</b>"
with mock_io(frag=capture_frag):
run(r('(frag "blog" "card" :slug "apple" :size "large")'))
assert received == {"slug": "apple", "size": "large"}
# ---------------------------------------------------------------------------
# Query resolution (mocked)
# ---------------------------------------------------------------------------
class TestQueryResolution:
def test_query_result_dict(self):
"""Query returning a dict renders as empty (dicts aren't renderable)."""
with mock_io(query={"title": "Apple"}):
html = run(r('(query "market" "product" :slug "apple")'))
assert html == ""
def test_query_returns_list(self):
"""Query returning a list of strings renders them."""
with mock_io(query=["Apple", "Banana"]):
html = run(r('(query "market" "product-names")'))
assert "Apple" in html
assert "Banana" in html
# ---------------------------------------------------------------------------
# Parallel I/O
# ---------------------------------------------------------------------------
class TestParallelIO:
def test_parallel_fetches(self):
"""Multiple I/O nodes are fetched concurrently."""
call_count = {"n": 0}
async def counting_frag(args, kwargs, ctx):
call_count["n"] += 1
await asyncio.sleep(0.01)
return f"<div>{args[1]}</div>"
with mock_io(frag=counting_frag):
html = run(r('''
(div
(frag "blog" "card-a")
(frag "blog" "card-b")
(frag "blog" "card-c"))
'''))
assert "<div>card-a</div>" in html
assert "<div>card-b</div>" in html
assert "<div>card-c</div>" in html
assert call_count["n"] == 3
# ---------------------------------------------------------------------------
# Request context primitives
# ---------------------------------------------------------------------------
class TestRequestContext:
def test_current_user(self):
user = {"id": 1, "name": "Alice"}
ctx = RequestContext(user=user)
result = run(execute_io("current-user", [], {}, ctx))
assert result == user
def test_htmx_true(self):
ctx = RequestContext(is_htmx=True)
assert run(execute_io("htmx-request?", [], {}, ctx)) is True
def test_htmx_false(self):
ctx = RequestContext(is_htmx=False)
assert run(execute_io("htmx-request?", [], {}, ctx)) is False
def test_no_user(self):
ctx = RequestContext()
assert run(execute_io("current-user", [], {}, ctx)) is None
# ---------------------------------------------------------------------------
# Error handling
# ---------------------------------------------------------------------------
class TestErrorHandling:
def test_frag_error_degrades_gracefully(self):
"""Failed I/O substitutes empty string, doesn't crash."""
async def failing_frag(args, kwargs, ctx):
raise ConnectionError("connection refused")
with mock_io(frag=failing_frag):
html = run(r('(div (h1 "Title") (frag "blog" "broken"))'))
assert "<h1>Title</h1>" in html
assert "<div>" in html
def test_query_error_degrades_gracefully(self):
"""Failed query substitutes empty string."""
async def failing_query(args, kwargs, ctx):
raise TimeoutError("timeout")
with mock_io(query=failing_query):
html = run(r('(div (p "Static") (query "market" "broken"))'))
assert "<p>Static</p>" in html
# ---------------------------------------------------------------------------
# Mixed static + I/O
# ---------------------------------------------------------------------------
class TestMixedContent:
def test_static_and_frag(self):
with mock_io(frag="<span>Dynamic</span>"):
html = run(r('''
(div
(h1 "Static Title")
(p "Static body")
(frag "blog" "widget"))
'''))
assert "<h1>Static Title</h1>" in html
assert "<p>Static body</p>" in html
assert "<span>Dynamic</span>" in html
def test_multiple_frag_types(self):
"""Different fragment types in one tree."""
async def dynamic_frag(args, kwargs, ctx):
return f"<b>{args[1]}</b>"
with mock_io(frag=dynamic_frag):
html = run(r('''
(div
(frag "blog" "header")
(frag "market" "sidebar"))
'''))
assert "<b>header</b>" in html
assert "<b>sidebar</b>" in html
def test_frag_and_query_together(self):
"""Tree with both frag and query nodes."""
async def mock_handler(args, kwargs, ctx):
name = args[1] if len(args) > 1 else "?"
return f"<i>{name}</i>"
with mock_io(frag=mock_handler, query="data"):
html = run(r('''
(div
(frag "blog" "card")
(query "market" "stats"))
'''))
assert "<i>card</i>" in html
assert "data" in html

View File

@@ -0,0 +1,233 @@
"""Test sx.js string renderer matches Python renderer output.
Runs sx.js through Node.js and compares output with Python.
"""
from __future__ import annotations
import json
import subprocess
from pathlib import Path
import pytest
from shared.sx.parser import parse, parse_all
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"
def _js_render(sx_text: str, components_text: str = "") -> str:
"""Run sx.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()}
if ({json.dumps(components_text)}) Sx.loadComponents({json.dumps(components_text)});
var result = Sx.renderToString({json.dumps(sx_text)});
process.stdout.write(result);
"""
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
class TestParserParity:
"""Parser produces equivalent structures."""
def test_simple_element(self):
assert _js_render('(div "hello")') == '<div>hello</div>'
def test_nested_elements(self):
html = _js_render('(div :class "card" (p "text"))')
assert html == '<div class="card"><p>text</p></div>'
def test_void_element(self):
assert _js_render('(img :src "a.jpg")') == '<img src="a.jpg">'
assert _js_render('(br)') == '<br>'
def test_boolean_attr(self):
assert _js_render('(input :disabled true :type "text")') == '<input disabled type="text">'
def test_nil_attr_omitted(self):
assert _js_render('(div :class nil "hi")') == '<div>hi</div>'
def test_false_attr_omitted(self):
assert _js_render('(div :class false "hi")') == '<div>hi</div>'
def test_numbers(self):
assert _js_render('(span 42)') == '<span>42</span>'
def test_escaping(self):
html = _js_render('(div "<script>alert(1)</script>")')
assert "&lt;script&gt;" in html
class TestSpecialForms:
"""Special forms render correctly."""
def test_if_true(self):
assert _js_render('(if true (span "yes") (span "no"))') == '<span>yes</span>'
def test_if_false(self):
assert _js_render('(if false (span "yes") (span "no"))') == '<span>no</span>'
def test_if_nil(self):
assert _js_render('(if nil (span "yes") (span "no"))') == '<span>no</span>'
def test_when_true(self):
assert _js_render('(when true (span "yes"))') == '<span>yes</span>'
def test_when_false(self):
assert _js_render('(when false (span "yes"))') == ''
def test_str(self):
assert _js_render('(div (str "a" "b" "c"))') == '<div>abc</div>'
def test_fragment(self):
assert _js_render('(<> (span "a") (span "b"))') == '<span>a</span><span>b</span>'
def test_let(self):
assert _js_render('(let ((x "hello")) (div x))') == '<div>hello</div>'
def test_let_clojure_style(self):
assert _js_render('(let (x "hello" y "world") (div (str x " " y)))') == '<div>hello world</div>'
def test_and(self):
assert _js_render('(when (and true true) (span "ok"))') == '<span>ok</span>'
assert _js_render('(when (and true false) (span "ok"))') == ''
def test_or(self):
assert _js_render('(div (or nil "fallback"))') == '<div>fallback</div>'
class TestComponents:
"""Component definition and rendering."""
CARD = '(defcomp ~card (&key title) (div :class "card" (h2 title)))'
def test_simple_component(self):
html = _js_render('(~card :title "Hello")', self.CARD)
assert html == '<div class="card"><h2>Hello</h2></div>'
def test_component_with_children(self):
comp = '(defcomp ~box (&key &rest children) (div :class "box" (raw! children)))'
html = _js_render('(~box (p "inside"))', comp)
assert html == '<div class="box"><p>inside</p></div>'
def test_component_with_conditional(self):
comp = '(defcomp ~badge (&key show label) (when show (span label)))'
assert _js_render('(~badge :show true :label "ok")', comp) == '<span>ok</span>'
assert _js_render('(~badge :show false :label "ok")', comp) == ''
def test_nested_components(self):
comps = """
(defcomp ~inner (&key text) (span text))
(defcomp ~outer (&key label) (div (~inner :text label)))
"""
html = _js_render('(~outer :label "hi")', comps)
assert html == '<div><span>hi</span></div>'
class TestClientComponentsTag:
"""client_components_tag() generates valid sx for JS consumption."""
def test_emits_script_tag(self):
from shared.sx.jinja_bridge import client_components_tag, register_components, _COMPONENT_ENV
# Register a test component
register_components('(defcomp ~test-cct (&key label) (span label))')
try:
tag = client_components_tag("test-cct")
assert tag.startswith('<script type="text/sx" data-components>')
assert tag.endswith('</script>')
assert "defcomp ~test-cct" in tag
finally:
_COMPONENT_ENV.pop("~test-cct", None)
def test_roundtrip_through_js(self):
"""Component emitted by client_components_tag renders identically in JS."""
from shared.sx.jinja_bridge import client_components_tag, register_components, _COMPONENT_ENV
register_components('(defcomp ~test-rt (&key title) (div :class "rt" title))')
try:
tag = client_components_tag("test-rt")
# Extract the sx source from the script tag
sx_source = tag.replace('<script type="text/sx" data-components>', '').replace('</script>', '')
js_html = _js_render('(~test-rt :title "hello")', sx_source)
py_html = py_render(parse('(~test-rt :title "hello")'), _COMPONENT_ENV)
assert js_html == py_html
finally:
_COMPONENT_ENV.pop("~test-rt", None)
class TestPythonParity:
"""JS string renderer matches Python renderer output."""
CASES = [
'(div :class "main" (p "hello"))',
'(div (if true "yes" "no"))',
'(div (when false "hidden"))',
'(span (str "a" "-" "b"))',
'(<> (div "one") (div "two"))',
'(ul (li "a") (li "b") (li "c"))',
'(input :type "text" :disabled true :value "x")',
'(div :class nil :id "ok" "text")',
'(img :src "photo.jpg" :alt "A photo")',
'(table (tr (td "cell")))',
]
@pytest.mark.parametrize("sx_text", CASES)
def test_matches_python(self, sx_text):
py_html = py_render(parse(sx_text))
js_html = _js_render(sx_text)
assert js_html == py_html, f"Mismatch for {sx_text!r}:\n PY: {py_html!r}\n JS: {js_html!r}"
COMP_CASES = [
(
'(defcomp ~tag (&key label colour) (span :class (str "tag-" colour) label))',
'(~tag :label "new" :colour "red")',
),
(
'(defcomp ~wrap (&key &rest children) (div :class "w" (raw! children)))',
'(~wrap (p "a") (p "b"))',
),
]
@pytest.mark.parametrize("comp_text,call_text", COMP_CASES)
def test_component_matches_python(self, comp_text, call_text):
env = {}
evaluate(parse(comp_text), env)
py_html = py_render(parse(call_text), env)
js_html = _js_render(call_text, comp_text)
assert js_html == py_html
MAP_CASES = [
# map with lambda returning HTML element
(
"",
'(ul (map (lambda (x) (li x)) ("a" "b" "c")))',
),
# map with lambda returning component
(
'(defcomp ~item (&key name) (span :class "item" name))',
'(div (map (lambda (t) (~item :name (get t "name"))) ({"name" "Alice"} {"name" "Bob"})))',
),
# map-indexed with lambda
(
"",
'(ul (map-indexed (lambda (i x) (li (str i ". " x))) ("foo" "bar")))',
),
]
@pytest.mark.parametrize("comp_text,call_text", MAP_CASES)
def test_map_lambda_render(self, comp_text, call_text):
env = {}
if comp_text:
for expr in parse_all(comp_text):
evaluate(expr, env)
py_html = py_render(parse(call_text), env)
js_html = _js_render(call_text, comp_text)
assert js_html == py_html, f"Mismatch:\n PY: {py_html!r}\n JS: {js_html!r}"

176
shared/sx/types.py Normal file
View File

@@ -0,0 +1,176 @@
"""
Core types for the s-expression language.
Symbol — unquoted identifier (e.g. div, ~card, map)
Keyword — colon-prefixed key (e.g. :class, :id)
Lambda — callable closure created by (lambda ...) or (fn ...)
Nil — singleton null value
"""
from __future__ import annotations
from dataclasses import dataclass, field
from typing import Any
# ---------------------------------------------------------------------------
# Nil
# ---------------------------------------------------------------------------
class _Nil:
"""Singleton nil value — falsy, serialises as 'nil'."""
_instance: _Nil | None = None
def __new__(cls):
if cls._instance is None:
cls._instance = super().__new__(cls)
return cls._instance
def __bool__(self):
return False
def __repr__(self):
return "nil"
def __eq__(self, other):
return other is None or isinstance(other, _Nil)
def __hash__(self):
return hash(None)
NIL = _Nil()
# ---------------------------------------------------------------------------
# Symbol
# ---------------------------------------------------------------------------
@dataclass(frozen=True)
class Symbol:
"""An unquoted symbol/identifier."""
name: str
def __repr__(self):
return f"Symbol({self.name!r})"
def __eq__(self, other):
if isinstance(other, Symbol):
return self.name == other.name
if isinstance(other, str):
return self.name == other
return False
def __hash__(self):
return hash(self.name)
@property
def is_component(self) -> bool:
"""True if this symbol names a component (~prefix)."""
return self.name.startswith("~")
# ---------------------------------------------------------------------------
# Keyword
# ---------------------------------------------------------------------------
@dataclass(frozen=True)
class Keyword:
"""A keyword starting with colon (e.g. :class, :id)."""
name: str
def __repr__(self):
return f"Keyword({self.name!r})"
def __eq__(self, other):
if isinstance(other, Keyword):
return self.name == other.name
return False
def __hash__(self):
return hash((":", self.name))
# ---------------------------------------------------------------------------
# Lambda
# ---------------------------------------------------------------------------
@dataclass
class Lambda:
"""A callable closure.
Created by ``(lambda (x) body)`` or ``(fn (x) body)``.
Captures the defining environment so free variables resolve correctly.
"""
params: list[str]
body: Any
closure: dict[str, Any] = field(default_factory=dict)
name: str | None = None # optional, set by (define name (fn ...))
def __repr__(self):
tag = self.name or "lambda"
return f"<{tag}({', '.join(self.params)})>"
def __call__(self, *args: Any, evaluator: Any = None, caller_env: dict | None = None) -> Any:
"""Invoke the lambda. Requires *evaluator* — the evaluate() function."""
if evaluator is None:
raise RuntimeError("Lambda requires evaluator to be called")
if len(args) != len(self.params):
raise RuntimeError(
f"{self!r} expects {len(self.params)} args, got {len(args)}"
)
local = dict(self.closure)
if caller_env:
local.update(caller_env)
for p, v in zip(self.params, args):
local[p] = v
return evaluator(self.body, local)
# ---------------------------------------------------------------------------
# Component
# ---------------------------------------------------------------------------
@dataclass
class Component:
"""A reusable UI component defined via ``(defcomp ~name (&key ...) body)``.
Components are like lambdas but accept keyword arguments and support
a ``children`` rest parameter.
"""
name: str
params: list[str] # keyword parameter names (without &key prefix)
has_children: bool # True if &rest children declared
body: Any # unevaluated s-expression body
closure: dict[str, Any] = field(default_factory=dict)
def __repr__(self):
return f"<Component ~{self.name}({', '.join(self.params)})>"
# ---------------------------------------------------------------------------
# RelationDef
# ---------------------------------------------------------------------------
@dataclass(frozen=True)
class RelationDef:
"""A declared relation between two entity types.
Created by ``(defrelation :name ...)`` s-expressions.
"""
name: str # "page->market"
from_type: str # "page"
to_type: str # "market"
cardinality: str # "one-to-one" | "one-to-many" | "many-to-many"
inverse: str | None # "market->page"
nav: str # "submenu" | "tab" | "badge" | "inline" | "hidden"
nav_icon: str | None # "fa fa-shopping-bag"
nav_label: str | None # "markets"
# ---------------------------------------------------------------------------
# Type alias
# ---------------------------------------------------------------------------
# An s-expression value after evaluation
SExp = int | float | str | bool | Symbol | Keyword | Lambda | Component | RelationDef | list | dict | _Nil | None