Migrate all apps to defpage declarative page routes
All checks were successful
Build and Deploy / build-and-deploy (push) Successful in 3m41s

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

@@ -33,7 +33,7 @@ from __future__ import annotations
from typing import Any
from .types import Component, HandlerDef, Keyword, Lambda, Macro, NIL, RelationDef, Symbol
from .types import Component, HandlerDef, Keyword, Lambda, Macro, NIL, PageDef, RelationDef, Symbol
from .primitives import _PRIMITIVES
@@ -635,6 +635,85 @@ def _sf_defrelation(expr: list, env: dict) -> RelationDef:
return defn
def _sf_defpage(expr: list, env: dict) -> PageDef:
"""``(defpage name :path "/..." :auth :public :content expr ...)``
Parses keyword args from the expression. All slot values are stored
as unevaluated AST — they are resolved at request time by execute_page().
"""
if len(expr) < 2:
raise EvalError("defpage requires a name")
name_sym = expr[1]
if not isinstance(name_sym, Symbol):
raise EvalError(f"defpage name must be symbol, got {type(name_sym).__name__}")
# Parse keyword args — values are NOT evaluated (stored as AST)
slots: dict[str, Any] = {}
i = 2
while i < len(expr):
key = expr[i]
if isinstance(key, Keyword) and i + 1 < len(expr):
slots[key.name] = expr[i + 1]
i += 2
else:
i += 1
# Required fields
path = slots.get("path")
if path is None:
raise EvalError(f"defpage {name_sym.name} missing required :path")
if not isinstance(path, str):
raise EvalError(f"defpage {name_sym.name} :path must be a string")
auth_val = slots.get("auth", "public")
if isinstance(auth_val, Keyword):
auth: str | list = auth_val.name
elif isinstance(auth_val, list):
# (:rights "a" "b") → ["rights", "a", "b"]
auth = []
for item in auth_val:
if isinstance(item, Keyword):
auth.append(item.name)
elif isinstance(item, str):
auth.append(item)
else:
auth.append(_eval(item, env))
else:
auth = str(auth_val) if auth_val else "public"
# Layout — keep unevaluated
layout = slots.get("layout")
if isinstance(layout, Keyword):
layout = layout.name
elif isinstance(layout, list):
# Keep as unevaluated list for execute_page to resolve at request time
pass
# Cache — evaluate if present (it's a static config dict)
cache_val = slots.get("cache")
cache = None
if cache_val is not None:
cache_result = _eval(cache_val, env)
if isinstance(cache_result, dict):
cache = cache_result
page = PageDef(
name=name_sym.name,
path=path,
auth=auth,
layout=layout,
cache=cache,
data_expr=slots.get("data"),
content_expr=slots.get("content"),
filter_expr=slots.get("filter"),
aside_expr=slots.get("aside"),
menu_expr=slots.get("menu"),
closure=dict(env),
)
env[f"page:{name_sym.name}"] = page
return page
_SPECIAL_FORMS: dict[str, Any] = {
"if": _sf_if,
"when": _sf_when,
@@ -657,6 +736,7 @@ _SPECIAL_FORMS: dict[str, Any] = {
"defmacro": _sf_defmacro,
"quasiquote": _sf_quasiquote,
"defhandler": _sf_defhandler,
"defpage": _sf_defpage,
}

View File

@@ -102,26 +102,136 @@ def root_header_sx(ctx: dict, *, oob: bool = False) -> str:
)
def mobile_nav_sx(ctx: dict) -> str:
"""Build mobile navigation panel from context fragments (nav_tree, auth_menu)."""
def mobile_menu_sx(*sections: str) -> str:
"""Assemble mobile menu from pre-built sections (deepest first)."""
parts = [s for s in sections if s]
return "(<> " + " ".join(parts) + ")" if parts else ""
def mobile_root_nav_sx(ctx: dict) -> str:
"""Root-level mobile nav via ~mobile-root-nav component."""
nav_tree = ctx.get("nav_tree") or ""
auth_menu = ctx.get("auth_menu") or ""
if not nav_tree and not auth_menu:
return ""
return sx_call("mobile-root-nav",
nav_tree=_as_sx(nav_tree),
auth_menu=_as_sx(auth_menu),
)
# ---------------------------------------------------------------------------
# Shared nav-item builders — used by BOTH desktop headers and mobile menus
# ---------------------------------------------------------------------------
def _post_nav_items_sx(ctx: dict) -> str:
"""Build post-level nav items (container_nav + admin cog). Shared by
``post_header_sx`` (desktop) and ``post_mobile_nav_sx`` (mobile)."""
post = ctx.get("post") or {}
slug = post.get("slug", "")
if not slug:
return ""
parts: list[str] = []
if nav_tree:
nav_tree_sx = _as_sx(nav_tree)
page_cart_count = ctx.get("page_cart_count", 0)
if page_cart_count and page_cart_count > 0:
cart_href = call_url(ctx, "cart_url", f"/{slug}/")
parts.append(sx_call("page-cart-badge", href=cart_href,
count=str(page_cart_count)))
container_nav = str(ctx.get("container_nav") or "").strip()
# Skip empty fragment wrappers like "(<> )"
if container_nav and container_nav.replace("(<>", "").replace(")", "").strip():
parts.append(
f'(div :class "flex flex-col gap-2 p-3 text-sm" {nav_tree_sx})'
)
if auth_menu:
auth_sx = _as_sx(auth_menu)
parts.append(
f'(div :class "p-3 border-t border-stone-200" {auth_sx})'
f'(div :id "entries-calendars-nav-wrapper"'
f' :class "flex flex-col sm:flex-row sm:items-center gap-2'
f' border-r border-stone-200 mr-2 sm:max-w-2xl"'
f' {container_nav})'
)
# Admin cog
admin_nav = ctx.get("post_admin_nav")
if not admin_nav:
rights = ctx.get("rights") or {}
has_admin = rights.get("admin") if isinstance(rights, dict) else getattr(rights, "admin", False)
if has_admin and slug:
from quart import request
admin_href = call_url(ctx, "blog_url", f"/{slug}/admin/")
is_admin_page = ctx.get("is_admin_section") or "/admin" in request.path
sel_cls = "!bg-stone-500 !text-white" if is_admin_page else ""
base_cls = ("justify-center cursor-pointer flex flex-row"
" items-center gap-2 rounded bg-stone-200 text-black p-3")
admin_nav = (
f'(div :class "relative nav-group"'
f' (a :href "{admin_href}"'
f' :class "{base_cls} {sel_cls}"'
f' (i :class "fa fa-cog" :aria-hidden "true")))'
)
if admin_nav:
parts.append(admin_nav)
return "(<> " + " ".join(parts) + ")" if parts else ""
def _post_admin_nav_items_sx(ctx: dict, slug: str,
selected: str = "") -> str:
"""Build post-admin nav items (calendars, markets, etc.). Shared by
``post_admin_header_sx`` (desktop) and mobile menu."""
select_colours = ctx.get("select_colours", "")
parts: list[str] = []
items = [
("events_url", f"/{slug}/admin/", "calendars"),
("market_url", f"/{slug}/admin/", "markets"),
("cart_url", f"/{slug}/admin/payments/", "payments"),
("blog_url", f"/{slug}/admin/entries/", "entries"),
("blog_url", f"/{slug}/admin/data/", "data"),
("blog_url", f"/{slug}/admin/preview/", "preview"),
("blog_url", f"/{slug}/admin/edit/", "edit"),
("blog_url", f"/{slug}/admin/settings/", "settings"),
]
for url_key, path, label in items:
url_fn = ctx.get(url_key)
if not callable(url_fn):
continue
href = url_fn(path)
is_sel = label == selected
parts.append(sx_call("nav-link", href=href, label=label,
select_colours=select_colours,
is_selected=is_sel or None))
return "(<> " + " ".join(parts) + ")" if parts else ""
# ---------------------------------------------------------------------------
# Mobile menu section builders — wrap shared nav items for hamburger panel
# ---------------------------------------------------------------------------
def post_mobile_nav_sx(ctx: dict) -> str:
"""Post-level mobile menu section."""
nav = _post_nav_items_sx(ctx)
if not nav:
return ""
post = ctx.get("post") or {}
slug = post.get("slug", "")
title = (post.get("title") or slug)[:40]
return sx_call("mobile-menu-section",
label=title,
href=call_url(ctx, "blog_url", f"/{slug}/"),
level=1,
items=SxExpr(nav),
)
def post_admin_mobile_nav_sx(ctx: dict, slug: str,
selected: str = "") -> str:
"""Post-admin mobile menu section."""
nav = _post_admin_nav_items_sx(ctx, slug, selected)
if not nav:
return ""
admin_href = call_url(ctx, "blog_url", f"/{slug}/admin/")
return sx_call("mobile-menu-section",
label="admin", href=admin_href, level=2,
items=SxExpr(nav),
)
def search_mobile_sx(ctx: dict) -> str:
"""Build mobile search input as sx call string."""
return sx_call("search-mobile",
@@ -154,43 +264,7 @@ def post_header_sx(ctx: dict, *, oob: bool = False) -> str:
feature_image = post.get("feature_image")
label_sx = sx_call("post-label", feature_image=feature_image, title=title)
nav_parts: list[str] = []
page_cart_count = ctx.get("page_cart_count", 0)
if page_cart_count and page_cart_count > 0:
cart_href = call_url(ctx, "cart_url", f"/{slug}/")
nav_parts.append(sx_call("page-cart-badge", href=cart_href, count=str(page_cart_count)))
container_nav = ctx.get("container_nav")
if container_nav:
nav_parts.append(
f'(div :id "entries-calendars-nav-wrapper"'
f' :class "flex flex-col sm:flex-row sm:items-center gap-2 border-r border-stone-200 mr-2 sm:max-w-2xl"'
f' {container_nav})'
)
# Admin cog
admin_nav = ctx.get("post_admin_nav")
if not admin_nav:
rights = ctx.get("rights") or {}
has_admin = rights.get("admin") if isinstance(rights, dict) else getattr(rights, "admin", False)
if has_admin and slug:
from quart import request
admin_href = call_url(ctx, "blog_url", f"/{slug}/admin/")
is_admin_page = ctx.get("is_admin_section") or "/admin" in request.path
sel_cls = "!bg-stone-500 !text-white" if is_admin_page else ""
base_cls = ("justify-center cursor-pointer flex flex-row"
" items-center gap-2 rounded bg-stone-200 text-black p-3")
admin_nav = (
f'(div :class "relative nav-group"'
f' (a :href "{admin_href}"'
f' :class "{base_cls} {sel_cls}"'
f' (i :class "fa fa-cog" :aria-hidden "true")))'
)
if admin_nav:
nav_parts.append(admin_nav)
nav_sx = "(<> " + " ".join(nav_parts) + ")" if nav_parts else None
nav_sx = _post_nav_items_sx(ctx) or None
link_href = call_url(ctx, "blog_url", f"/{slug}/")
return sx_call("menu-row-sx",
@@ -212,39 +286,7 @@ def post_admin_header_sx(ctx: dict, slug: str, *, oob: bool = False,
label_parts.append(f'(span :class "text-white" "{escape(selected)}")')
label_sx = "(<> " + " ".join(label_parts) + ")"
# Nav items
select_colours = ctx.get("select_colours", "")
base_cls = ("justify-center cursor-pointer flex flex-row items-center"
" gap-2 rounded bg-stone-200 text-black p-3")
selected_cls = ("justify-center cursor-pointer flex flex-row items-center"
" gap-2 rounded !bg-stone-500 !text-white p-3")
nav_parts: list[str] = []
items = [
("events_url", f"/{slug}/admin/", "calendars"),
("market_url", f"/{slug}/admin/", "markets"),
("cart_url", f"/{slug}/admin/payments/", "payments"),
("blog_url", f"/{slug}/admin/entries/", "entries"),
("blog_url", f"/{slug}/admin/data/", "data"),
("blog_url", f"/{slug}/admin/preview/", "preview"),
("blog_url", f"/{slug}/admin/edit/", "edit"),
("blog_url", f"/{slug}/admin/settings/", "settings"),
]
for url_key, path, label in items:
url_fn = ctx.get(url_key)
if not callable(url_fn):
continue
href = url_fn(path)
is_sel = label == selected
cls = selected_cls if is_sel else base_cls
aria = "true" if is_sel else None
nav_parts.append(
f'(div :class "relative nav-group"'
f' (a :href "{escape(href)}"'
+ (f' :aria-selected "true"' if aria else "")
+ f' :class "{cls} {escape(select_colours)}"'
+ f' "{escape(label)}"))'
)
nav_sx = "(<> " + " ".join(nav_parts) + ")" if nav_parts else None
nav_sx = _post_admin_nav_items_sx(ctx, slug, selected) or None
if not admin_href:
blog_fn = ctx.get("blog_url")
@@ -301,7 +343,7 @@ def full_page_sx(ctx: dict, *, header_rows: str,
"""
# Auto-generate mobile nav from context when no menu provided
if not menu:
menu = mobile_nav_sx(ctx)
menu = mobile_root_nav_sx(ctx)
body_sx = sx_call("app-body",
header_rows=SxExpr(f"(<> {header_rows})") if header_rows else None,
filter=SxExpr(filter) if filter else None,

View File

@@ -16,6 +16,8 @@ from typing import Any, Callable, Awaitable
from .helpers import (
root_header_sx, post_header_sx, post_admin_header_sx,
oob_header_sx, header_child_sx,
mobile_menu_sx, mobile_root_nav_sx,
post_mobile_nav_sx, post_admin_mobile_nav_sx,
)
@@ -26,17 +28,19 @@ from .helpers import (
class Layout:
"""A named layout that generates header rows for full and OOB rendering."""
__slots__ = ("name", "_full_fn", "_oob_fn")
__slots__ = ("name", "_full_fn", "_oob_fn", "_mobile_fn")
def __init__(
self,
name: str,
full_fn: Callable[..., str | Awaitable[str]],
oob_fn: Callable[..., str | Awaitable[str]],
mobile_fn: Callable[..., str | Awaitable[str]] | None = None,
):
self.name = name
self._full_fn = full_fn
self._oob_fn = oob_fn
self._mobile_fn = mobile_fn
async def full_headers(self, ctx: dict, **kwargs: Any) -> str:
result = self._full_fn(ctx, **kwargs)
@@ -50,6 +54,14 @@ class Layout:
result = await result
return result
async def mobile_menu(self, ctx: dict, **kwargs: Any) -> str:
if self._mobile_fn is None:
return ""
result = self._mobile_fn(ctx, **kwargs)
if hasattr(result, "__await__"):
result = await result
return result
def __repr__(self) -> str:
return f"<Layout:{self.name}>"
@@ -113,9 +125,27 @@ def _post_admin_oob(ctx: dict, **kw: Any) -> str:
return "(<> " + post_hdr + " " + admin_oob + ")"
register_layout(Layout("root", _root_full, _root_oob))
register_layout(Layout("post", _post_full, _post_oob))
register_layout(Layout("post-admin", _post_admin_full, _post_admin_oob))
def _root_mobile(ctx: dict, **kw: Any) -> str:
return mobile_root_nav_sx(ctx)
def _post_mobile(ctx: dict, **kw: Any) -> str:
return mobile_menu_sx(post_mobile_nav_sx(ctx), mobile_root_nav_sx(ctx))
def _post_admin_mobile(ctx: dict, **kw: Any) -> str:
slug = ctx.get("post", {}).get("slug", "")
selected = kw.get("selected", "")
return mobile_menu_sx(
post_admin_mobile_nav_sx(ctx, slug, selected),
post_mobile_nav_sx(ctx),
mobile_root_nav_sx(ctx),
)
register_layout(Layout("root", _root_full, _root_oob, _root_mobile))
register_layout(Layout("post", _post_full, _post_oob, _post_mobile))
register_layout(Layout("post-admin", _post_admin_full, _post_admin_oob, _post_admin_mobile))
# ---------------------------------------------------------------------------
@@ -127,13 +157,15 @@ _CUSTOM_LAYOUTS: dict[str, tuple] = {} # name → (full_fn, oob_fn)
def register_custom_layout(name: str,
full_fn: Callable[..., str | Awaitable[str]],
oob_fn: Callable[..., str | Awaitable[str]]) -> None:
oob_fn: Callable[..., str | Awaitable[str]],
mobile_fn: Callable[..., str | Awaitable[str]] | None = None) -> None:
"""Register a custom layout function.
Used by services with non-standard header patterns::
register_custom_layout("sx-section",
full_fn=my_full_headers,
oob_fn=my_oob_headers)
oob_fn=my_oob_headers,
mobile_fn=my_mobile_menu)
"""
register_layout(Layout(name, full_fn, oob_fn))
register_layout(Layout(name, full_fn, oob_fn, mobile_fn))

View File

@@ -219,7 +219,7 @@ async def execute_page(
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)
# Resolve layout → header rows
# Resolve layout → header rows + mobile menu fallback
tctx = await get_template_context()
header_rows = ""
oob_headers = ""
@@ -261,6 +261,8 @@ async def execute_page(
if layout is not None:
header_rows = await layout.full_headers(tctx, **layout_kwargs)
oob_headers = await layout.oob_headers(tctx, **layout_kwargs)
if not menu_sx:
menu_sx = await layout.mobile_menu(tctx, **layout_kwargs)
# Branch on request type
is_htmx = is_htmx_request()
@@ -288,17 +290,22 @@ async def execute_page(
# Blueprint mounting
# ---------------------------------------------------------------------------
def mount_pages(bp: Any, service_name: str) -> None:
"""Mount all registered PageDef routes onto a Quart Blueprint.
def mount_pages(bp: Any, service_name: str,
names: set[str] | list[str] | None = None) -> None:
"""Mount registered PageDef routes onto a Quart Blueprint.
For each PageDef, adds a GET route with appropriate auth/cache
decorators. Coexists with existing Python routes on the same blueprint.
If *names* is given, only mount pages whose name is in the set.
"""
from quart import make_response
pages = get_all_pages(service_name)
for page_def in pages.values():
if names is not None and page_def.name not in names:
continue
_mount_one_page(bp, service_name, page_def)
@@ -347,6 +354,9 @@ def _apply_auth(fn: Any, auth: str | list) -> Any:
if auth == "admin":
from shared.browser.app.authz import require_admin
return require_admin(fn)
if auth == "post_author":
from shared.browser.app.authz import require_post_author
return require_post_author(fn)
if isinstance(auth, list) and auth and auth[0] == "rights":
from shared.browser.app.authz import require_rights
return require_rights(*auth[1:])(fn)

View File

@@ -40,6 +40,7 @@ IO_PRIMITIVES: frozenset[str] = frozenset({
"request-path",
"nav-tree",
"get-children",
"g",
})
@@ -300,6 +301,19 @@ async def _io_get_children(
# Handler registry
# ---------------------------------------------------------------------------
async def _io_g(
args: list[Any], kwargs: dict[str, Any], ctx: RequestContext
) -> Any:
"""``(g "key")`` → getattr(g, key, None).
Reads a value from the Quart request-local ``g`` object.
Kebab-case keys are converted to snake_case automatically.
"""
from quart import g
key = str(args[0]).replace("-", "_") if args else ""
return getattr(g, key, None)
_IO_HANDLERS: dict[str, Any] = {
"frag": _io_frag,
"query": _io_query,
@@ -311,4 +325,5 @@ _IO_HANDLERS: dict[str, Any] = {
"request-path": _io_request_path,
"nav-tree": _io_nav_tree,
"get-children": _io_get_children,
"g": _io_g,
}

View File

@@ -120,6 +120,31 @@
(defcomp ~header-child-sx (&key id inner)
(div :id (or id "root-header-child") :class "flex flex-col w-full items-center" inner))
;; ---------------------------------------------------------------------------
;; Mobile menu — vertical nav sections for hamburger panel
;; ---------------------------------------------------------------------------
;; Labelled section: colour bar header + vertical nav items
(defcomp ~mobile-menu-section (&key label href colour level items)
(let* ((c (or colour "sky"))
(lv (or level 1))
(shade (str (- 500 (* lv 100)))))
(div
(div :class (str "flex items-center gap-2 px-3 py-1.5 text-sm font-bold bg-" c "-" shade)
(if href
(a :href href :class "hover:underline" label)
(span label)))
(div :class "flex flex-col gap-1 p-2 text-sm"
items))))
;; Root-level mobile nav: site nav items + auth links
(defcomp ~mobile-root-nav (&key nav-tree auth-menu)
(<>
(when nav-tree
(div :class "flex flex-col gap-2 p-3 text-sm" nav-tree))
(when auth-menu
(div :class "p-3 border-t border-stone-200" auth-menu))))
(defcomp ~error-content (&key errnum message image)
(div :class "text-center p-8 max-w-lg mx-auto"
(div :class "font-bold text-2xl md:text-4xl text-red-500 mb-4" errnum)

View File

@@ -213,9 +213,36 @@ class RelationDef:
nav_label: str | None # "markets"
# ---------------------------------------------------------------------------
# PageDef
# ---------------------------------------------------------------------------
@dataclass
class PageDef:
"""A declarative GET page defined in an .sx file.
Created by ``(defpage name :path "/..." :auth :public :content expr)``.
Slots are stored as unevaluated AST and resolved at request time.
"""
name: str
path: str
auth: str | list # "public", "login", "admin", or ["rights", ...]
layout: Any # layout name/config (unevaluated)
cache: dict | None
data_expr: Any # unevaluated AST
content_expr: Any # unevaluated AST
filter_expr: Any
aside_expr: Any
menu_expr: Any
closure: dict[str, Any] = field(default_factory=dict)
def __repr__(self):
return f"<page:{self.name} path={self.path!r}>"
# ---------------------------------------------------------------------------
# Type alias
# ---------------------------------------------------------------------------
# An s-expression value after evaluation
SExp = int | float | str | bool | Symbol | Keyword | Lambda | Macro | Component | HandlerDef | RelationDef | list | dict | _Nil | None
SExp = int | float | str | bool | Symbol | Keyword | Lambda | Macro | Component | HandlerDef | RelationDef | PageDef | list | dict | _Nil | None