Replace sx_call() with render_to_sx() across all services

Python no longer generates s-expression strings. All SX rendering now
goes through render_to_sx() which builds AST from native Python values
and evaluates via async_eval_to_sx() — no SX string literals in Python.

- Add render_to_sx()/render_to_html() infrastructure in shared/sx/helpers.py
- Add (abort status msg) IO primitive in shared/sx/primitives_io.py
- Convert all 9 services: ~650 sx_call() invocations replaced
- Convert shared helpers (root_header_sx, full_page_sx, etc.) to async
- Fix likes service import bug (likes.models → models)

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-03-04 00:08:33 +00:00
parent 0554f8a113
commit e085fe43b4
51 changed files with 1824 additions and 1742 deletions

View File

@@ -56,6 +56,6 @@ def register(url_prefix="/"):
await g.s.flush()
from sx.sx_components import render_newsletter_toggle
return sx_response(render_newsletter_toggle(un))
return sx_response(await render_newsletter_toggle(un))
return account_bp

View File

@@ -11,7 +11,7 @@ from typing import Any
from shared.sx.jinja_bridge import load_service_components
from shared.sx.helpers import (
sx_call, SxExpr,
render_to_sx,
root_header_sx, full_page_sx,
)
@@ -28,9 +28,10 @@ async def render_login_page(ctx: dict) -> str:
"""Full page: login form."""
error = ctx.get("error", "")
email = ctx.get("email", "")
hdr = root_header_sx(ctx)
content = sx_call("account-login-content", error=error or None, email=email)
return full_page_sx(ctx, header_rows=hdr,
hdr = await root_header_sx(ctx)
content = await render_to_sx("account-login-content",
error=error or None, email=email)
return await full_page_sx(ctx, header_rows=hdr,
content=content,
meta_html='<title>Login \u2014 Rose Ash</title>')
@@ -39,18 +40,19 @@ async def render_device_page(ctx: dict) -> str:
"""Full page: device authorization form."""
error = ctx.get("error", "")
code = ctx.get("code", "")
hdr = root_header_sx(ctx)
content = sx_call("account-device-content", error=error or None, code=code)
return full_page_sx(ctx, header_rows=hdr,
hdr = await root_header_sx(ctx)
content = await render_to_sx("account-device-content",
error=error or None, code=code)
return await full_page_sx(ctx, header_rows=hdr,
content=content,
meta_html='<title>Authorize Device \u2014 Rose Ash</title>')
async def render_device_approved_page(ctx: dict) -> str:
"""Full page: device approved."""
hdr = root_header_sx(ctx)
content = sx_call("account-device-approved")
return full_page_sx(ctx, header_rows=hdr,
hdr = await root_header_sx(ctx)
content = await render_to_sx("account-device-approved")
return await full_page_sx(ctx, header_rows=hdr,
content=content,
meta_html='<title>Device Authorized \u2014 Rose Ash</title>')
@@ -59,10 +61,10 @@ async def render_check_email_page(ctx: dict) -> str:
"""Full page: check email after magic link sent."""
email = ctx.get("email", "")
email_error = ctx.get("email_error")
hdr = root_header_sx(ctx)
content = sx_call("account-check-email-content",
email=email, email_error=email_error)
return full_page_sx(ctx, header_rows=hdr,
hdr = await root_header_sx(ctx)
content = await render_to_sx("account-check-email-content",
email=email, email_error=email_error)
return await full_page_sx(ctx, header_rows=hdr,
content=content,
meta_html='<title>Check your email \u2014 Rose Ash</title>')
@@ -71,7 +73,7 @@ async def render_check_email_page(ctx: dict) -> str:
# Public API: Fragment renderers for POST handlers
# ---------------------------------------------------------------------------
def render_newsletter_toggle(un) -> str:
async def render_newsletter_toggle(un) -> str:
"""Render a newsletter toggle switch for POST response."""
from shared.browser.app.csrf import generate_csrf_token
@@ -94,7 +96,7 @@ def render_newsletter_toggle(un) -> str:
translate = "translate-x-1"
checked = "false"
return sx_call(
return await render_to_sx(
"account-newsletter-toggle",
id=f"nl-{nid}", url=toggle_url,
hdrs=f'{{"X-CSRFToken": "{csrf}"}}',
@@ -103,27 +105,3 @@ def render_newsletter_toggle(un) -> str:
checked=checked,
knob_cls=f"inline-block h-4 w-4 rounded-full bg-white shadow transform transition-transform {translate}",
)
# ---------------------------------------------------------------------------
# Internal helpers
# ---------------------------------------------------------------------------
def _fragment_content(frag: object) -> str:
"""Convert a fragment response to sx content string.
SxExpr (from text/sx responses) is embedded as-is; plain strings
(from text/html) are wrapped in ``~rich-text``.
"""
from shared.sx.parser import SxExpr
if isinstance(frag, SxExpr):
return frag.source
s = str(frag) if frag else ""
if not s:
return ""
return f'(~rich-text :html "{_sx_escape(s)}")'
def _sx_escape(s: str) -> str:
"""Escape a string for embedding in sx string literals."""
return s.replace("\\", "\\\\").replace('"', '\\"')

View File

@@ -26,43 +26,51 @@ def _register_account_layouts() -> None:
register_custom_layout("account", _account_full, _account_oob, _account_mobile)
def _account_full(ctx: dict, **kw: Any) -> str:
from shared.sx.helpers import root_header_sx, header_child_sx, call_url, sx_call, SxExpr
async def _account_full(ctx: dict, **kw: Any) -> str:
from shared.sx.helpers import root_header_sx, header_child_sx, render_to_sx
root_hdr = root_header_sx(ctx)
auth_hdr = sx_call("auth-header-row",
account_url=call_url(ctx, "account_url", ""),
root_hdr = await root_header_sx(ctx)
auth_hdr = await render_to_sx("auth-header-row",
account_url=_call_url(ctx, "account_url", ""),
select_colours=ctx.get("select_colours", ""),
account_nav=_as_sx_nav(ctx),
)
hdr_child = header_child_sx(auth_hdr)
hdr_child = await header_child_sx(auth_hdr)
return "(<> " + root_hdr + " " + hdr_child + ")"
def _account_oob(ctx: dict, **kw: Any) -> str:
from shared.sx.helpers import root_header_sx, call_url, sx_call
async def _account_oob(ctx: dict, **kw: Any) -> str:
from shared.sx.helpers import root_header_sx, render_to_sx
auth_hdr = sx_call("auth-header-row",
account_url=call_url(ctx, "account_url", ""),
auth_hdr = await render_to_sx("auth-header-row",
account_url=_call_url(ctx, "account_url", ""),
select_colours=ctx.get("select_colours", ""),
account_nav=_as_sx_nav(ctx),
oob=True,
)
return "(<> " + auth_hdr + " " + root_header_sx(ctx, oob=True) + ")"
return "(<> " + auth_hdr + " " + await root_header_sx(ctx, oob=True) + ")"
def _account_mobile(ctx: dict, **kw: Any) -> str:
from shared.sx.helpers import mobile_menu_sx, mobile_root_nav_sx, sx_call, SxExpr, call_url
async def _account_mobile(ctx: dict, **kw: Any) -> str:
from shared.sx.helpers import mobile_menu_sx, mobile_root_nav_sx, render_to_sx
from shared.sx.parser import SxExpr
ctx = _inject_account_nav(ctx)
nav_items = sx_call("auth-nav-items",
account_url=call_url(ctx, "account_url", ""),
nav_items = await render_to_sx("auth-nav-items",
account_url=_call_url(ctx, "account_url", ""),
select_colours=ctx.get("select_colours", ""),
account_nav=_as_sx_nav(ctx),
)
auth_section = sx_call("mobile-menu-section",
auth_section = await render_to_sx("mobile-menu-section",
label="account", href="/", level=1, colour="sky",
items=SxExpr(nav_items))
return mobile_menu_sx(auth_section, mobile_root_nav_sx(ctx))
return mobile_menu_sx(auth_section, await mobile_root_nav_sx(ctx))
def _call_url(ctx: dict, key: str, path: str = "/") -> str:
fn = ctx.get(key)
if callable(fn):
return fn(path)
return str(fn or "") + path
def _inject_account_nav(ctx: dict) -> dict:
@@ -75,7 +83,7 @@ def _inject_account_nav(ctx: dict) -> dict:
def _as_sx_nav(ctx: dict) -> Any:
"""Convert account_nav fragment to SxExpr for use in sx_call."""
"""Convert account_nav fragment to SxExpr for use in component calls."""
from shared.sx.helpers import _as_sx
ctx = _inject_account_nav(ctx)
return _as_sx(ctx.get("account_nav"))
@@ -100,8 +108,7 @@ async def _h_newsletters_content(**kw):
from sqlalchemy import select
from shared.models import UserNewsletter
from shared.models.ghost_membership_entities import GhostNewsletter
from shared.sx.helpers import sx_call, SxExpr
from shared.sx.parser import serialize
from shared.sx.helpers import render_to_sx
result = await g.s.execute(
select(GhostNewsletter).order_by(GhostNewsletter.name)
@@ -131,8 +138,8 @@ async def _h_newsletters_content(**kw):
# Call account_url to get the base URL string
account_url_str = account_url("") if callable(account_url) else str(account_url or "")
return sx_call("account-newsletters-content",
newsletter_list=SxExpr(serialize(newsletter_list)),
return await render_to_sx("account-newsletters-content",
newsletter_list=newsletter_list,
account_url=account_url_str)
@@ -148,5 +155,11 @@ async def _h_fragment_content(slug=None, **kw):
)
if not fragment_html:
abort(404)
from sx.sx_components import _fragment_content
return _fragment_content(fragment_html)
from shared.sx.parser import SxExpr
if isinstance(fragment_html, SxExpr):
return fragment_html.source
s = str(fragment_html) if fragment_html else ""
if not s:
return ""
from shared.sx.helpers import render_to_sx
return await render_to_sx("rich-text", html=s)