Rebrand sexp → sx across web platform (173 files)
All checks were successful
Build and Deploy / build-and-deploy (push) Successful in 11m37s
All checks were successful
Build and Deploy / build-and-deploy (push) Successful in 11m37s
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:
66
shared/sx/__init__.py
Normal file
66
shared/sx/__init__.py
Normal 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
19
shared/sx/components.py
Normal 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
97
shared/sx/env.py
Normal 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
620
shared/sx/evaluator.py
Normal 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
458
shared/sx/helpers.py
Normal 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("&", "&")
|
||||
.replace("<", "<")
|
||||
.replace(">", ">")
|
||||
.replace('"', """))
|
||||
481
shared/sx/html.py
Normal file
481
shared/sx/html.py
Normal 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("&", "&")
|
||||
.replace("<", "<")
|
||||
.replace(">", ">")
|
||||
)
|
||||
|
||||
|
||||
def escape_attr(s: str) -> str:
|
||||
"""Escape an attribute value for safe embedding in double quotes."""
|
||||
return (
|
||||
s.replace("&", "&")
|
||||
.replace('"', """)
|
||||
.replace("<", "<")
|
||||
.replace(">", ">")
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 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
249
shared/sx/jinja_bridge.py
Normal 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
97
shared/sx/page.py
Normal 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
347
shared/sx/parser.py
Normal 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
419
shared/sx/primitives.py
Normal 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
153
shared/sx/primitives_io.py
Normal 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
101
shared/sx/relations.py
Normal 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
196
shared/sx/resolver.py
Normal 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]
|
||||
44
shared/sx/templates/cards.sx
Normal file
44
shared/sx/templates/cards.sx
Normal 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"))))
|
||||
98
shared/sx/templates/controls.sx
Normal file
98
shared/sx/templates/controls.sx
Normal 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)))
|
||||
73
shared/sx/templates/fragments.sx
Normal file
73
shared/sx/templates/fragments.sx
Normal 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)))
|
||||
139
shared/sx/templates/layout.sx
Normal file
139
shared/sx/templates/layout.sx
Normal 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)))))
|
||||
35
shared/sx/templates/misc.sx
Normal file
35
shared/sx/templates/misc.sx
Normal 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)))
|
||||
24
shared/sx/templates/navigation.sx
Normal file
24
shared/sx/templates/navigation.sx
Normal 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))))
|
||||
25
shared/sx/templates/pages.sx
Normal file
25
shared/sx/templates/pages.sx
Normal 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"))))))
|
||||
15
shared/sx/templates/relations.sx
Normal file
15
shared/sx/templates/relations.sx
Normal 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")))
|
||||
0
shared/sx/tests/__init__.py
Normal file
0
shared/sx/tests/__init__.py
Normal file
350
shared/sx/tests/test_components.py
Normal file
350
shared/sx/tests/test_components.py
Normal 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
|
||||
326
shared/sx/tests/test_evaluator.py
Normal file
326
shared/sx/tests/test_evaluator.py
Normal 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
|
||||
365
shared/sx/tests/test_html.py
Normal file
365
shared/sx/tests/test_html.py
Normal 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 & B"
|
||||
|
||||
def test_escape_text_lt_gt(self):
|
||||
assert escape_text("<script>") == "<script>"
|
||||
|
||||
def test_escape_attr_quotes(self):
|
||||
assert escape_attr('he said "hi"') == "he said "hi""
|
||||
|
||||
def test_escape_attr_all(self):
|
||||
assert escape_attr('&<>"') == "&<>""
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Primitives / atoms
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class TestAtoms:
|
||||
def test_string(self):
|
||||
assert r('"Hello"') == "Hello"
|
||||
|
||||
def test_string_with_entities(self):
|
||||
assert r('"<b>bold</b>"') == "<b>bold</b>"
|
||||
|
||||
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 & B"></div>'
|
||||
|
||||
def test_attr_with_quotes(self):
|
||||
assert r("""(div :title "say \\"hi\\"")""") == \
|
||||
'<div title="say "hi""></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"
|
||||
201
shared/sx/tests/test_jinja_bridge.py
Normal file
201
shared/sx/tests/test_jinja_bridge.py
Normal 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 "<script>" 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>"
|
||||
35
shared/sx/tests/test_parse_all.py
Normal file
35
shared/sx/tests/test_parse_all.py
Normal 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"
|
||||
191
shared/sx/tests/test_parser.py
Normal file
191
shared/sx/tests/test_parser.py
Normal 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
|
||||
245
shared/sx/tests/test_relations.py
Normal file
245
shared/sx/tests/test_relations.py
Normal 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"
|
||||
320
shared/sx/tests/test_resolver.py
Normal file
320
shared/sx/tests/test_resolver.py
Normal 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 "<" 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
|
||||
233
shared/sx/tests/test_sx_js.py
Normal file
233
shared/sx/tests/test_sx_js.py
Normal 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 "<script>" 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
176
shared/sx/types.py
Normal 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
|
||||
Reference in New Issue
Block a user