Rebrand sexp → sx across web platform (173 files)
All checks were successful
Build and Deploy / build-and-deploy (push) Successful in 11m37s
All checks were successful
Build and Deploy / build-and-deploy (push) Successful in 11m37s
Rename all sexp directories, files, identifiers, and references to sx. artdag/ excluded (separate media processing DSL). Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
249
shared/sx/jinja_bridge.py
Normal file
249
shared/sx/jinja_bridge.py
Normal file
@@ -0,0 +1,249 @@
|
||||
"""
|
||||
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)::
|
||||
|
||||
{{ sx('(~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.sx.jinja_bridge import setup_sx_bridge
|
||||
setup_sx_bridge(app) # call after setup_jinja(app)
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import glob
|
||||
import os
|
||||
from typing import Any
|
||||
|
||||
from .types import NIL, Component, Keyword, Symbol
|
||||
from .parser import parse
|
||||
from .html import render as html_render, _render_component
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 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 load_sx_dir(directory: str) -> None:
|
||||
"""Load all .sx files from a directory and register components."""
|
||||
for filepath in sorted(
|
||||
glob.glob(os.path.join(directory, "*.sx"))
|
||||
):
|
||||
with open(filepath, encoding="utf-8") as f:
|
||||
register_components(f.read())
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Dev-mode auto-reload of sx templates
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
_watched_dirs: list[str] = []
|
||||
_file_mtimes: dict[str, float] = {}
|
||||
|
||||
|
||||
def watch_sx_dir(directory: str) -> None:
|
||||
"""Register a directory for dev-mode file watching."""
|
||||
_watched_dirs.append(directory)
|
||||
# Seed mtimes
|
||||
for fp in sorted(
|
||||
glob.glob(os.path.join(directory, "*.sx"))
|
||||
):
|
||||
_file_mtimes[fp] = os.path.getmtime(fp)
|
||||
|
||||
|
||||
def reload_if_changed() -> None:
|
||||
"""Re-read sx files if any have changed on disk. Called per-request in dev."""
|
||||
changed = False
|
||||
for directory in _watched_dirs:
|
||||
for fp in sorted(
|
||||
glob.glob(os.path.join(directory, "*.sx"))
|
||||
):
|
||||
mtime = os.path.getmtime(fp)
|
||||
if fp not in _file_mtimes or _file_mtimes[fp] != mtime:
|
||||
_file_mtimes[fp] = mtime
|
||||
changed = True
|
||||
if changed:
|
||||
_COMPONENT_ENV.clear()
|
||||
for directory in _watched_dirs:
|
||||
load_sx_dir(directory)
|
||||
|
||||
|
||||
def load_service_components(service_dir: str) -> None:
|
||||
"""Load service-specific s-expression components from {service_dir}/sx/."""
|
||||
sx_dir = os.path.join(service_dir, "sx")
|
||||
if os.path.isdir(sx_dir):
|
||||
load_sx_dir(sx_dir)
|
||||
watch_sx_dir(sx_dir)
|
||||
|
||||
|
||||
def register_components(sx_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(sx_source)
|
||||
for expr in exprs:
|
||||
_eval(expr, _COMPONENT_ENV)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# sx() — render s-expression from Jinja template
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def sx(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::
|
||||
|
||||
{{ sx('(~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 ``sx_async()``.
|
||||
"""
|
||||
env = dict(_COMPONENT_ENV)
|
||||
env.update(kwargs)
|
||||
expr = parse(source)
|
||||
return html_render(expr, env)
|
||||
|
||||
|
||||
def render(component_name: str, **kwargs: Any) -> str:
|
||||
"""Call a registered component by name with Python kwargs.
|
||||
|
||||
Automatically converts Python snake_case to sx kebab-case.
|
||||
No sx strings needed — just a function call.
|
||||
"""
|
||||
name = component_name if component_name.startswith("~") else f"~{component_name}"
|
||||
comp = _COMPONENT_ENV.get(name)
|
||||
if not isinstance(comp, Component):
|
||||
raise ValueError(f"Unknown component: {name}")
|
||||
|
||||
env = dict(_COMPONENT_ENV)
|
||||
args: list[Any] = []
|
||||
for key, val in kwargs.items():
|
||||
kw_name = key.replace("_", "-")
|
||||
args.append(Keyword(kw_name))
|
||||
args.append(val)
|
||||
env[kw_name] = val
|
||||
|
||||
return _render_component(comp, args, env)
|
||||
|
||||
|
||||
async def sx_async(source: str, **kwargs: Any) -> str:
|
||||
"""Async version of ``sx()`` — resolves I/O primitives (frag, query)
|
||||
before rendering.
|
||||
|
||||
Use when the s-expression contains I/O nodes::
|
||||
|
||||
{{ sx_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("SX-Request") or request.headers.get("HX-Request"))
|
||||
return RequestContext(user=user, is_htmx=is_htmx)
|
||||
except Exception:
|
||||
return RequestContext()
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Quart integration
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def client_components_tag(*names: str) -> str:
|
||||
"""Emit a <script type="text/sx"> tag with component definitions.
|
||||
|
||||
Reads the source definitions from loaded .sx files and sends them
|
||||
to the client so sx.js can render them identically.
|
||||
|
||||
Usage in Python::
|
||||
|
||||
body_end_html = client_components_tag("test-filter-card", "test-row")
|
||||
|
||||
Or send all loaded components::
|
||||
|
||||
body_end_html = client_components_tag()
|
||||
"""
|
||||
from .parser import serialize
|
||||
parts = []
|
||||
for key, val in _COMPONENT_ENV.items():
|
||||
if not isinstance(val, Component):
|
||||
continue
|
||||
if names and val.name not in names and key.lstrip("~") not in names:
|
||||
continue
|
||||
# Reconstruct defcomp source from the Component object
|
||||
param_strs = ["&key"] + list(val.params)
|
||||
if val.has_children:
|
||||
param_strs.extend(["&rest", "children"])
|
||||
params_sx = "(" + " ".join(param_strs) + ")"
|
||||
body_sx = serialize(val.body, pretty=True)
|
||||
parts.append(f"(defcomp ~{val.name} {params_sx} {body_sx})")
|
||||
if not parts:
|
||||
return ""
|
||||
source = "\n".join(parts)
|
||||
return f'<script type="text/sx" data-components>{source}</script>'
|
||||
|
||||
|
||||
def setup_sx_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.sx.jinja_bridge import setup_sx_bridge
|
||||
setup_sx_bridge(app)
|
||||
|
||||
This registers:
|
||||
- ``sx(source, **kwargs)`` — sync render (components, pure HTML)
|
||||
- ``sx_async(source, **kwargs)`` — async render (with I/O resolution)
|
||||
"""
|
||||
app.jinja_env.globals["sx"] = sx
|
||||
app.jinja_env.globals["render"] = render
|
||||
app.jinja_env.globals["sx_async"] = sx_async
|
||||
Reference in New Issue
Block a user