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

@@ -1,6 +1,5 @@
from __future__ import annotations from __future__ import annotations
import path_setup # noqa: F401 # adds shared/ to sys.path import path_setup # noqa: F401 # adds shared/ to sys.path
import sx.sx_components as sx_components # noqa: F401 # ensure Hypercorn --reload watches this file
from pathlib import Path from pathlib import Path
from quart import g, request from quart import g, request
@@ -72,8 +71,9 @@ def create_app() -> "Quart":
app.jinja_loader, app.jinja_loader,
]) ])
# Setup defpage routes # Load .sx component files and setup defpage routes
import sx.sx_components # noqa: F811 — ensure components loaded from shared.sx.jinja_bridge import load_service_components
load_service_components(str(Path(__file__).resolve().parent), service_name="account")
from sxc.pages import setup_account_pages from sxc.pages import setup_account_pages
setup_account_pages() setup_account_pages()

View File

@@ -7,14 +7,13 @@ from __future__ import annotations
from quart import ( from quart import (
Blueprint, Blueprint,
request,
g, g,
) )
from sqlalchemy import select from sqlalchemy import select
from shared.models import UserNewsletter from shared.models import UserNewsletter
from shared.infrastructure.fragments import fetch_fragments from shared.infrastructure.fragments import fetch_fragments
from shared.sx.helpers import sx_response from shared.sx.helpers import sx_response, render_to_sx
def register(url_prefix="/"): def register(url_prefix="/"):
@@ -55,7 +54,26 @@ def register(url_prefix="/"):
await g.s.flush() await g.s.flush()
from sx.sx_components import render_newsletter_toggle # Render toggle directly — no sx_components intermediary
return sx_response(await render_newsletter_toggle(un)) from shared.browser.app.csrf import generate_csrf_token
from shared.infrastructure.urls import account_url
nid = un.newsletter_id
url_fn = getattr(g, "_account_url", None) or account_url
toggle_url = url_fn(f"/newsletter/{nid}/toggle/")
csrf = generate_csrf_token()
bg = "bg-emerald-500" if un.subscribed else "bg-stone-300"
translate = "translate-x-6" if un.subscribed else "translate-x-1"
checked = "true" if un.subscribed else "false"
return sx_response(await render_to_sx(
"account-newsletter-toggle",
id=f"nl-{nid}", url=toggle_url,
hdrs=f'{{"X-CSRFToken": "{csrf}"}}',
target=f"#nl-{nid}",
cls=f"relative inline-flex h-6 w-11 items-center rounded-full transition-colors focus:outline-none focus:ring-2 focus:ring-emerald-500 focus:ring-offset-2 {bg}",
checked=checked,
knob_cls=f"inline-block h-4 w-4 rounded-full bg-white shadow transform transition-transform {translate}",
))
return account_bp return account_bp

View File

@@ -44,6 +44,17 @@ from .services import (
SESSION_USER_KEY = "uid" SESSION_USER_KEY = "uid"
ACCOUNT_SESSION_KEY = "account_sid" ACCOUNT_SESSION_KEY = "account_sid"
async def _render_auth_page(component: str, title: str, **kwargs) -> str:
"""Render an auth page with root layout — replaces sx_components helpers."""
from shared.sx.helpers import render_to_sx, full_page_sx, root_header_sx
from shared.sx.page import get_template_context
ctx = await get_template_context()
hdr = await root_header_sx(ctx)
content = await render_to_sx(component, **{k: v for k, v in kwargs.items() if v})
return await full_page_sx(ctx, header_rows=hdr, content=content,
meta_html=f"<title>{title}</title>")
ALLOWED_CLIENTS = {"blog", "market", "cart", "events", "federation", "orders", "test", "sx", "artdag", "artdag_l2"} ALLOWED_CLIENTS = {"blog", "market", "cart", "events", "federation", "orders", "test", "sx", "artdag", "artdag_l2"}
@@ -275,10 +286,7 @@ def register(url_prefix="/auth"):
redirect_url = pop_login_redirect_target() redirect_url = pop_login_redirect_target()
return redirect(redirect_url) return redirect(redirect_url)
from shared.sx.page import get_template_context return await _render_auth_page("account-login-content", "Login \u2014 Rose Ash")
from sx.sx_components import render_login_page
ctx = await get_template_context()
return await render_login_page(ctx)
@rate_limit( @rate_limit(
key_func=lambda: request.headers.get("X-Forwarded-For", request.remote_addr), key_func=lambda: request.headers.get("X-Forwarded-For", request.remote_addr),
@@ -291,20 +299,20 @@ def register(url_prefix="/auth"):
is_valid, email = validate_email(email_input) is_valid, email = validate_email(email_input)
if not is_valid: if not is_valid:
from shared.sx.page import get_template_context return await _render_auth_page(
from sx.sx_components import render_login_page "account-login-content", "Login \u2014 Rose Ash",
ctx = await get_template_context(error="Please enter a valid email address.", email=email_input) error="Please enter a valid email address.", email=email_input,
return await render_login_page(ctx), 400 ), 400
# Per-email rate limit: 5 magic links per 15 minutes # Per-email rate limit: 5 magic links per 15 minutes
from shared.infrastructure.rate_limit import _check_rate_limit from shared.infrastructure.rate_limit import _check_rate_limit
try: try:
allowed, _ = await _check_rate_limit(f"magic_email:{email}", 5, 900) allowed, _ = await _check_rate_limit(f"magic_email:{email}", 5, 900)
if not allowed: if not allowed:
from shared.sx.page import get_template_context return await _render_auth_page(
from sx.sx_components import render_check_email_page "account-check-email-content", "Check your email \u2014 Rose Ash",
ctx = await get_template_context(email=email, email_error=None) email=email,
return await render_check_email_page(ctx), 200 ), 200
except Exception: except Exception:
pass # Redis down — allow the request pass # Redis down — allow the request
@@ -324,10 +332,10 @@ def register(url_prefix="/auth"):
"Please try again in a moment." "Please try again in a moment."
) )
from shared.sx.page import get_template_context return await _render_auth_page(
from sx.sx_components import render_check_email_page "account-check-email-content", "Check your email \u2014 Rose Ash",
ctx = await get_template_context(email=email, email_error=email_error) email=email, email_error=email_error,
return await render_check_email_page(ctx) )
@auth_bp.get("/magic/<token>/") @auth_bp.get("/magic/<token>/")
async def magic(token: str): async def magic(token: str):
@@ -340,17 +348,17 @@ def register(url_prefix="/auth"):
user, error = await validate_magic_link(s, token) user, error = await validate_magic_link(s, token)
if error: if error:
from shared.sx.page import get_template_context return await _render_auth_page(
from sx.sx_components import render_login_page "account-login-content", "Login \u2014 Rose Ash",
ctx = await get_template_context(error=error) error=error,
return await render_login_page(ctx), 400 ), 400
user_id = user.id user_id = user.id
except Exception: except Exception:
from shared.sx.page import get_template_context return await _render_auth_page(
from sx.sx_components import render_login_page "account-login-content", "Login \u2014 Rose Ash",
ctx = await get_template_context(error="Could not sign you in right now. Please try again.") error="Could not sign you in right now. Please try again.",
return await render_login_page(ctx), 502 ), 502
assert user_id is not None assert user_id is not None
@@ -679,11 +687,11 @@ def register(url_prefix="/auth"):
@auth_bp.get("/device/") @auth_bp.get("/device/")
async def device_form(): async def device_form():
"""Browser form where user enters the code displayed in terminal.""" """Browser form where user enters the code displayed in terminal."""
from shared.sx.page import get_template_context
from sx.sx_components import render_device_page
code = request.args.get("code", "") code = request.args.get("code", "")
ctx = await get_template_context(code=code) return await _render_auth_page(
return await render_device_page(ctx) "account-device-content", "Authorize Device \u2014 Rose Ash",
code=code,
)
@auth_bp.post("/device") @auth_bp.post("/device")
@auth_bp.post("/device/") @auth_bp.post("/device/")
@@ -693,20 +701,20 @@ def register(url_prefix="/auth"):
user_code = (form.get("code") or "").strip().replace("-", "").upper() user_code = (form.get("code") or "").strip().replace("-", "").upper()
if not user_code or len(user_code) != 8: if not user_code or len(user_code) != 8:
from shared.sx.page import get_template_context return await _render_auth_page(
from sx.sx_components import render_device_page "account-device-content", "Authorize Device \u2014 Rose Ash",
ctx = await get_template_context(error="Please enter a valid 8-character code.", code=form.get("code", "")) error="Please enter a valid 8-character code.", code=form.get("code", ""),
return await render_device_page(ctx), 400 ), 400
from shared.infrastructure.auth_redis import get_auth_redis from shared.infrastructure.auth_redis import get_auth_redis
r = await get_auth_redis() r = await get_auth_redis()
device_code = await r.get(f"devflow_uc:{user_code}") device_code = await r.get(f"devflow_uc:{user_code}")
if not device_code: if not device_code:
from shared.sx.page import get_template_context return await _render_auth_page(
from sx.sx_components import render_device_page "account-device-content", "Authorize Device \u2014 Rose Ash",
ctx = await get_template_context(error="Code not found or expired. Please try again.", code=form.get("code", "")) error="Code not found or expired. Please try again.", code=form.get("code", ""),
return await render_device_page(ctx), 400 ), 400
if isinstance(device_code, bytes): if isinstance(device_code, bytes):
device_code = device_code.decode() device_code = device_code.decode()
@@ -720,23 +728,19 @@ def register(url_prefix="/auth"):
# Logged in — approve immediately # Logged in — approve immediately
ok = await _approve_device(device_code, g.user) ok = await _approve_device(device_code, g.user)
if not ok: if not ok:
from shared.sx.page import get_template_context return await _render_auth_page(
from sx.sx_components import render_device_page "account-device-content", "Authorize Device \u2014 Rose Ash",
ctx = await get_template_context(error="Code expired or already used.") error="Code expired or already used.",
return await render_device_page(ctx), 400 ), 400
from shared.sx.page import get_template_context return await _render_auth_page(
from sx.sx_components import render_device_approved_page "account-device-approved", "Device Authorized \u2014 Rose Ash",
ctx = await get_template_context() )
return await render_device_approved_page(ctx)
@auth_bp.get("/device/complete") @auth_bp.get("/device/complete")
@auth_bp.get("/device/complete/") @auth_bp.get("/device/complete/")
async def device_complete(): async def device_complete():
"""Post-login redirect — completes approval after magic link auth.""" """Post-login redirect — completes approval after magic link auth."""
from shared.sx.page import get_template_context
from sx.sx_components import render_device_page, render_device_approved_page
device_code = request.args.get("code", "") device_code = request.args.get("code", "")
if not device_code: if not device_code:
@@ -748,12 +752,13 @@ def register(url_prefix="/auth"):
ok = await _approve_device(device_code, g.user) ok = await _approve_device(device_code, g.user)
if not ok: if not ok:
ctx = await get_template_context( return await _render_auth_page(
"account-device-content", "Authorize Device \u2014 Rose Ash",
error="Code expired or already used. Please start the login process again in your terminal.", error="Code expired or already used. Please start the login process again in your terminal.",
) ), 400
return await render_device_page(ctx), 400
ctx = await get_template_context() return await _render_auth_page(
return await render_device_approved_page(ctx) "account-device-approved", "Device Authorized \u2014 Rose Ash",
)
return auth_bp return auth_bp

View File

@@ -1,107 +0,0 @@
"""
Account service s-expression page components.
Renders login, device auth, and check-email pages. Dashboard and newsletters
are now fully handled by .sx defcomps called from defpage expressions.
"""
from __future__ import annotations
import os
from typing import Any
from shared.sx.jinja_bridge import load_service_components
from shared.sx.helpers import (
render_to_sx,
root_header_sx, full_page_sx,
)
# Load account-specific .sx components + handlers at import time
load_service_components(os.path.dirname(os.path.dirname(__file__)),
service_name="account")
# ---------------------------------------------------------------------------
# Public API: Auth pages (login, device, check_email)
# ---------------------------------------------------------------------------
async def render_login_page(ctx: dict) -> str:
"""Full page: login form."""
error = ctx.get("error", "")
email = ctx.get("email", "")
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>')
async def render_device_page(ctx: dict) -> str:
"""Full page: device authorization form."""
error = ctx.get("error", "")
code = ctx.get("code", "")
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 = 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>')
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 = 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>')
# ---------------------------------------------------------------------------
# Public API: Fragment renderers for POST handlers
# ---------------------------------------------------------------------------
async def render_newsletter_toggle(un) -> str:
"""Render a newsletter toggle switch for POST response."""
from shared.browser.app.csrf import generate_csrf_token
nid = un.newsletter_id
from quart import g
account_url_fn = getattr(g, "_account_url", None)
if account_url_fn is None:
from shared.infrastructure.urls import account_url
account_url_fn = account_url
toggle_url = account_url_fn(f"/newsletter/{nid}/toggle/")
csrf = generate_csrf_token()
if un.subscribed:
bg = "bg-emerald-500"
translate = "translate-x-6"
checked = "true"
else:
bg = "bg-stone-300"
translate = "translate-x-1"
checked = "false"
return await render_to_sx(
"account-newsletter-toggle",
id=f"nl-{nid}", url=toggle_url,
hdrs=f'{{"X-CSRFToken": "{csrf}"}}',
target=f"#nl-{nid}",
cls=f"relative inline-flex h-6 w-11 items-center rounded-full transition-colors focus:outline-none focus:ring-2 focus:ring-emerald-500 focus:ring-offset-2 {bg}",
checked=checked,
knob_cls=f"inline-block h-4 w-4 rounded-full bg-white shadow transform transition-transform {translate}",
)

View File

@@ -54,6 +54,7 @@ async def _account_oob(ctx: dict, **kw: Any) -> str:
async def _account_mobile(ctx: dict, **kw: Any) -> str: 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.helpers import mobile_menu_sx, mobile_root_nav_sx, render_to_sx
from shared.sx.parser import SxExpr from shared.sx.parser import SxExpr
ctx = _inject_account_nav(ctx) ctx = _inject_account_nav(ctx)
nav_items = await render_to_sx("auth-nav-items", nav_items = await render_to_sx("auth-nav-items",
account_url=_call_url(ctx, "account_url", ""), account_url=_call_url(ctx, "account_url", ""),
@@ -97,18 +98,16 @@ def _register_account_helpers() -> None:
from shared.sx.pages import register_page_helpers from shared.sx.pages import register_page_helpers
register_page_helpers("account", { register_page_helpers("account", {
"newsletters-content": _h_newsletters_content, "newsletters-data": _h_newsletters_data,
"fragment-content": _h_fragment_content,
}) })
async def _h_newsletters_content(**kw): async def _h_newsletters_data(**kw):
"""Fetch newsletter data, return assembled defcomp call.""" """Fetch newsletter data returns dict merged into defpage env."""
from quart import g from quart import g
from sqlalchemy import select from sqlalchemy import select
from shared.models import UserNewsletter from shared.models import UserNewsletter
from shared.models.ghost_membership_entities import GhostNewsletter from shared.models.ghost_membership_entities import GhostNewsletter
from shared.sx.helpers import render_to_sx
result = await g.s.execute( result = await g.s.execute(
select(GhostNewsletter).order_by(GhostNewsletter.name) select(GhostNewsletter).order_by(GhostNewsletter.name)
@@ -135,31 +134,6 @@ async def _h_newsletters_content(**kw):
if account_url is None: if account_url is None:
from shared.infrastructure.urls import account_url as _account_url from shared.infrastructure.urls import account_url as _account_url
account_url = _account_url account_url = _account_url
# Call account_url to get the base URL string
account_url_str = account_url("") if callable(account_url) else str(account_url or "") account_url_str = account_url("") if callable(account_url) else str(account_url or "")
return await render_to_sx("account-newsletters-content", return {"newsletter-list": newsletter_list, "account-url": account_url_str}
newsletter_list=newsletter_list,
account_url=account_url_str)
async def _h_fragment_content(slug=None, **kw):
from quart import g, abort
from shared.infrastructure.fragments import fetch_fragment
if not slug or not g.get("user"):
return ""
fragment_html = await fetch_fragment(
"events", "account-page",
params={"slug": slug, "user_id": str(g.user.id)},
)
if not fragment_html:
abort(404)
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)

View File

@@ -18,7 +18,10 @@
:path "/newsletters/" :path "/newsletters/"
:auth :login :auth :login
:layout :account :layout :account
:content (newsletters-content)) :data (newsletters-data)
:content (~account-newsletters-content
:newsletter-list newsletter-list
:account-url account-url))
;; --------------------------------------------------------------------------- ;; ---------------------------------------------------------------------------
;; Fragment pages (tickets, bookings, etc. from events service) ;; Fragment pages (tickets, bookings, etc. from events service)
@@ -28,4 +31,10 @@
:path "/<slug>/" :path "/<slug>/"
:auth :login :auth :login
:layout :account :layout :account
:content (fragment-content slug)) :content (let* ((user (current-user))
(result (frag "events" "account-page"
:slug slug
:user-id (str (get user "id")))))
(if (or (nil? result) (empty? result))
(abort 404)
result)))

View File

@@ -198,7 +198,7 @@ async def _calendar_nav_sx(ctx: dict) -> str:
admin_href = url_for("defpage_calendar_admin", calendar_slug=cal_slug) admin_href = url_for("defpage_calendar_admin", calendar_slug=cal_slug)
parts.append(await render_to_sx("nav-link", href=admin_href, icon="fa fa-cog", parts.append(await render_to_sx("nav-link", href=admin_href, icon="fa fa-cog",
select_colours=select_colours)) select_colours=select_colours))
return "".join(parts) return "(<> " + " ".join(parts) + ")" if parts else ""
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------

View File

@@ -111,7 +111,7 @@ async def _market_header_sx(ctx: dict, *, oob: bool = False) -> str:
sub_div=SxExpr(sub_div) if sub_div else None, sub_div=SxExpr(sub_div) if sub_div else None,
) )
link_href = url_for("market.browse.defpage_market_home") link_href = url_for("defpage_market_home")
# Build desktop nav from categories # Build desktop nav from categories
categories = ctx.get("categories", {}) categories = ctx.get("categories", {})

View File

@@ -910,6 +910,42 @@ async def async_eval_to_sx(
return serialize(result) 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: async def _aser(expr: Any, env: dict[str, Any], ctx: RequestContext) -> Any:
"""Evaluate *expr*, producing SxExpr for rendering forms, raw values """Evaluate *expr*, producing SxExpr for rendering forms, raw values
for everything else.""" for everything else."""
@@ -1022,6 +1058,33 @@ async def _aser_fragment(children: list, env: dict, ctx: RequestContext) -> SxEx
return SxExpr("(<> " + " ".join(parts) + ")") 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( async def _aser_call(
name: str, args: list, env: dict, ctx: RequestContext, name: str, args: list, env: dict, ctx: RequestContext,
) -> SxExpr: ) -> SxExpr:

View File

@@ -132,31 +132,14 @@ def load_page_dir(directory: str, service_name: str) -> list[PageDef]:
# Page execution # Page execution
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
async def _eval_slot(expr: Any, env: dict, ctx: Any, async def _eval_slot(expr: Any, env: dict, ctx: Any) -> str:
async_eval_fn: Any, async_eval_to_sx_fn: Any) -> str:
"""Evaluate a page slot expression and return an sx source string. """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 Expands component calls (so IO in the body executes) but serializes
builder), use it directly as sx source. If it evaluates to an AST/list, the result as SX wire format, not HTML.
serialize it to sx wire format via async_eval_to_sx.
""" """
from .html import _RawHTML from .async_eval import async_eval_slot_to_sx
from .parser import SxExpr return await async_eval_slot_to_sx(expr, env, ctx)
# 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)
async def execute_page( 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() 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 .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 .page import get_template_context
from .helpers import full_page_sx, oob_page_sx, sx_response from .helpers import full_page_sx, oob_page_sx, sx_response
from .layouts import get_layout from .layouts import get_layout
@@ -204,20 +187,20 @@ async def execute_page(
env.update(data_result) env.update(data_result)
# Render content slot (required) # 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 # Render optional slots
filter_sx = "" filter_sx = ""
if page_def.filter_expr is not None: 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 = "" aside_sx = ""
if page_def.aside_expr is not None: 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 = "" menu_sx = ""
if page_def.menu_expr is not None: 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 # Resolve layout → header rows + mobile menu fallback
tctx = await get_template_context() tctx = await get_template_context()