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
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 quart import g, request
@@ -72,8 +71,9 @@ def create_app() -> "Quart":
app.jinja_loader,
])
# Setup defpage routes
import sx.sx_components # noqa: F811 — ensure components loaded
# Load .sx component files and setup defpage routes
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
setup_account_pages()

View File

@@ -7,14 +7,13 @@ from __future__ import annotations
from quart import (
Blueprint,
request,
g,
)
from sqlalchemy import select
from shared.models import UserNewsletter
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="/"):
@@ -55,7 +54,26 @@ def register(url_prefix="/"):
await g.s.flush()
from sx.sx_components import render_newsletter_toggle
return sx_response(await render_newsletter_toggle(un))
# Render toggle directly — no sx_components intermediary
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

View File

@@ -44,6 +44,17 @@ from .services import (
SESSION_USER_KEY = "uid"
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"}
@@ -275,10 +286,7 @@ def register(url_prefix="/auth"):
redirect_url = pop_login_redirect_target()
return redirect(redirect_url)
from shared.sx.page import get_template_context
from sx.sx_components import render_login_page
ctx = await get_template_context()
return await render_login_page(ctx)
return await _render_auth_page("account-login-content", "Login \u2014 Rose Ash")
@rate_limit(
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)
if not is_valid:
from shared.sx.page import get_template_context
from sx.sx_components import render_login_page
ctx = await get_template_context(error="Please enter a valid email address.", email=email_input)
return await render_login_page(ctx), 400
return await _render_auth_page(
"account-login-content", "Login \u2014 Rose Ash",
error="Please enter a valid email address.", email=email_input,
), 400
# Per-email rate limit: 5 magic links per 15 minutes
from shared.infrastructure.rate_limit import _check_rate_limit
try:
allowed, _ = await _check_rate_limit(f"magic_email:{email}", 5, 900)
if not allowed:
from shared.sx.page import get_template_context
from sx.sx_components import render_check_email_page
ctx = await get_template_context(email=email, email_error=None)
return await render_check_email_page(ctx), 200
return await _render_auth_page(
"account-check-email-content", "Check your email \u2014 Rose Ash",
email=email,
), 200
except Exception:
pass # Redis down — allow the request
@@ -324,10 +332,10 @@ def register(url_prefix="/auth"):
"Please try again in a moment."
)
from shared.sx.page import get_template_context
from sx.sx_components import render_check_email_page
ctx = await get_template_context(email=email, email_error=email_error)
return await render_check_email_page(ctx)
return await _render_auth_page(
"account-check-email-content", "Check your email \u2014 Rose Ash",
email=email, email_error=email_error,
)
@auth_bp.get("/magic/<token>/")
async def magic(token: str):
@@ -340,17 +348,17 @@ def register(url_prefix="/auth"):
user, error = await validate_magic_link(s, token)
if error:
from shared.sx.page import get_template_context
from sx.sx_components import render_login_page
ctx = await get_template_context(error=error)
return await render_login_page(ctx), 400
return await _render_auth_page(
"account-login-content", "Login \u2014 Rose Ash",
error=error,
), 400
user_id = user.id
except Exception:
from shared.sx.page import get_template_context
from sx.sx_components import render_login_page
ctx = await get_template_context(error="Could not sign you in right now. Please try again.")
return await render_login_page(ctx), 502
return await _render_auth_page(
"account-login-content", "Login \u2014 Rose Ash",
error="Could not sign you in right now. Please try again.",
), 502
assert user_id is not None
@@ -679,11 +687,11 @@ def register(url_prefix="/auth"):
@auth_bp.get("/device/")
async def device_form():
"""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", "")
ctx = await get_template_context(code=code)
return await render_device_page(ctx)
return await _render_auth_page(
"account-device-content", "Authorize Device \u2014 Rose Ash",
code=code,
)
@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()
if not user_code or len(user_code) != 8:
from shared.sx.page import get_template_context
from sx.sx_components import render_device_page
ctx = await get_template_context(error="Please enter a valid 8-character code.", code=form.get("code", ""))
return await render_device_page(ctx), 400
return await _render_auth_page(
"account-device-content", "Authorize Device \u2014 Rose Ash",
error="Please enter a valid 8-character code.", code=form.get("code", ""),
), 400
from shared.infrastructure.auth_redis import get_auth_redis
r = await get_auth_redis()
device_code = await r.get(f"devflow_uc:{user_code}")
if not device_code:
from shared.sx.page import get_template_context
from sx.sx_components import render_device_page
ctx = await get_template_context(error="Code not found or expired. Please try again.", code=form.get("code", ""))
return await render_device_page(ctx), 400
return await _render_auth_page(
"account-device-content", "Authorize Device \u2014 Rose Ash",
error="Code not found or expired. Please try again.", code=form.get("code", ""),
), 400
if isinstance(device_code, bytes):
device_code = device_code.decode()
@@ -720,23 +728,19 @@ def register(url_prefix="/auth"):
# Logged in — approve immediately
ok = await _approve_device(device_code, g.user)
if not ok:
from shared.sx.page import get_template_context
from sx.sx_components import render_device_page
ctx = await get_template_context(error="Code expired or already used.")
return await render_device_page(ctx), 400
return await _render_auth_page(
"account-device-content", "Authorize Device \u2014 Rose Ash",
error="Code expired or already used.",
), 400
from shared.sx.page import get_template_context
from sx.sx_components import render_device_approved_page
ctx = await get_template_context()
return await render_device_approved_page(ctx)
return await _render_auth_page(
"account-device-approved", "Device Authorized \u2014 Rose Ash",
)
@auth_bp.get("/device/complete")
@auth_bp.get("/device/complete/")
async def device_complete():
"""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", "")
if not device_code:
@@ -748,12 +752,13 @@ def register(url_prefix="/auth"):
ok = await _approve_device(device_code, g.user)
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.",
)
return await render_device_page(ctx), 400
), 400
ctx = await get_template_context()
return await render_device_approved_page(ctx)
return await _render_auth_page(
"account-device-approved", "Device Authorized \u2014 Rose Ash",
)
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:
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 = await render_to_sx("auth-nav-items",
account_url=_call_url(ctx, "account_url", ""),
@@ -97,18 +98,16 @@ def _register_account_helpers() -> None:
from shared.sx.pages import register_page_helpers
register_page_helpers("account", {
"newsletters-content": _h_newsletters_content,
"fragment-content": _h_fragment_content,
"newsletters-data": _h_newsletters_data,
})
async def _h_newsletters_content(**kw):
"""Fetch newsletter data, return assembled defcomp call."""
async def _h_newsletters_data(**kw):
"""Fetch newsletter data returns dict merged into defpage env."""
from quart import g
from sqlalchemy import select
from shared.models import UserNewsletter
from shared.models.ghost_membership_entities import GhostNewsletter
from shared.sx.helpers import render_to_sx
result = await g.s.execute(
select(GhostNewsletter).order_by(GhostNewsletter.name)
@@ -135,31 +134,6 @@ async def _h_newsletters_content(**kw):
if account_url is None:
from shared.infrastructure.urls import account_url as _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 "")
return await render_to_sx("account-newsletters-content",
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)
return {"newsletter-list": newsletter_list, "account-url": account_url_str}

View File

@@ -18,7 +18,10 @@
:path "/newsletters/"
:auth :login
: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)
@@ -28,4 +31,10 @@
:path "/<slug>/"
:auth :login
: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)))