Phase 4: Jinja bridge for incremental s-expression migration
All checks were successful
Build and Deploy / build-and-deploy (push) Successful in 1m14s
All checks were successful
Build and Deploy / build-and-deploy (push) Successful in 1m14s
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>
This commit is contained in:
138
shared/sexp/jinja_bridge.py
Normal file
138
shared/sexp/jinja_bridge.py
Normal file
@@ -0,0 +1,138 @@
|
||||
"""
|
||||
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
|
||||
Reference in New Issue
Block a user