Migrate all apps to defpage declarative page routes

Replace Python GET page handlers with declarative defpage definitions in .sx
files across all 8 apps (sx docs, orders, account, market, cart, federation,
events, blog). Each app now has sxc/pages/ with setup functions, layout
registrations, page helpers, and .sx defpage declarations.

Core infrastructure: add g I/O primitive, PageDef support for auth/layout/
data/content/filter/aside/menu slots, post_author auth level, and custom
layout registration. Remove ~1400 lines of render_*_page/render_*_oob
boilerplate. Update all endpoint references in routes, sx_components, and
templates to defpage_* naming.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-03-03 14:52:34 +00:00
parent 5b4cacaf19
commit c243d17eeb
108 changed files with 3598 additions and 2851 deletions

View File

@@ -72,9 +72,19 @@ def create_app() -> "Quart":
app.jinja_loader,
])
# Setup defpage routes
import sx.sx_components # noqa: F811 — ensure components loaded
from sxc.pages import setup_account_pages
setup_account_pages()
# --- blueprints ---
app.register_blueprint(register_auth_bp())
app.register_blueprint(register_account_bp())
account_bp = register_account_bp()
from shared.sx.pages import mount_pages
mount_pages(account_bp, "account")
app.register_blueprint(account_bp)
app.register_blueprint(register_fragments())
from bp.actions.routes import register as register_actions

View File

@@ -1,14 +1,13 @@
"""Account pages blueprint.
Moved from federation/bp/auth — newsletters, fragment pages (tickets, bookings).
Mounted at root /.
Mounted at root /. GET page handlers replaced by defpage.
"""
from __future__ import annotations
from quart import (
Blueprint,
request,
make_response,
redirect,
g,
)
@@ -20,85 +19,62 @@ from shared.infrastructure.urls import login_url
from shared.infrastructure.fragments import fetch_fragment, fetch_fragments
from shared.sx.helpers import sx_response
oob = {
"oob_extends": "oob_elements.html",
"extends": "_types/root/_index.html",
"parent_id": "root-header-child",
"child_id": "auth-header-child",
"header": "_types/auth/header/_header.html",
"parent_header": "_types/root/header/_header.html",
"nav": "_types/auth/_nav.html",
"main": "_types/auth/_main_panel.html",
}
def register(url_prefix="/"):
account_bp = Blueprint("account", __name__, url_prefix=url_prefix)
@account_bp.context_processor
async def context():
@account_bp.before_request
async def _prepare_page_data():
"""Fetch account_nav fragments and load data for defpage routes."""
# Fetch account nav items for layout (was in context_processor)
events_nav, cart_nav, artdag_nav = await fetch_fragments([
("events", "account-nav-item", {}),
("cart", "account-nav-item", {}),
("artdag", "nav-item", {}),
], required=False)
return {"oob": oob, "account_nav": events_nav + cart_nav + artdag_nav}
g.account_nav = events_nav + cart_nav + artdag_nav
@account_bp.get("/")
async def account():
from shared.browser.app.utils.htmx import is_htmx_request
from shared.sx.page import get_template_context
from sx.sx_components import render_account_page, render_account_oob
if request.method != "GET":
return
if not g.get("user"):
return redirect(login_url("/"))
endpoint = request.endpoint or ""
ctx = await get_template_context()
if not is_htmx_request():
html = await render_account_page(ctx)
return await make_response(html)
else:
sx_src = await render_account_oob(ctx)
return sx_response(sx_src)
@account_bp.get("/newsletters/")
async def newsletters():
from shared.browser.app.utils.htmx import is_htmx_request
if not g.get("user"):
return redirect(login_url("/newsletters/"))
result = await g.s.execute(
select(GhostNewsletter).order_by(GhostNewsletter.name)
)
all_newsletters = result.scalars().all()
sub_result = await g.s.execute(
select(UserNewsletter).where(
UserNewsletter.user_id == g.user.id,
# Newsletters page — load newsletter data
if endpoint.endswith("defpage_newsletters"):
result = await g.s.execute(
select(GhostNewsletter).order_by(GhostNewsletter.name)
)
)
user_subs = {un.newsletter_id: un for un in sub_result.scalars().all()}
all_newsletters = result.scalars().all()
newsletter_list = []
for nl in all_newsletters:
un = user_subs.get(nl.id)
newsletter_list.append({
"newsletter": nl,
"un": un,
"subscribed": un.subscribed if un else False,
})
sub_result = await g.s.execute(
select(UserNewsletter).where(
UserNewsletter.user_id == g.user.id,
)
)
user_subs = {un.newsletter_id: un for un in sub_result.scalars().all()}
from shared.sx.page import get_template_context
from sx.sx_components import render_newsletters_page, render_newsletters_oob
newsletter_list = []
for nl in all_newsletters:
un = user_subs.get(nl.id)
newsletter_list.append({
"newsletter": nl,
"un": un,
"subscribed": un.subscribed if un else False,
})
g.newsletters_data = newsletter_list
ctx = await get_template_context()
if not is_htmx_request():
html = await render_newsletters_page(ctx, newsletter_list)
return await make_response(html)
else:
sx_src = await render_newsletters_oob(ctx, newsletter_list)
return sx_response(sx_src)
# Fragment page — load fragment from events service
elif endpoint.endswith("defpage_fragment_page"):
slug = request.view_args.get("slug")
if slug and g.get("user"):
fragment_html = await fetch_fragment(
"events", "account-page",
params={"slug": slug, "user_id": str(g.user.id)},
)
if not fragment_html:
from quart import abort
abort(404)
g.fragment_page_data = fragment_html
@account_bp.post("/newsletter/<int:newsletter_id>/toggle/")
async def toggle_newsletter(newsletter_id: int):
@@ -128,31 +104,4 @@ def register(url_prefix="/"):
from sx.sx_components import render_newsletter_toggle
return sx_response(render_newsletter_toggle(un))
# Catch-all for fragment-provided pages — must be last
@account_bp.get("/<slug>/")
async def fragment_page(slug):
from shared.browser.app.utils.htmx import is_htmx_request
from quart import abort
if not g.get("user"):
return redirect(login_url(f"/{slug}/"))
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.page import get_template_context
from sx.sx_components import render_fragment_page, render_fragment_oob
ctx = await get_template_context()
if not is_htmx_request():
html = await render_fragment_page(ctx, fragment_html)
return await make_response(html)
else:
sx_src = await render_fragment_oob(ctx, fragment_html)
return sx_response(sx_src)
return account_bp

View File

@@ -12,7 +12,7 @@ from typing import Any
from shared.sx.jinja_bridge import load_service_components
from shared.sx.helpers import (
call_url, sx_call, SxExpr,
root_header_sx, full_page_sx, header_child_sx, oob_page_sx,
root_header_sx, full_page_sx,
)
# Load account-specific .sx components + handlers at import time
@@ -238,88 +238,8 @@ def _device_approved_content() -> str:
# Public API: Account dashboard
# ---------------------------------------------------------------------------
async def render_account_page(ctx: dict) -> str:
"""Full page: account dashboard."""
main = _account_main_panel_sx(ctx)
hdr = root_header_sx(ctx)
hdr_child = header_child_sx(_auth_header_sx(ctx))
header_rows = "(<> " + hdr + " " + hdr_child + ")"
return full_page_sx(ctx, header_rows=header_rows,
content=main,
menu=_auth_nav_mobile_sx(ctx))
async def render_account_oob(ctx: dict) -> str:
"""OOB response for account dashboard."""
main = _account_main_panel_sx(ctx)
oobs = "(<> " + _auth_header_sx(ctx, oob=True) + " " + root_header_sx(ctx, oob=True) + ")"
return oob_page_sx(oobs=oobs,
content=main,
menu=_auth_nav_mobile_sx(ctx))
# ---------------------------------------------------------------------------
# Public API: Newsletters
# ---------------------------------------------------------------------------
async def render_newsletters_page(ctx: dict, newsletter_list: list) -> str:
"""Full page: newsletters."""
main = _newsletters_panel_sx(ctx, newsletter_list)
hdr = root_header_sx(ctx)
hdr_child = header_child_sx(_auth_header_sx(ctx))
header_rows = "(<> " + hdr + " " + hdr_child + ")"
return full_page_sx(ctx, header_rows=header_rows,
content=main,
menu=_auth_nav_mobile_sx(ctx))
async def render_newsletters_oob(ctx: dict, newsletter_list: list) -> str:
"""OOB response for newsletters."""
main = _newsletters_panel_sx(ctx, newsletter_list)
oobs = "(<> " + _auth_header_sx(ctx, oob=True) + " " + root_header_sx(ctx, oob=True) + ")"
return oob_page_sx(oobs=oobs,
content=main,
menu=_auth_nav_mobile_sx(ctx))
# ---------------------------------------------------------------------------
# Public API: Fragment pages
# ---------------------------------------------------------------------------
async def render_fragment_page(ctx: dict, page_fragment: str) -> str:
"""Full page: fragment-provided content.
*page_fragment* may be sx source (from text/sx fragments wrapped in
SxExpr) or HTML (from text/html fragments). Sx source is embedded
directly; HTML is wrapped in ``~rich-text``.
"""
from shared.sx.parser import SxExpr
hdr = root_header_sx(ctx)
hdr_child = header_child_sx(_auth_header_sx(ctx))
header_rows = "(<> " + hdr + " " + hdr_child + ")"
content = _fragment_content(page_fragment)
return full_page_sx(ctx, header_rows=header_rows,
content=content,
menu=_auth_nav_mobile_sx(ctx))
async def render_fragment_oob(ctx: dict, page_fragment: str) -> str:
"""OOB response for fragment pages."""
oobs = "(<> " + _auth_header_sx(ctx, oob=True) + " " + root_header_sx(ctx, oob=True) + ")"
content = _fragment_content(page_fragment)
return oob_page_sx(oobs=oobs,
content=content,
menu=_auth_nav_mobile_sx(ctx))
def _fragment_content(frag: object) -> str:

0
account/sxc/__init__.py Normal file
View File

View File

@@ -0,0 +1,105 @@
"""Account defpage setup — registers layouts, page helpers, and loads .sx pages."""
from __future__ import annotations
from typing import Any
def setup_account_pages() -> None:
"""Register account-specific layouts, page helpers, and load page definitions."""
_register_account_layouts()
_register_account_helpers()
_load_account_page_files()
def _load_account_page_files() -> None:
import os
from shared.sx.pages import load_page_dir
load_page_dir(os.path.dirname(__file__), "account")
# ---------------------------------------------------------------------------
# Layouts
# ---------------------------------------------------------------------------
def _register_account_layouts() -> None:
from shared.sx.layouts import register_custom_layout
register_custom_layout("account", _account_full, _account_oob, _account_mobile)
def _account_full(ctx: dict, **kw: Any) -> str:
from shared.sx.helpers import root_header_sx, header_child_sx
from sx.sx_components import _auth_header_sx
root_hdr = root_header_sx(ctx)
hdr_child = header_child_sx(_auth_header_sx(ctx))
return "(<> " + root_hdr + " " + hdr_child + ")"
def _account_oob(ctx: dict, **kw: Any) -> str:
from shared.sx.helpers import root_header_sx
from sx.sx_components import _auth_header_sx
return "(<> " + _auth_header_sx(ctx, oob=True) + " " + root_header_sx(ctx, oob=True) + ")"
def _account_mobile(ctx: dict, **kw: Any) -> str:
from shared.sx.helpers import mobile_menu_sx, mobile_root_nav_sx, sx_call, SxExpr
from sx.sx_components import _auth_nav_mobile_sx
ctx = _inject_account_nav(ctx)
auth_section = sx_call("mobile-menu-section",
label="account", href="/", level=1, colour="sky",
items=SxExpr(_auth_nav_mobile_sx(ctx)))
return mobile_menu_sx(auth_section, mobile_root_nav_sx(ctx))
def _inject_account_nav(ctx: dict) -> dict:
"""Ensure account_nav is in ctx from g.account_nav."""
if "account_nav" not in ctx:
from quart import g
ctx = dict(ctx)
ctx["account_nav"] = getattr(g, "account_nav", "")
return ctx
# ---------------------------------------------------------------------------
# Page helpers
# ---------------------------------------------------------------------------
def _register_account_helpers() -> None:
from shared.sx.pages import register_page_helpers
register_page_helpers("account", {
"account-content": _h_account_content,
"newsletters-content": _h_newsletters_content,
"fragment-content": _h_fragment_content,
})
def _h_account_content():
from sx.sx_components import _account_main_panel_sx
return _account_main_panel_sx({})
def _h_newsletters_content():
from quart import g
d = getattr(g, "newsletters_data", None)
if not d:
from shared.sx.helpers import sx_call
return sx_call("account-newsletter-empty")
from shared.sx.page import get_template_context_sync
from sx.sx_components import _newsletters_panel_sx
# Build a minimal ctx with account_url
ctx = {"account_url": getattr(g, "_account_url", None)}
if ctx["account_url"] is None:
from shared.infrastructure.urls import account_url
ctx["account_url"] = account_url
return _newsletters_panel_sx(ctx, d)
def _h_fragment_content():
from quart import g
frag = getattr(g, "fragment_page_data", None)
if not frag:
return ""
from sx.sx_components import _fragment_content
return _fragment_content(frag)

View File

@@ -0,0 +1,31 @@
;; Account app — declarative page definitions
;; ---------------------------------------------------------------------------
;; Account dashboard
;; ---------------------------------------------------------------------------
(defpage account-dashboard
:path "/"
:auth :login
:layout :account
:content (account-content))
;; ---------------------------------------------------------------------------
;; Newsletters
;; ---------------------------------------------------------------------------
(defpage newsletters
:path "/newsletters/"
:auth :login
:layout :account
:content (newsletters-content))
;; ---------------------------------------------------------------------------
;; Fragment pages (tickets, bookings, etc. from events service)
;; ---------------------------------------------------------------------------
(defpage fragment-page
:path "/<slug>/"
:auth :login
:layout :account
:content (fragment-content))