Files
mono/shared/sexp/jinja_bridge.py
giles 5d9f1586af Phase 4: Jinja bridge for incremental s-expression migration
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>
2026-02-27 14:34:42 +00:00

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