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>
This commit is contained in:
2026-02-27 14:34:42 +00:00
parent fbb7a1422c
commit 5d9f1586af
3 changed files with 360 additions and 0 deletions

View File

@@ -687,6 +687,27 @@ Each phase is independently deployable. The end state: a platform where the appl
- 27 tests: passthrough rendering (4), I/O collection (8), fragment resolution (3), query resolution (2), parallel I/O (1), request context (4), error handling (2), mixed content (3)
- **199 total tests across all 4 files, all passing**
### Phase 4: Jinja Bridge — COMPLETE
**Branch:** `sexpression`
**Delivered** (`shared/sexp/`):
- `jinja_bridge.py` — Two-way bridge between Jinja and s-expressions:
- `sexp(source, **kwargs)` — sync render for Jinja templates: `{{ sexp('(~card :title "Hi")') | safe }}`
- `sexp_async(source, **kwargs)` — async render with I/O resolution
- `register_components(sexp_source)` — load component definitions at startup
- `get_component_env()` — access the shared component registry
- `setup_sexp_bridge(app)` — register `sexp` and `sexp_async` as Jinja globals
- `_get_request_context()` — auto-builds RequestContext from Quart request
**Integration point**: Call `setup_sexp_bridge(app)` after `setup_jinja(app)` in app factories. Components registered via `register_components()` are available globally across all templates.
**First migration target: `~link-card`** — unified component replacing 5 separate Jinja templates (`blog/fragments/link_card.html`, `market/fragments/link_card.html`, `events/fragments/link_card.html`, `federation/fragments/link_card.html`, `artdag/l1/app/templates/fragments/link_card.html`). The s-expression component handles image/no-image, brand, and is usable from both Jinja templates and s-expression trees.
**Tests** (`shared/sexp/tests/test_jinja_bridge.py`):
- 19 tests: sexp() rendering (5), component registration (6), link-card migration (5), sexp_async (3)
- **218 total tests across all 5 files, all passing**
### Test Infrastructure — COMPLETE
**Delivered:**

138
shared/sexp/jinja_bridge.py Normal file
View 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

View File

@@ -0,0 +1,201 @@
"""Tests for the Jinja ↔ s-expression bridge."""
import asyncio
from shared.sexp.jinja_bridge import (
sexp,
sexp_async,
register_components,
get_component_env,
_COMPONENT_ENV,
)
# ---------------------------------------------------------------------------
# Helpers
# ---------------------------------------------------------------------------
def run(coro):
return asyncio.run(coro)
def setup_function():
"""Clear component env before each test."""
_COMPONENT_ENV.clear()
# ---------------------------------------------------------------------------
# sexp() — synchronous rendering
# ---------------------------------------------------------------------------
class TestSexp:
def test_simple_html(self):
assert sexp('(div "Hello")') == "<div>Hello</div>"
def test_with_kwargs(self):
html = sexp('(p name)', name="Alice")
assert html == "<p>Alice</p>"
def test_multiple_kwargs(self):
html = sexp('(a :href url title)', url="/about", title="About")
assert html == '<a href="/about">About</a>'
def test_escaping(self):
html = sexp('(p text)', text="<script>alert(1)</script>")
assert "&lt;script&gt;" in html
assert "<script>" not in html
def test_nested(self):
html = sexp('(div :class "card" (h1 title))', title="Hi")
assert html == '<div class="card"><h1>Hi</h1></div>'
# ---------------------------------------------------------------------------
# register_components() + sexp()
# ---------------------------------------------------------------------------
class TestComponents:
def test_register_and_use(self):
register_components('''
(defcomp ~badge (&key label)
(span :class "badge" label))
''')
html = sexp('(~badge :label "New")')
assert html == '<span class="badge">New</span>'
def test_multiple_components(self):
register_components('''
(defcomp ~tag (&key text)
(span :class "tag" text))
(defcomp ~pill (&key text)
(span :class "pill" text))
''')
assert '<span class="tag">A</span>' == sexp('(~tag :text "A")')
assert '<span class="pill">B</span>' == sexp('(~pill :text "B")')
def test_component_with_children(self):
register_components('''
(defcomp ~box (&key title &rest children)
(div :class "box" (h2 title) children))
''')
html = sexp('(~box :title "Box" (p "Content"))')
assert '<div class="box">' in html
assert "<h2>Box</h2>" in html
assert "<p>Content</p>" in html
def test_component_with_kwargs_override(self):
"""Kwargs passed to sexp() are available alongside components."""
register_components('''
(defcomp ~greeting (&key name)
(p (str "Hello " name)))
''')
html = sexp('(~greeting :name user)', user="Bob")
assert html == "<p>Hello Bob</p>"
def test_component_env_persists(self):
"""Components registered once are available in subsequent calls."""
register_components('(defcomp ~x (&key v) (b v))')
assert sexp('(~x :v "1")') == "<b>1</b>"
assert sexp('(~x :v "2")') == "<b>2</b>"
def test_get_component_env(self):
register_components('(defcomp ~foo (&key x) (span x))')
env = get_component_env()
assert "~foo" in env
# ---------------------------------------------------------------------------
# Link card example — the first migration target
# ---------------------------------------------------------------------------
class TestLinkCard:
def setup_method(self):
_COMPONENT_ENV.clear()
register_components('''
(defcomp ~link-card (&key link title image icon brand)
(a :href link
:class "block rounded border border-stone-200 bg-white hover:bg-stone-50 transition-colors no-underline"
(div :class "flex flex-row items-start gap-3 p-3"
(if image
(img :src image :alt "" :class "flex-shrink-0 w-16 h-16 rounded object-cover")
(div :class "flex-shrink-0 w-16 h-16 rounded bg-stone-100 flex items-center justify-center text-stone-400"
(i :class icon)))
(div :class "flex-1 min-w-0"
(div :class "font-medium text-stone-900 text-sm clamp-2" title)
(when brand
(div :class "text-xs text-stone-500 mt-0.5" brand))))))
''')
def test_with_image(self):
html = sexp('''
(~link-card
:link "/products/apple/"
:title "Apple"
:image "/img/apple.jpg"
:icon "fas fa-shopping-bag")
''')
assert 'href="/products/apple/"' in html
assert '<img src="/img/apple.jpg"' in html
assert "Apple" in html
def test_without_image(self):
html = sexp('''
(~link-card
:link "/posts/hello/"
:title "Hello World"
:icon "fas fa-file-alt")
''')
assert 'href="/posts/hello/"' in html
assert "<img" not in html
assert "fas fa-file-alt" in html
assert "Hello World" in html
def test_with_brand(self):
html = sexp('''
(~link-card
:link "/p/x/"
:title "Widget"
:image "/img/w.jpg"
:brand "Acme Corp")
''')
assert "Acme Corp" in html
def test_without_brand(self):
html = sexp('''
(~link-card
:link "/p/x/"
:title "Widget"
:image "/img/w.jpg")
''')
# brand div should not appear
assert "mt-0.5" not in html
def test_kwargs_from_python(self):
"""Pass data from Python (like a route handler would)."""
html = sexp(
'(~link-card :link link :title title :image image :icon "fas fa-box")',
link="/products/banana/",
title="Banana",
image="/img/banana.jpg",
)
assert 'href="/products/banana/"' in html
assert "Banana" in html
# ---------------------------------------------------------------------------
# sexp_async() — async rendering (no real I/O, just passthrough)
# ---------------------------------------------------------------------------
class TestSexpAsync:
def test_simple(self):
html = run(sexp_async('(div "Async")'))
assert html == "<div>Async</div>"
def test_with_kwargs(self):
html = run(sexp_async('(p name)', name="Alice"))
assert html == "<p>Alice</p>"
def test_with_component(self):
register_components('(defcomp ~x (&key v) (b v))')
html = run(sexp_async('(~x :v "OK")'))
assert html == "<b>OK</b>"