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:
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user