Make SxExpr a str subclass, sx_call/render functions return SxExpr

SxExpr is now a str subclass so it works everywhere a plain string
does (join, isinstance, f-strings) while serialize() still emits it
unquoted. sx_call() and all internal render functions (_render_to_sx,
async_eval_to_sx, etc.) return SxExpr, eliminating the "forgot to
wrap" bug class that caused the sx_content leak and list serialization
bugs.

- Phase 0: SxExpr(str) with .source property, __add__/__radd__
- Phase 1: sx_call returns SxExpr (drop-in, all 200+ sites unchanged)
- Phase 2: async_eval_to_sx, async_eval_slot_to_sx, _render_to_sx,
  mobile_menu_sx return SxExpr; remove isinstance(str) workaround
- Phase 3: Remove ~150 redundant SxExpr() wrappings across 45 files
- Phase 4: serialize() docstring, handler return docs, ;; returns: sx

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-03-04 21:47:00 +00:00
parent ad75798ab7
commit 278ae3e8f6
45 changed files with 378 additions and 379 deletions

View File

@@ -74,10 +74,10 @@ async def root_header_sx(ctx: dict, *, oob: bool = False) -> str:
)
def mobile_menu_sx(*sections: str) -> str:
def mobile_menu_sx(*sections: str) -> SxExpr:
"""Assemble mobile menu from pre-built sections (deepest first)."""
parts = [s for s in sections if s]
return "(<> " + " ".join(parts) + ")" if parts else ""
return SxExpr("(<> " + " ".join(parts) + ")") if parts else SxExpr("")
async def mobile_root_nav_sx(ctx: dict) -> str:
@@ -96,13 +96,13 @@ async def mobile_root_nav_sx(ctx: dict) -> str:
# Shared nav-item builders — used by BOTH desktop headers and mobile menus
# ---------------------------------------------------------------------------
async def _post_nav_items_sx(ctx: dict) -> str:
async def _post_nav_items_sx(ctx: dict) -> SxExpr:
"""Build post-level nav items (container_nav + admin cog). Shared by
``post_header_sx`` (desktop) and ``post_mobile_nav_sx`` (mobile)."""
post = ctx.get("post") or {}
slug = post.get("slug", "")
if not slug:
return ""
return SxExpr("")
parts: list[str] = []
page_cart_count = ctx.get("page_cart_count", 0)
if page_cart_count and page_cart_count > 0:
@@ -130,11 +130,11 @@ async def _post_nav_items_sx(ctx: dict) -> str:
is_admin_page=is_admin_page or None)
if admin_nav:
parts.append(admin_nav)
return "(<> " + " ".join(parts) + ")" if parts else ""
return SxExpr("(<> " + " ".join(parts) + ")") if parts else SxExpr("")
async def _post_admin_nav_items_sx(ctx: dict, slug: str,
selected: str = "") -> str:
selected: str = "") -> SxExpr:
"""Build post-admin nav items (calendars, markets, etc.). Shared by
``post_admin_header_sx`` (desktop) and mobile menu."""
select_colours = ctx.get("select_colours", "")
@@ -158,7 +158,7 @@ async def _post_admin_nav_items_sx(ctx: dict, slug: str,
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 ""
return SxExpr("(<> " + " ".join(parts) + ")") if parts else SxExpr("")
# ---------------------------------------------------------------------------
@@ -177,7 +177,7 @@ async def post_mobile_nav_sx(ctx: dict) -> str:
label=title,
href=call_url(ctx, "blog_url", f"/{slug}/"),
level=1,
items=SxExpr(nav),
items=nav,
)
@@ -220,8 +220,8 @@ async def post_header_sx(ctx: dict, *, oob: bool = False, child: str = "") -> st
return await _render_to_sx("menu-row-sx",
id="post-row", level=1,
link_href=link_href,
link_label_content=SxExpr(label_sx),
nav=SxExpr(nav_sx) if nav_sx else None,
link_label_content=label_sx,
nav=nav_sx,
child_id="post-header-child",
child=SxExpr(child) if child else None,
oob=oob, external=True,
@@ -244,8 +244,8 @@ async def post_admin_header_sx(ctx: dict, slug: str, *, oob: bool = False,
return await _render_to_sx("menu-row-sx",
id="post-admin-row", level=2,
link_href=admin_href,
link_label_content=SxExpr(label_sx),
nav=SxExpr(nav_sx) if nav_sx else None,
link_label_content=label_sx,
nav=nav_sx,
child_id="post-admin-header-child", oob=oob,
)
@@ -352,7 +352,7 @@ async def _render_to_sx_with_env(__name: str, extra_env: dict, **kwargs: Any) ->
env = dict(get_component_env())
env.update(extra_env)
ctx = _get_request_context()
return await async_eval_slot_to_sx(ast, env, ctx)
return SxExpr(await async_eval_slot_to_sx(ast, env, ctx))
async def _render_to_sx(__name: str, **kwargs: Any) -> str:
@@ -371,7 +371,7 @@ async def _render_to_sx(__name: str, **kwargs: Any) -> str:
ast = _build_component_ast(__name, **kwargs)
env = dict(get_component_env())
ctx = _get_request_context()
return await async_eval_to_sx(ast, env, ctx)
return SxExpr(await async_eval_to_sx(ast, env, ctx))
# Backwards-compat alias — layout infrastructure still imports this.
@@ -420,7 +420,7 @@ def sx_call(component_name: str, **kwargs: Any) -> str:
parts.append("(list " + " ".join(items) + ")")
else:
parts.append(serialize(val))
return "(" + " ".join(parts) + ")"
return SxExpr("(" + " ".join(parts) + ")")