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:
@@ -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,
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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))
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user