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:
@@ -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
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
|
||||
201
shared/sexp/tests/test_jinja_bridge.py
Normal file
201
shared/sexp/tests/test_jinja_bridge.py
Normal 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 "<script>" 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>"
|
||||
Reference in New Issue
Block a user