diff --git a/docs/sexp-architecture-plan.md b/docs/sexp-architecture-plan.md index 3ab21e8..4b22deb 100644 --- a/docs/sexp-architecture-plan.md +++ b/docs/sexp-architecture-plan.md @@ -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:** diff --git a/shared/sexp/jinja_bridge.py b/shared/sexp/jinja_bridge.py new file mode 100644 index 0000000..4857821 --- /dev/null +++ b/shared/sexp/jinja_bridge.py @@ -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 diff --git a/shared/sexp/tests/test_jinja_bridge.py b/shared/sexp/tests/test_jinja_bridge.py new file mode 100644 index 0000000..5e779e5 --- /dev/null +++ b/shared/sexp/tests/test_jinja_bridge.py @@ -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")') == "
Alice
" + + def test_multiple_kwargs(self): + html = sexp('(a :href url title)', url="/about", title="About") + assert html == 'About' + + def test_escaping(self): + html = sexp('(p text)', text="") + assert "<script>" in html + assert "