Two-way bridge: sexp() Jinja global renders s-expression components in templates, register_components() loads definitions at startup. Includes ~link-card component test proving unified replacement of 5 per-service Jinja fragment templates. 19 new tests (218 total). Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
139 lines
4.4 KiB
Python
139 lines
4.4 KiB
Python
"""
|
|
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)::
|
|
|
|
{{ sexp('(~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.sexp.jinja_bridge import setup_sexp_bridge
|
|
setup_sexp_bridge(app) # call after setup_jinja(app)
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
from typing import Any
|
|
|
|
from .types import NIL, Symbol
|
|
from .parser import parse
|
|
from .html import render as html_render
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# 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 register_components(sexp_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(sexp_source)
|
|
for expr in exprs:
|
|
_eval(expr, _COMPONENT_ENV)
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# sexp() — render s-expression from Jinja template
|
|
# ---------------------------------------------------------------------------
|
|
|
|
def sexp(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::
|
|
|
|
{{ sexp('(~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 ``sexp_async()``.
|
|
"""
|
|
env = dict(_COMPONENT_ENV)
|
|
env.update(kwargs)
|
|
expr = parse(source)
|
|
return html_render(expr, env)
|
|
|
|
|
|
async def sexp_async(source: str, **kwargs: Any) -> str:
|
|
"""Async version of ``sexp()`` — resolves I/O primitives (frag, query)
|
|
before rendering.
|
|
|
|
Use when the s-expression contains I/O nodes::
|
|
|
|
{{ sexp_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("HX-Request"))
|
|
return RequestContext(user=user, is_htmx=is_htmx)
|
|
except Exception:
|
|
return RequestContext()
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Quart integration
|
|
# ---------------------------------------------------------------------------
|
|
|
|
def setup_sexp_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.sexp.jinja_bridge import setup_sexp_bridge
|
|
setup_sexp_bridge(app)
|
|
|
|
This registers:
|
|
- ``sexp(source, **kwargs)`` — sync render (components, pure HTML)
|
|
- ``sexp_async(source, **kwargs)`` — async render (with I/O resolution)
|
|
"""
|
|
app.jinja_env.globals["sexp"] = sexp
|
|
app.jinja_env.globals["sexp_async"] = sexp_async
|