Remove render_to_sx from public API: enforce sx_call for all service code
All checks were successful
Build and Deploy / build-and-deploy (push) Successful in 1m44s

Replace ~250 render_to_sx calls across all services with sync sx_call,
converting many async functions to sync where no other awaits remained.
Make render_to_sx/render_to_sx_with_env private (_render_to_sx).
Add (post-header-ctx) IO primitive and shared post/post-admin defmacros.
Convert built-in post/post-admin layouts from Python to register_sx_layout
with .sx defcomps. Remove dead post_admin_mobile_nav_sx.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-03-04 19:30:45 +00:00
parent 57e0d0c341
commit 959e63d440
61 changed files with 1352 additions and 1208 deletions

View File

@@ -60,7 +60,7 @@ async def root_header_sx(ctx: dict, *, oob: bool = False) -> str:
rights = ctx.get("rights") or {}
is_admin = rights.get("admin") if isinstance(rights, dict) else getattr(rights, "admin", False)
settings_url = call_url(ctx, "blog_url", "/settings/") if is_admin else ""
return await render_to_sx("header-row-sx",
return await _render_to_sx("header-row-sx",
cart_mini=_as_sx(ctx.get("cart_mini")),
blog_url=call_url(ctx, "blog_url", ""),
site_title=ctx.get("base_title", ""),
@@ -86,7 +86,7 @@ async def mobile_root_nav_sx(ctx: dict) -> str:
auth_menu = ctx.get("auth_menu") or ""
if not nav_tree and not auth_menu:
return ""
return await render_to_sx("mobile-root-nav",
return await _render_to_sx("mobile-root-nav",
nav_tree=_as_sx(nav_tree),
auth_menu=_as_sx(auth_menu),
)
@@ -107,13 +107,13 @@ async def _post_nav_items_sx(ctx: dict) -> str:
page_cart_count = ctx.get("page_cart_count", 0)
if page_cart_count and page_cart_count > 0:
cart_href = call_url(ctx, "cart_url", f"/{slug}/")
parts.append(await render_to_sx("page-cart-badge", href=cart_href,
parts.append(await _render_to_sx("page-cart-badge", href=cart_href,
count=str(page_cart_count)))
container_nav = str(ctx.get("container_nav") or "").strip()
# Skip empty fragment wrappers like "(<> )"
if container_nav and container_nav.replace("(<>", "").replace(")", "").strip():
parts.append(await render_to_sx("container-nav-wrapper",
parts.append(await _render_to_sx("container-nav-wrapper",
content=SxExpr(container_nav)))
# Admin cog
@@ -125,7 +125,7 @@ async def _post_nav_items_sx(ctx: dict) -> str:
from quart import request
admin_href = call_url(ctx, "blog_url", f"/{slug}/admin/")
is_admin_page = ctx.get("is_admin_section") or "/admin" in request.path
admin_nav = await render_to_sx("admin-cog-button",
admin_nav = await _render_to_sx("admin-cog-button",
href=admin_href,
is_admin_page=is_admin_page or None)
if admin_nav:
@@ -155,7 +155,7 @@ async def _post_admin_nav_items_sx(ctx: dict, slug: str,
continue
href = url_fn(path)
is_sel = label == selected
parts.append(await render_to_sx("nav-link", href=href, label=label,
parts.append(await _render_to_sx("nav-link", href=href, label=label,
select_colours=select_colours,
is_selected=is_sel or None))
return "(<> " + " ".join(parts) + ")" if parts else ""
@@ -173,7 +173,7 @@ async def post_mobile_nav_sx(ctx: dict) -> str:
post = ctx.get("post") or {}
slug = post.get("slug", "")
title = (post.get("title") or slug)[:40]
return await render_to_sx("mobile-menu-section",
return await _render_to_sx("mobile-menu-section",
label=title,
href=call_url(ctx, "blog_url", f"/{slug}/"),
level=1,
@@ -181,22 +181,10 @@ async def post_mobile_nav_sx(ctx: dict) -> str:
)
async def post_admin_mobile_nav_sx(ctx: dict, slug: str,
selected: str = "") -> str:
"""Post-admin mobile menu section."""
nav = await _post_admin_nav_items_sx(ctx, slug, selected)
if not nav:
return ""
admin_href = call_url(ctx, "blog_url", f"/{slug}/admin/")
return await render_to_sx("mobile-menu-section",
label="admin", href=admin_href, level=2,
items=SxExpr(nav),
)
async def search_mobile_sx(ctx: dict) -> str:
"""Build mobile search input as sx wire format."""
return await render_to_sx("search-mobile",
return await _render_to_sx("search-mobile",
current_local_href=ctx.get("current_local_href", "/"),
search=ctx.get("search", ""),
search_count=ctx.get("search_count", ""),
@@ -207,7 +195,7 @@ async def search_mobile_sx(ctx: dict) -> str:
async def search_desktop_sx(ctx: dict) -> str:
"""Build desktop search input as sx wire format."""
return await render_to_sx("search-desktop",
return await _render_to_sx("search-desktop",
current_local_href=ctx.get("current_local_href", "/"),
search=ctx.get("search", ""),
search_count=ctx.get("search_count", ""),
@@ -225,11 +213,11 @@ async def post_header_sx(ctx: dict, *, oob: bool = False, child: str = "") -> st
title = (post.get("title") or "")[:160]
feature_image = post.get("feature_image")
label_sx = await render_to_sx("post-label", feature_image=feature_image, title=title)
label_sx = await _render_to_sx("post-label", feature_image=feature_image, title=title)
nav_sx = await _post_nav_items_sx(ctx) or None
link_href = call_url(ctx, "blog_url", f"/{slug}/")
return await render_to_sx("menu-row-sx",
return await _render_to_sx("menu-row-sx",
id="post-row", level=1,
link_href=link_href,
link_label_content=SxExpr(label_sx),
@@ -244,7 +232,7 @@ async def post_admin_header_sx(ctx: dict, slug: str, *, oob: bool = False,
selected: str = "", admin_href: str = "") -> str:
"""Post admin header row as sx wire format."""
# Label
label_sx = await render_to_sx("post-admin-label",
label_sx = await _render_to_sx("post-admin-label",
selected=str(escape(selected)) if selected else None)
nav_sx = await _post_admin_nav_items_sx(ctx, slug, selected) or None
@@ -253,7 +241,7 @@ async def post_admin_header_sx(ctx: dict, slug: str, *, oob: bool = False,
blog_fn = ctx.get("blog_url")
admin_href = blog_fn(f"/{slug}/admin/") if callable(blog_fn) else f"/{slug}/admin/"
return await render_to_sx("menu-row-sx",
return await _render_to_sx("menu-row-sx",
id="post-admin-row", level=2,
link_href=admin_href,
link_label_content=SxExpr(label_sx),
@@ -268,7 +256,7 @@ async def oob_header_sx(parent_id: str, child_id: str, row_sx: str) -> str:
child_id is accepted for call-site compatibility but no longer used —
the child placeholder is created by ~menu-row-sx itself.
"""
return await render_to_sx("oob-header-sx",
return await _render_to_sx("oob-header-sx",
parent_id=parent_id,
row=SxExpr(row_sx),
)
@@ -276,7 +264,7 @@ async def oob_header_sx(parent_id: str, child_id: str, row_sx: str) -> str:
async def header_child_sx(inner_sx: str, *, id: str = "root-header-child") -> str:
"""Wrap inner sx in a header-child div."""
return await render_to_sx("header-child-sx",
return await _render_to_sx("header-child-sx",
id=id, inner=SxExpr(f"(<> {inner_sx})"),
)
@@ -284,7 +272,7 @@ async def header_child_sx(inner_sx: str, *, id: str = "root-header-child") -> st
async def oob_page_sx(*, oobs: str = "", filter: str = "", aside: str = "",
content: str = "", menu: str = "") -> str:
"""Build OOB response as sx wire format."""
return await render_to_sx("oob-sx",
return await _render_to_sx("oob-sx",
oobs=SxExpr(f"(<> {oobs})") if oobs else None,
filter=SxExpr(filter) if filter else None,
aside=SxExpr(aside) if aside else None,
@@ -305,7 +293,7 @@ async def full_page_sx(ctx: dict, *, header_rows: str,
# Auto-generate mobile nav from context when no menu provided
if not menu:
menu = await mobile_root_nav_sx(ctx)
body_sx = await render_to_sx("app-body",
body_sx = await _render_to_sx("app-body",
header_rows=SxExpr(f"(<> {header_rows})") if header_rows else None,
filter=SxExpr(filter) if filter else None,
aside=SxExpr(aside) if aside else None,
@@ -345,8 +333,8 @@ def _build_component_ast(__name: str, **kwargs: Any) -> list:
return ast
async def render_to_sx_with_env(__name: str, extra_env: dict, **kwargs: Any) -> str:
"""Like ``render_to_sx`` but merges *extra_env* into the evaluation
async def _render_to_sx_with_env(__name: str, extra_env: dict, **kwargs: Any) -> str:
"""Like ``_render_to_sx`` but merges *extra_env* into the evaluation
environment before eval. Used by ``register_sx_layout`` so .sx
defcomps can read ctx values as free variables.
@@ -354,6 +342,8 @@ async def render_to_sx_with_env(__name: str, extra_env: dict, **kwargs: Any) ->
top-level component body is expanded server-side — free variables
from *extra_env* are resolved during expansion rather than being
serialized as unresolved symbols for the client.
**Private** — service code should use ``sx_call()`` or defmacros instead.
"""
from .jinja_bridge import get_component_env, _get_request_context
from .async_eval import async_eval_slot_to_sx
@@ -365,16 +355,15 @@ async def render_to_sx_with_env(__name: str, extra_env: dict, **kwargs: Any) ->
return await async_eval_slot_to_sx(ast, env, ctx)
async def render_to_sx(__name: str, **kwargs: Any) -> str:
async def _render_to_sx(__name: str, **kwargs: Any) -> str:
"""Call a defcomp and get SX wire format back. No SX string literals.
Builds an AST from Python values and evaluates it through the SX
evaluator, which resolves IO primitives and serializes component/tag
calls as SX wire format.
await render_to_sx("card", title="hello", count=3)
# equivalent to old: sx_call("card", title="hello", count=3)
# but values flow as native objects, not serialized strings
**Private** — service code should use ``sx_call()`` or defmacros instead.
Only infrastructure code (helpers.py, layouts.py) should call this.
"""
from .jinja_bridge import get_component_env, _get_request_context
from .async_eval import async_eval_to_sx
@@ -385,6 +374,11 @@ async def render_to_sx(__name: str, **kwargs: Any) -> str:
return await async_eval_to_sx(ast, env, ctx)
# Backwards-compat alias — layout infrastructure still imports this.
# Will be removed once all layouts use register_sx_layout().
render_to_sx_with_env = _render_to_sx_with_env
async def render_to_html(__name: str, **kwargs: Any) -> str:
"""Call a defcomp and get HTML back. No SX string literals.