Externalize sexp to .sexpr files + render() API

Replace all 676 inline sexp() string calls across 7 services with
render(component_name, **kwargs) calls backed by 46 external .sexpr
component definition files (587 defcomps total).

- Add render() function to shared/sexp/jinja_bridge.py
- Add load_service_components() helper and update load_sexp_dir() for *.sexpr
- Update parser keyword regex to support HTMX hx-on::event syntax
- Convert remaining inline HTML in route files to render() calls
- Add shared/sexp/templates/misc.sexp for cross-service utility components

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-02-28 16:14:58 +00:00
parent f4c2f4b6b8
commit f9d9697c67
64 changed files with 5041 additions and 4051 deletions

View File

@@ -24,9 +24,9 @@ import glob
import os
from typing import Any
from .types import NIL, Symbol
from .types import NIL, Component, Keyword, Symbol
from .parser import parse
from .html import render as html_render
from .html import render as html_render, _render_component
# ---------------------------------------------------------------------------
@@ -44,12 +44,22 @@ def get_component_env() -> dict[str, Any]:
def load_sexp_dir(directory: str) -> None:
"""Load all .sexp files from a directory and register components."""
for filepath in sorted(glob.glob(os.path.join(directory, "*.sexp"))):
"""Load all .sexp and .sexpr files from a directory and register components."""
for filepath in sorted(
glob.glob(os.path.join(directory, "*.sexp"))
+ glob.glob(os.path.join(directory, "*.sexpr"))
):
with open(filepath, encoding="utf-8") as f:
register_components(f.read())
def load_service_components(service_dir: str) -> None:
"""Load service-specific s-expression components from {service_dir}/sexp/."""
sexp_dir = os.path.join(service_dir, "sexp")
if os.path.isdir(sexp_dir):
load_sexp_dir(sexp_dir)
def register_components(sexp_source: str) -> None:
"""Parse and evaluate s-expression component definitions into the
shared environment.
@@ -96,6 +106,28 @@ def sexp(source: str, **kwargs: Any) -> str:
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 sexp kebab-case.
No sexp 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 sexp_async(source: str, **kwargs: Any) -> str:
"""Async version of ``sexp()`` — resolves I/O primitives (frag, query)
before rendering.
@@ -144,4 +176,5 @@ def setup_sexp_bridge(app: Any) -> None:
- ``sexp_async(source, **kwargs)`` — async render (with I/O resolution)
"""
app.jinja_env.globals["sexp"] = sexp
app.jinja_env.globals["render"] = render
app.jinja_env.globals["sexp_async"] = sexp_async