Delete account/sx/sx_components.py — all rendering now in .sx

Phase 1 of zero-Python rendering: account service.

- Auth pages (login, device, check-email) use _render_auth_page() helper
  calling render_to_sx() + full_page_sx() directly in routes
- Newsletter toggle POST renders inline via render_to_sx()
- Newsletter page helper returns data dict; defpage :data slot fetches,
  :content slot renders via ~account-newsletters-content defcomp
- Fragment page uses (frag ...) IO primitive directly in .sx
- Defpage _eval_slot now uses async_eval_slot_to_sx which expands
  component bodies server-side (executing IO) but serializes tags as SX
- Fix pre-existing OOB ParseError: _eval_slot was producing HTML instead
  of s-expressions for component content slots
- Fix market url_for endpoint: defpage_market_home (app-level, not blueprint)
- Fix events calendar nav: wrap multiple SX parts in fragment

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-03-04 01:16:01 +00:00
parent 44503a7d9b
commit 400667b15a
10 changed files with 173 additions and 228 deletions

View File

@@ -910,6 +910,42 @@ async def async_eval_to_sx(
return serialize(result)
async def async_eval_slot_to_sx(
expr: Any,
env: dict[str, Any],
ctx: RequestContext | None = None,
) -> str:
"""Like async_eval_to_sx but expands component calls.
Used by defpage slot evaluation where the content expression is
typically a component call like ``(~dashboard-content)``. Normal
``async_eval_to_sx`` serializes component calls without expanding;
this variant expands one level so IO primitives in the body execute,
then serializes the result as SX wire format.
"""
if ctx is None:
ctx = RequestContext()
# If expr is a component call, expand it through _aser
if isinstance(expr, list) and expr:
head = expr[0]
if isinstance(head, Symbol) and head.name.startswith("~"):
comp = env.get(head.name)
if isinstance(comp, Component):
result = await _aser_component(comp, expr[1:], env, ctx)
if isinstance(result, SxExpr):
return result.source
if result is None or result is NIL:
return ""
return serialize(result)
# Fall back to normal async_eval_to_sx
result = await _aser(expr, env, ctx)
if isinstance(result, SxExpr):
return result.source
if result is None or result is NIL:
return ""
return serialize(result)
async def _aser(expr: Any, env: dict[str, Any], ctx: RequestContext) -> Any:
"""Evaluate *expr*, producing SxExpr for rendering forms, raw values
for everything else."""
@@ -1022,6 +1058,33 @@ async def _aser_fragment(children: list, env: dict, ctx: RequestContext) -> SxEx
return SxExpr("(<> " + " ".join(parts) + ")")
async def _aser_component(
comp: Component, args: list, env: dict, ctx: RequestContext,
) -> Any:
"""Expand a component body through _aser — produces SX, not HTML."""
kwargs: dict[str, Any] = {}
children: list[Any] = []
i = 0
while i < len(args):
arg = args[i]
if isinstance(arg, Keyword) and i + 1 < len(args):
kwargs[arg.name] = await async_eval(args[i + 1], env, ctx)
i += 2
else:
children.append(arg)
i += 1
local = dict(comp.closure)
local.update(env)
for p in comp.params:
local[p] = kwargs.get(p, NIL)
if comp.has_children:
child_parts = []
for c in children:
child_parts.append(serialize(await _aser(c, env, ctx)))
local["children"] = SxExpr("(<> " + " ".join(child_parts) + ")")
return await _aser(comp.body, local, ctx)
async def _aser_call(
name: str, args: list, env: dict, ctx: RequestContext,
) -> SxExpr:

View File

@@ -132,31 +132,14 @@ def load_page_dir(directory: str, service_name: str) -> list[PageDef]:
# Page execution
# ---------------------------------------------------------------------------
async def _eval_slot(expr: Any, env: dict, ctx: Any,
async_eval_fn: Any, async_eval_to_sx_fn: Any) -> str:
async def _eval_slot(expr: Any, env: dict, ctx: Any) -> str:
"""Evaluate a page slot expression and return an sx source string.
If the expression evaluates to a plain string (e.g. from a Python content
builder), use it directly as sx source. If it evaluates to an AST/list,
serialize it to sx wire format via async_eval_to_sx.
Expands component calls (so IO in the body executes) but serializes
the result as SX wire format, not HTML.
"""
from .html import _RawHTML
from .parser import SxExpr
# First try async_eval to get the raw value
result = await async_eval_fn(expr, env, ctx)
# If it's already an sx source string, use as-is
if isinstance(result, str):
return result
if isinstance(result, _RawHTML):
return result.html
if isinstance(result, SxExpr):
return result.source
if result is None:
return ""
# For other types (lists, components rendered to HTML via _RawHTML, etc.),
# serialize to sx wire format
from .parser import serialize
return serialize(result)
from .async_eval import async_eval_slot_to_sx
return await async_eval_slot_to_sx(expr, env, ctx)
async def execute_page(
@@ -174,7 +157,7 @@ async def execute_page(
6. Branch: full_page_sx() vs oob_page_sx() based on is_htmx_request()
"""
from .jinja_bridge import get_component_env, _get_request_context
from .async_eval import async_eval, async_eval_to_sx
from .async_eval import async_eval
from .page import get_template_context
from .helpers import full_page_sx, oob_page_sx, sx_response
from .layouts import get_layout
@@ -204,20 +187,20 @@ async def execute_page(
env.update(data_result)
# Render content slot (required)
content_sx = await _eval_slot(page_def.content_expr, env, ctx, async_eval, async_eval_to_sx)
content_sx = await _eval_slot(page_def.content_expr, env, ctx)
# Render optional slots
filter_sx = ""
if page_def.filter_expr is not None:
filter_sx = await _eval_slot(page_def.filter_expr, env, ctx, async_eval, async_eval_to_sx)
filter_sx = await _eval_slot(page_def.filter_expr, env, ctx)
aside_sx = ""
if page_def.aside_expr is not None:
aside_sx = await _eval_slot(page_def.aside_expr, env, ctx, async_eval, async_eval_to_sx)
aside_sx = await _eval_slot(page_def.aside_expr, env, ctx)
menu_sx = ""
if page_def.menu_expr is not None:
menu_sx = await _eval_slot(page_def.menu_expr, env, ctx, async_eval, async_eval_to_sx)
menu_sx = await _eval_slot(page_def.menu_expr, env, ctx)
# Resolve layout → header rows + mobile menu fallback
tctx = await get_template_context()