Send all responses as sexp wire format with client-side rendering
All checks were successful
Build and Deploy / build-and-deploy (push) Successful in 5m35s

- Server sends sexp source text, client (sexp.js) renders everything
- SexpExpr marker class for nested sexp composition in serialize()
- sexp_page() HTML shell with data-mount="body" for full page loads
- sexp_response() returns text/sexp for OOB/partial responses
- ~app-body layout component replaces ~app-layout (no raw!)
- ~rich-text is the only component using raw! (for CMS HTML content)
- Fragment endpoints return text/sexp, auto-wrapped in SexpExpr
- All _*_html() helpers converted to _*_sexp() returning sexp source
- Head auto-hoist: sexp.js moves meta/title/link/script[ld+json]
  from rendered body to document.head automatically
- Unknown components render warning box instead of crashing page
- Component kwargs preserve AST for lazy rendering (fixes <> in kwargs)
- Fix unterminated paren in events/sexp/tickets.sexpr

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-03-01 09:45:07 +00:00
parent 0d48fd22ee
commit 22802bd36b
270 changed files with 7153 additions and 5382 deletions

View File

@@ -44,14 +44,14 @@ async def account_context() -> dict:
if ident["session_id"] is not None:
cart_params["session_id"] = ident["session_id"]
cart_mini_html, auth_menu_html, nav_tree_html = await fetch_fragments([
cart_mini, auth_menu, nav_tree = await fetch_fragments([
("cart", "cart-mini", cart_params or None),
("account", "auth-menu", {"email": user.email} if user else None),
("blog", "nav-tree", {"app_name": "account", "path": request.path}),
])
ctx["cart_mini_html"] = cart_mini_html
ctx["auth_menu_html"] = auth_menu_html
ctx["nav_tree_html"] = nav_tree_html
ctx["cart_mini"] = cart_mini
ctx["auth_menu"] = auth_menu
ctx["nav_tree"] = nav_tree
return ctx

View File

@@ -18,6 +18,7 @@ from shared.models import UserNewsletter
from shared.models.ghost_membership_entities import GhostNewsletter
from shared.infrastructure.urls import login_url
from shared.infrastructure.fragments import fetch_fragment, fetch_fragments
from shared.sexp.helpers import sexp_response
oob = {
"oob_extends": "oob_elements.html",
@@ -41,7 +42,7 @@ def register(url_prefix="/"):
("cart", "account-nav-item", {}),
("artdag", "nav-item", {}),
], required=False)
return {"oob": oob, "account_nav_html": events_nav + cart_nav + artdag_nav}
return {"oob": oob, "account_nav": events_nav + cart_nav + artdag_nav}
@account_bp.get("/")
async def account():
@@ -55,10 +56,10 @@ def register(url_prefix="/"):
ctx = await get_template_context()
if not is_htmx_request():
html = await render_account_page(ctx)
return await make_response(html)
else:
html = await render_account_oob(ctx)
return await make_response(html)
sexp_src = await render_account_oob(ctx)
return sexp_response(sexp_src)
@account_bp.get("/newsletters/")
async def newsletters():
@@ -94,10 +95,10 @@ def register(url_prefix="/"):
ctx = await get_template_context()
if not is_htmx_request():
html = await render_newsletters_page(ctx, newsletter_list)
return await make_response(html)
else:
html = await render_newsletters_oob(ctx, newsletter_list)
return await make_response(html)
sexp_src = await render_newsletters_oob(ctx, newsletter_list)
return sexp_response(sexp_src)
@account_bp.post("/newsletter/<int:newsletter_id>/toggle/")
async def toggle_newsletter(newsletter_id: int):
@@ -125,7 +126,7 @@ def register(url_prefix="/"):
await g.s.flush()
from sexp.sexp_components import render_newsletter_toggle
return render_newsletter_toggle(un)
return sexp_response(render_newsletter_toggle(un))
# Catch-all for fragment-provided pages — must be last
@account_bp.get("/<slug>/")
@@ -149,9 +150,9 @@ def register(url_prefix="/"):
ctx = await get_template_context()
if not is_htmx_request():
html = await render_fragment_page(ctx, fragment_html)
return await make_response(html)
else:
html = await render_fragment_oob(ctx, fragment_html)
return await make_response(html)
sexp_src = await render_fragment_oob(ctx, fragment_html)
return sexp_response(sexp_src)
return account_bp

View File

@@ -1,6 +1,6 @@
"""Account app fragment endpoints.
Exposes HTML fragments at ``/internal/fragments/<type>`` for consumption
Exposes sexp fragments at ``/internal/fragments/<type>`` for consumption
by other coop apps via the fragment client.
Fragments:
@@ -18,18 +18,17 @@ def register():
bp = Blueprint("fragments", __name__, url_prefix="/internal/fragments")
# ---------------------------------------------------------------
# Fragment handlers
# Fragment handlers — return sexp source text
# ---------------------------------------------------------------
async def _auth_menu():
from shared.infrastructure.urls import account_url
from shared.sexp.jinja_bridge import sexp as render_sexp
from shared.sexp.helpers import sexp_call
user_email = request.args.get("email", "")
return render_sexp(
'(~auth-menu :user-email user-email :account-url account-url)',
**{"user-email": user_email or None, "account-url": account_url("")},
)
return sexp_call("auth-menu",
user_email=user_email or None,
account_url=account_url(""))
_handlers = {
"auth-menu": _auth_menu,
@@ -48,8 +47,8 @@ def register():
async def get_fragment(fragment_type: str):
handler = _handlers.get(fragment_type)
if handler is None:
return Response("", status=200, content_type="text/html")
html = await handler()
return Response(html, status=200, content_type="text/html")
return Response("", status=200, content_type="text/sexp")
src = await handler()
return Response(src, status=200, content_type="text/sexp")
return bp

View File

@@ -3,12 +3,12 @@
(defcomp ~account-login-error (&key error)
(when error
(div :class "bg-red-50 border border-red-200 text-red-700 p-3 rounded mb-4"
(raw! error))))
error)))
(defcomp ~account-login-form (&key error-html action csrf-token email)
(defcomp ~account-login-form (&key error action csrf-token email)
(div :class "py-8 max-w-md mx-auto"
(h1 :class "text-2xl font-bold mb-6" "Sign in")
(raw! error-html)
error
(form :method "post" :action action :class "space-y-4"
(input :type "hidden" :name "csrf_token" :value csrf-token)
(div
@@ -22,13 +22,13 @@
(defcomp ~account-device-error (&key error)
(when error
(div :class "bg-red-50 border border-red-200 text-red-700 p-3 rounded mb-4"
(raw! error))))
error)))
(defcomp ~account-device-form (&key error-html action csrf-token code)
(defcomp ~account-device-form (&key error action csrf-token code)
(div :class "py-8 max-w-md mx-auto"
(h1 :class "text-2xl font-bold mb-6" "Authorize device")
(p :class "text-stone-600 mb-4" "Enter the code shown in your terminal to sign in.")
(raw! error-html)
error
(form :method "post" :action action :class "space-y-4"
(input :type "hidden" :name "csrf_token" :value csrf-token)
(div
@@ -48,11 +48,11 @@
(defcomp ~account-check-email-error (&key error)
(when error
(div :class "bg-yellow-50 border border-yellow-200 text-yellow-700 p-3 rounded mt-4"
(raw! error))))
error)))
(defcomp ~account-check-email (&key email error-html)
(defcomp ~account-check-email (&key email error)
(div :class "py-8 max-w-md mx-auto text-center"
(h1 :class "text-2xl font-bold mb-4" "Check your email")
(p :class "text-stone-600 mb-2" "We sent a sign-in link to " (strong (raw! email)) ".")
(p :class "text-stone-600 mb-2" "We sent a sign-in link to " (strong email) ".")
(p :class "text-stone-500 text-sm" "Click the link in the email to sign in. The link expires in 15 minutes.")
(raw! error-html)))
error))

View File

@@ -3,15 +3,15 @@
(defcomp ~account-error-banner (&key error)
(when error
(div :class "rounded-lg border border-red-200 bg-red-50 text-red-800 px-4 py-3 text-sm"
(raw! error))))
error)))
(defcomp ~account-user-email (&key email)
(when email
(p :class "text-sm text-stone-500 mt-1" (raw! email))))
(p :class "text-sm text-stone-500 mt-1" email)))
(defcomp ~account-user-name (&key name)
(when name
(p :class "text-sm text-stone-600" (raw! name))))
(p :class "text-sm text-stone-600" name)))
(defcomp ~account-logout-form (&key csrf-token)
(form :action "/auth/logout/" :method "post"
@@ -22,27 +22,27 @@
(defcomp ~account-label-item (&key name)
(span :class "inline-flex items-center rounded-full border border-stone-200 px-3 py-1 text-xs font-medium bg-white/60"
(raw! name)))
name))
(defcomp ~account-labels-section (&key items-html)
(when items-html
(defcomp ~account-labels-section (&key items)
(when items
(div
(h2 :class "text-base font-semibold tracking-tight mb-3" "Labels")
(div :class "flex flex-wrap gap-2" (raw! items-html)))))
(div :class "flex flex-wrap gap-2" items))))
(defcomp ~account-main-panel (&key error-html email-html name-html logout-html labels-html)
(defcomp ~account-main-panel (&key error email name logout labels)
(div :class "w-full max-w-3xl mx-auto px-4 py-6"
(div :class "bg-white/70 backdrop-blur rounded-2xl shadow border border-stone-200 p-6 sm:p-8 space-y-8"
(raw! error-html)
error
(div :class "flex items-center justify-between"
(div
(h1 :class "text-xl font-semibold tracking-tight" "Account")
(raw! email-html)
(raw! name-html))
(raw! logout-html))
(raw! labels-html))))
email
name)
logout)
labels)))
;; Header child wrapper
(defcomp ~account-header-child (&key inner-html)
(defcomp ~account-header-child (&key inner)
(div :id "root-header-child" :class "flex flex-col w-full items-center"
(raw! inner-html)))
inner))

View File

@@ -2,36 +2,36 @@
(defcomp ~account-newsletter-desc (&key description)
(when description
(p :class "text-xs text-stone-500 mt-0.5 truncate" (raw! description))))
(p :class "text-xs text-stone-500 mt-0.5 truncate" description)))
(defcomp ~account-newsletter-toggle (&key id url hdrs target cls checked knob-cls)
(div :id id :class "flex items-center"
(button :hx-post url :hx-headers hdrs :hx-target target :hx-swap "outerHTML"
(button :sx-post url :sx-headers hdrs :sx-target target :sx-swap "outerHTML"
:class cls :role "switch" :aria-checked checked
(span :class knob-cls))))
(defcomp ~account-newsletter-toggle-off (&key id url hdrs target)
(div :id id :class "flex items-center"
(button :hx-post url :hx-headers hdrs :hx-target target :hx-swap "outerHTML"
(button :sx-post url :sx-headers hdrs :sx-target target :sx-swap "outerHTML"
:class "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-stone-300"
:role "switch" :aria-checked "false"
(span :class "inline-block h-4 w-4 rounded-full bg-white shadow transform transition-transform translate-x-1"))))
(defcomp ~account-newsletter-item (&key name desc-html toggle-html)
(defcomp ~account-newsletter-item (&key name desc toggle)
(div :class "flex items-center justify-between py-4 first:pt-0 last:pb-0"
(div :class "min-w-0 flex-1"
(p :class "text-sm font-medium text-stone-800" (raw! name))
(raw! desc-html))
(div :class "ml-4 flex-shrink-0" (raw! toggle-html))))
(p :class "text-sm font-medium text-stone-800" name)
desc)
(div :class "ml-4 flex-shrink-0" toggle)))
(defcomp ~account-newsletter-list (&key items-html)
(div :class "divide-y divide-stone-100" (raw! items-html)))
(defcomp ~account-newsletter-list (&key items)
(div :class "divide-y divide-stone-100" items))
(defcomp ~account-newsletter-empty ()
(p :class "text-sm text-stone-500" "No newsletters available."))
(defcomp ~account-newsletters-panel (&key list-html)
(defcomp ~account-newsletters-panel (&key list)
(div :class "w-full max-w-3xl mx-auto px-4 py-6"
(div :class "bg-white/70 backdrop-blur rounded-2xl shadow border border-stone-200 p-6 sm:p-8 space-y-6"
(h1 :class "text-xl font-semibold tracking-tight" "Newsletters")
(raw! list-html))))
list)))

View File

@@ -9,10 +9,10 @@ from __future__ import annotations
import os
from typing import Any
from shared.sexp.jinja_bridge import render, load_service_components
from shared.sexp.jinja_bridge import load_service_components
from shared.sexp.helpers import (
call_url, root_header_html, search_desktop_html,
search_mobile_html, full_page, oob_page,
call_url, sexp_call, SexpExpr,
root_header_sexp, full_page_sexp, header_child_sexp, oob_page_sexp,
)
# Load account-specific .sexpr components at import time
@@ -23,51 +23,53 @@ load_service_components(os.path.dirname(os.path.dirname(__file__)))
# Header helpers
# ---------------------------------------------------------------------------
def _auth_nav_html(ctx: dict) -> str:
def _auth_nav_sexp(ctx: dict) -> str:
"""Auth section desktop nav items."""
html = render(
"nav-link",
href=call_url(ctx, "account_url", "/newsletters/"),
label="newsletters",
select_colours=ctx.get("select_colours", ""),
)
account_nav_html = ctx.get("account_nav_html", "")
if account_nav_html:
html += account_nav_html
return html
parts = [
sexp_call("nav-link",
href=call_url(ctx, "account_url", "/newsletters/"),
label="newsletters",
select_colours=ctx.get("select_colours", ""),
)
]
account_nav = ctx.get("account_nav")
if account_nav:
parts.append(account_nav)
return "(<> " + " ".join(parts) + ")"
def _auth_header_html(ctx: dict, *, oob: bool = False) -> str:
def _auth_header_sexp(ctx: dict, *, oob: bool = False) -> str:
"""Build the account section header row."""
return render(
"menu-row",
return sexp_call(
"menu-row-sx",
id="auth-row", level=1, colour="sky",
link_href=call_url(ctx, "account_url", "/"),
link_label="account", icon="fa-solid fa-user",
nav_html=_auth_nav_html(ctx),
nav=SexpExpr(_auth_nav_sexp(ctx)),
child_id="auth-header-child", oob=oob,
)
def _auth_nav_mobile_html(ctx: dict) -> str:
def _auth_nav_mobile_sexp(ctx: dict) -> str:
"""Mobile nav menu for auth section."""
html = render(
"nav-link",
href=call_url(ctx, "account_url", "/newsletters/"),
label="newsletters",
select_colours=ctx.get("select_colours", ""),
)
account_nav_html = ctx.get("account_nav_html", "")
if account_nav_html:
html += account_nav_html
return html
parts = [
sexp_call("nav-link",
href=call_url(ctx, "account_url", "/newsletters/"),
label="newsletters",
select_colours=ctx.get("select_colours", ""),
)
]
account_nav = ctx.get("account_nav")
if account_nav:
parts.append(account_nav)
return "(<> " + " ".join(parts) + ")"
# ---------------------------------------------------------------------------
# Account dashboard (GET /)
# ---------------------------------------------------------------------------
def _account_main_panel_html(ctx: dict) -> str:
def _account_main_panel_sexp(ctx: dict) -> str:
"""Account info panel with user details and logout."""
from quart import g
from shared.browser.app.csrf import generate_csrf_token
@@ -75,30 +77,33 @@ def _account_main_panel_html(ctx: dict) -> str:
user = getattr(g, "user", None)
error = ctx.get("error", "")
error_html = render("account-error-banner", error=error) if error else ""
error_sexp = sexp_call("account-error-banner", error=error) if error else ""
user_email_html = ""
user_name_html = ""
user_email_sexp = ""
user_name_sexp = ""
if user:
user_email_html = render("account-user-email", email=user.email)
user_email_sexp = sexp_call("account-user-email", email=user.email)
if user.name:
user_name_html = render("account-user-name", name=user.name)
user_name_sexp = sexp_call("account-user-name", name=user.name)
logout_html = render("account-logout-form", csrf_token=generate_csrf_token())
logout_sexp = sexp_call("account-logout-form", csrf_token=generate_csrf_token())
labels_html = ""
labels_sexp = ""
if user and hasattr(user, "labels") and user.labels:
label_items = "".join(
render("account-label-item", name=label.name)
label_items = " ".join(
sexp_call("account-label-item", name=label.name)
for label in user.labels
)
labels_html = render("account-labels-section", items_html=label_items)
labels_sexp = sexp_call("account-labels-section",
items=SexpExpr("(<> " + label_items + ")"))
return render(
return sexp_call(
"account-main-panel",
error_html=error_html, email_html=user_email_html,
name_html=user_name_html, logout_html=logout_html,
labels_html=labels_html,
error=SexpExpr(error_sexp) if error_sexp else None,
email=SexpExpr(user_email_sexp) if user_email_sexp else None,
name=SexpExpr(user_name_sexp) if user_name_sexp else None,
logout=SexpExpr(logout_sexp),
labels=SexpExpr(labels_sexp) if labels_sexp else None,
)
@@ -106,7 +111,7 @@ def _account_main_panel_html(ctx: dict) -> str:
# Newsletters (GET /newsletters/)
# ---------------------------------------------------------------------------
def _newsletter_toggle_html(un: Any, account_url_fn: Any, csrf_token: str) -> str:
def _newsletter_toggle_sexp(un: Any, account_url_fn: Any, csrf_token: str) -> str:
"""Render a single newsletter toggle switch."""
nid = un.newsletter_id
toggle_url = account_url_fn(f"/newsletter/{nid}/toggle/")
@@ -118,7 +123,7 @@ def _newsletter_toggle_html(un: Any, account_url_fn: Any, csrf_token: str) -> st
bg = "bg-stone-300"
translate = "translate-x-1"
checked = "false"
return render(
return sexp_call(
"account-newsletter-toggle",
id=f"nl-{nid}", url=toggle_url,
hdrs=f'{{"X-CSRFToken": "{csrf_token}"}}',
@@ -129,9 +134,9 @@ def _newsletter_toggle_html(un: Any, account_url_fn: Any, csrf_token: str) -> st
)
def _newsletter_toggle_off_html(nid: int, toggle_url: str, csrf_token: str) -> str:
def _newsletter_toggle_off_sexp(nid: int, toggle_url: str, csrf_token: str) -> str:
"""Render an unsubscribed newsletter toggle (no subscription record yet)."""
return render(
return sexp_call(
"account-newsletter-toggle-off",
id=f"nl-{nid}", url=toggle_url,
hdrs=f'{{"X-CSRFToken": "{csrf_token}"}}',
@@ -139,7 +144,7 @@ def _newsletter_toggle_off_html(nid: int, toggle_url: str, csrf_token: str) -> s
)
def _newsletters_panel_html(ctx: dict, newsletter_list: list) -> str:
def _newsletters_panel_sexp(ctx: dict, newsletter_list: list) -> str:
"""Newsletters management panel."""
from shared.browser.app.csrf import generate_csrf_token
@@ -152,28 +157,30 @@ def _newsletters_panel_html(ctx: dict, newsletter_list: list) -> str:
nl = item["newsletter"]
un = item.get("un")
desc_html = render(
desc_sexp = sexp_call(
"account-newsletter-desc", description=nl.description
) if nl.description else ""
if un:
toggle = _newsletter_toggle_html(un, account_url_fn, csrf)
toggle = _newsletter_toggle_sexp(un, account_url_fn, csrf)
else:
toggle_url = account_url_fn(f"/newsletter/{nl.id}/toggle/")
toggle = _newsletter_toggle_off_html(nl.id, toggle_url, csrf)
toggle = _newsletter_toggle_off_sexp(nl.id, toggle_url, csrf)
items.append(render(
items.append(sexp_call(
"account-newsletter-item",
name=nl.name, desc_html=desc_html, toggle_html=toggle,
name=nl.name,
desc=SexpExpr(desc_sexp) if desc_sexp else None,
toggle=SexpExpr(toggle),
))
list_html = render(
list_sexp = sexp_call(
"account-newsletter-list",
items_html="".join(items),
items=SexpExpr("(<> " + " ".join(items) + ")"),
)
else:
list_html = render("account-newsletter-empty")
list_sexp = sexp_call("account-newsletter-empty")
return render("account-newsletters-panel", list_html=list_html)
return sexp_call("account-newsletters-panel", list=SexpExpr(list_sexp))
# ---------------------------------------------------------------------------
@@ -189,11 +196,12 @@ def _login_page_content(ctx: dict) -> str:
email = ctx.get("email", "")
action = url_for("auth.start_login")
error_html = render("account-login-error", error=error) if error else ""
error_sexp = sexp_call("account-login-error", error=error) if error else ""
return render(
return sexp_call(
"account-login-form",
error_html=error_html, action=action,
error=SexpExpr(error_sexp) if error_sexp else None,
action=action,
csrf_token=generate_csrf_token(), email=email,
)
@@ -207,18 +215,19 @@ def _device_page_content(ctx: dict) -> str:
code = ctx.get("code", "")
action = url_for("auth.device_submit")
error_html = render("account-device-error", error=error) if error else ""
error_sexp = sexp_call("account-device-error", error=error) if error else ""
return render(
return sexp_call(
"account-device-form",
error_html=error_html, action=action,
error=SexpExpr(error_sexp) if error_sexp else None,
action=action,
csrf_token=generate_csrf_token(), code=code,
)
def _device_approved_content() -> str:
"""Device approved success content."""
return render("account-device-approved")
return sexp_call("account-device-approved")
# ---------------------------------------------------------------------------
@@ -227,28 +236,26 @@ def _device_approved_content() -> str:
async def render_account_page(ctx: dict) -> str:
"""Full page: account dashboard."""
main = _account_main_panel_html(ctx)
main = _account_main_panel_sexp(ctx)
hdr = root_header_html(ctx)
hdr += render("account-header-child", inner_html=_auth_header_html(ctx))
hdr = root_header_sexp(ctx)
hdr_child = header_child_sexp(_auth_header_sexp(ctx))
header_rows = "(<> " + hdr + " " + hdr_child + ")"
return full_page(ctx, header_rows_html=hdr,
content_html=main,
menu_html=_auth_nav_mobile_html(ctx))
return full_page_sexp(ctx, header_rows=header_rows,
content=main,
menu=_auth_nav_mobile_sexp(ctx))
async def render_account_oob(ctx: dict) -> str:
"""OOB response for account dashboard."""
main = _account_main_panel_html(ctx)
main = _account_main_panel_sexp(ctx)
oobs = (
_auth_header_html(ctx, oob=True)
+ root_header_html(ctx, oob=True)
)
oobs = "(<> " + _auth_header_sexp(ctx, oob=True) + " " + root_header_sexp(ctx, oob=True) + ")"
return oob_page(ctx, oobs_html=oobs,
content_html=main,
menu_html=_auth_nav_mobile_html(ctx))
return oob_page_sexp(oobs=oobs,
content=main,
menu=_auth_nav_mobile_sexp(ctx))
# ---------------------------------------------------------------------------
@@ -257,28 +264,26 @@ async def render_account_oob(ctx: dict) -> str:
async def render_newsletters_page(ctx: dict, newsletter_list: list) -> str:
"""Full page: newsletters."""
main = _newsletters_panel_html(ctx, newsletter_list)
main = _newsletters_panel_sexp(ctx, newsletter_list)
hdr = root_header_html(ctx)
hdr += render("account-header-child", inner_html=_auth_header_html(ctx))
hdr = root_header_sexp(ctx)
hdr_child = header_child_sexp(_auth_header_sexp(ctx))
header_rows = "(<> " + hdr + " " + hdr_child + ")"
return full_page(ctx, header_rows_html=hdr,
content_html=main,
menu_html=_auth_nav_mobile_html(ctx))
return full_page_sexp(ctx, header_rows=header_rows,
content=main,
menu=_auth_nav_mobile_sexp(ctx))
async def render_newsletters_oob(ctx: dict, newsletter_list: list) -> str:
"""OOB response for newsletters."""
main = _newsletters_panel_html(ctx, newsletter_list)
main = _newsletters_panel_sexp(ctx, newsletter_list)
oobs = (
_auth_header_html(ctx, oob=True)
+ root_header_html(ctx, oob=True)
)
oobs = "(<> " + _auth_header_sexp(ctx, oob=True) + " " + root_header_sexp(ctx, oob=True) + ")"
return oob_page(ctx, oobs_html=oobs,
content_html=main,
menu_html=_auth_nav_mobile_html(ctx))
return oob_page_sexp(oobs=oobs,
content=main,
menu=_auth_nav_mobile_sexp(ctx))
# ---------------------------------------------------------------------------
@@ -287,24 +292,22 @@ async def render_newsletters_oob(ctx: dict, newsletter_list: list) -> str:
async def render_fragment_page(ctx: dict, page_fragment_html: str) -> str:
"""Full page: fragment-provided content."""
hdr = root_header_html(ctx)
hdr += render("account-header-child", inner_html=_auth_header_html(ctx))
hdr = root_header_sexp(ctx)
hdr_child = header_child_sexp(_auth_header_sexp(ctx))
header_rows = "(<> " + hdr + " " + hdr_child + ")"
return full_page(ctx, header_rows_html=hdr,
content_html=page_fragment_html,
menu_html=_auth_nav_mobile_html(ctx))
return full_page_sexp(ctx, header_rows=header_rows,
content=f'(~rich-text :html "{_sexp_escape(page_fragment_html)}")',
menu=_auth_nav_mobile_sexp(ctx))
async def render_fragment_oob(ctx: dict, page_fragment_html: str) -> str:
"""OOB response for fragment pages."""
oobs = (
_auth_header_html(ctx, oob=True)
+ root_header_html(ctx, oob=True)
)
oobs = "(<> " + _auth_header_sexp(ctx, oob=True) + " " + root_header_sexp(ctx, oob=True) + ")"
return oob_page(ctx, oobs_html=oobs,
content_html=page_fragment_html,
menu_html=_auth_nav_mobile_html(ctx))
return oob_page_sexp(oobs=oobs,
content=f'(~rich-text :html "{_sexp_escape(page_fragment_html)}")',
menu=_auth_nav_mobile_sexp(ctx))
# ---------------------------------------------------------------------------
@@ -313,26 +316,26 @@ async def render_fragment_oob(ctx: dict, page_fragment_html: str) -> str:
async def render_login_page(ctx: dict) -> str:
"""Full page: login form."""
hdr = root_header_html(ctx)
return full_page(ctx, header_rows_html=hdr,
content_html=_login_page_content(ctx),
meta_html='<title>Login \u2014 Rose Ash</title>')
hdr = root_header_sexp(ctx)
return full_page_sexp(ctx, header_rows=hdr,
content=_login_page_content(ctx),
meta_html='<title>Login \u2014 Rose Ash</title>')
async def render_device_page(ctx: dict) -> str:
"""Full page: device authorization form."""
hdr = root_header_html(ctx)
return full_page(ctx, header_rows_html=hdr,
content_html=_device_page_content(ctx),
meta_html='<title>Authorize Device \u2014 Rose Ash</title>')
hdr = root_header_sexp(ctx)
return full_page_sexp(ctx, header_rows=hdr,
content=_device_page_content(ctx),
meta_html='<title>Authorize Device \u2014 Rose Ash</title>')
async def render_device_approved_page(ctx: dict) -> str:
"""Full page: device approved."""
hdr = root_header_html(ctx)
return full_page(ctx, header_rows_html=hdr,
content_html=_device_approved_content(),
meta_html='<title>Device Authorized \u2014 Rose Ash</title>')
hdr = root_header_sexp(ctx)
return full_page_sexp(ctx, header_rows=hdr,
content=_device_approved_content(),
meta_html='<title>Device Authorized \u2014 Rose Ash</title>')
# ---------------------------------------------------------------------------
@@ -343,13 +346,14 @@ def _check_email_content(email: str, email_error: str | None = None) -> str:
"""Check email confirmation content."""
from markupsafe import escape
error_html = render(
error_sexp = sexp_call(
"account-check-email-error", error=str(escape(email_error))
) if email_error else ""
return render(
return sexp_call(
"account-check-email",
email=str(escape(email)), error_html=error_html,
email=str(escape(email)),
error=SexpExpr(error_sexp) if error_sexp else None,
)
@@ -357,10 +361,10 @@ 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 = root_header_html(ctx)
return full_page(ctx, header_rows_html=hdr,
content_html=_check_email_content(email, email_error),
meta_html='<title>Check your email \u2014 Rose Ash</title>')
hdr = root_header_sexp(ctx)
return full_page_sexp(ctx, header_rows=hdr,
content=_check_email_content(email, email_error),
meta_html='<title>Check your email \u2014 Rose Ash</title>')
# ---------------------------------------------------------------------------
@@ -370,7 +374,7 @@ async def render_check_email_page(ctx: dict) -> str:
def render_newsletter_toggle_html(un) -> str:
"""Render a newsletter toggle switch for POST response."""
from shared.browser.app.csrf import generate_csrf_token
return _newsletter_toggle_html(un, lambda p: f"/newsletter/{un.newsletter_id}/toggle/" if "/toggle/" in p else p,
return _newsletter_toggle_sexp(un, lambda p: f"/newsletter/{un.newsletter_id}/toggle/" if "/toggle/" in p else p,
generate_csrf_token())
@@ -382,4 +386,13 @@ def render_newsletter_toggle(un) -> str:
if account_url_fn is None:
from shared.infrastructure.urls import account_url
account_url_fn = account_url
return _newsletter_toggle_html(un, account_url_fn, generate_csrf_token())
return _newsletter_toggle_sexp(un, account_url_fn, generate_csrf_token())
# ---------------------------------------------------------------------------
# Internal helpers
# ---------------------------------------------------------------------------
def _sexp_escape(s: str) -> str:
"""Escape a string for embedding in sexp string literals."""
return s.replace("\\", "\\\\").replace('"', '\\"')

View File

@@ -1,9 +1,9 @@
<div id="nl-{{ un.newsletter_id }}" class="flex items-center">
<button
hx-post="{{ account_url('/newsletter/' ~ un.newsletter_id ~ '/toggle/') }}"
hx-headers='{"X-CSRFToken": "{{ csrf_token() }}"}'
hx-target="#nl-{{ un.newsletter_id }}"
hx-swap="outerHTML"
sx-post="{{ account_url('/newsletter/' ~ un.newsletter_id ~ '/toggle/') }}"
sx-headers='{"X-CSRFToken": "{{ csrf_token() }}"}'
sx-target="#nl-{{ un.newsletter_id }}"
sx-swap="outerHTML"
class="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
{% if un.subscribed %}bg-emerald-500{% else %}bg-stone-300{% endif %}"
role="switch"

View File

@@ -22,10 +22,10 @@
{# No subscription row yet — show an off toggle that will create one #}
<div id="nl-{{ item.newsletter.id }}" class="flex items-center">
<button
hx-post="{{ account_url('/newsletter/' ~ item.newsletter.id ~ '/toggle/') }}"
hx-headers='{"X-CSRFToken": "{{ csrf_token() }}"}'
hx-target="#nl-{{ item.newsletter.id }}"
hx-swap="outerHTML"
sx-post="{{ account_url('/newsletter/' ~ item.newsletter.id ~ '/toggle/') }}"
sx-headers='{"X-CSRFToken": "{{ csrf_token() }}"}'
sx-target="#nl-{{ item.newsletter.id }}"
sx-swap="outerHTML"
class="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-stone-300"
role="switch"
aria-checked="false"