Replace env free-variable threading with IO-primitive auto-fetch macros
Layout components now self-resolve context (cart-mini, auth-menu, nav-tree, rights, URLs) via new IO primitives (root-header-ctx, select-colours, account-nav-ctx, app-rights) and defmacro wrappers (~root-header-auto, ~auth-header-row-auto, ~root-mobile-auto). This eliminates _ctx_to_env(), HELPER_CSS_CLASSES, and verbose :key threading across all 10 services. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -16,34 +16,6 @@ from .page import SEARCH_HEADERS_MOBILE, SEARCH_HEADERS_DESKTOP
|
||||
from .parser import SxExpr
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Pre-computed CSS classes for inline sx built by Python helpers
|
||||
# ---------------------------------------------------------------------------
|
||||
# These :class strings appear in post_header_sx / post_admin_header_sx etc.
|
||||
# They're static — scan once at import time so they aren't re-scanned per request.
|
||||
|
||||
_HELPER_CLASS_SOURCES = [
|
||||
':class "flex flex-col sm:flex-row sm:items-center gap-2 border-r border-stone-200 mr-2 sm:max-w-2xl"',
|
||||
':class "relative nav-group"',
|
||||
':class "justify-center cursor-pointer flex flex-row items-center gap-2 rounded bg-stone-200 text-black p-3"',
|
||||
':class "!bg-stone-500 !text-white"',
|
||||
':class "fa fa-cog"',
|
||||
':class "fa fa-shield-halved"',
|
||||
':class "text-white"',
|
||||
':class "justify-center cursor-pointer flex flex-row items-center gap-2 rounded !bg-stone-500 !text-white p-3"',
|
||||
]
|
||||
|
||||
|
||||
def _scan_helper_classes() -> frozenset[str]:
|
||||
"""Scan the static class strings from helper functions once."""
|
||||
from .css_registry import scan_classes_from_sx
|
||||
combined = " ".join(_HELPER_CLASS_SOURCES)
|
||||
return frozenset(scan_classes_from_sx(combined))
|
||||
|
||||
|
||||
HELPER_CSS_CLASSES: frozenset[str] = _scan_helper_classes()
|
||||
|
||||
|
||||
def call_url(ctx: dict, key: str, path: str = "/") -> str:
|
||||
"""Call a URL helper from context (e.g., blog_url, account_url)."""
|
||||
fn = ctx.get(key)
|
||||
@@ -141,12 +113,8 @@ async def _post_nav_items_sx(ctx: dict) -> str:
|
||||
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 :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})'
|
||||
)
|
||||
parts.append(await render_to_sx("container-nav-wrapper",
|
||||
content=SxExpr(container_nav)))
|
||||
|
||||
# Admin cog
|
||||
admin_nav = ctx.get("post_admin_nav")
|
||||
@@ -157,15 +125,9 @@ async def _post_nav_items_sx(ctx: dict) -> str:
|
||||
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")))'
|
||||
)
|
||||
admin_nav = await render_to_sx("admin-cog-button",
|
||||
href=admin_href,
|
||||
is_admin_page=is_admin_page or None)
|
||||
if admin_nav:
|
||||
parts.append(admin_nav)
|
||||
return "(<> " + " ".join(parts) + ")" if parts else ""
|
||||
@@ -282,10 +244,8 @@ async def post_admin_header_sx(ctx: dict, slug: str, *, oob: bool = False,
|
||||
selected: str = "", admin_href: str = "") -> str:
|
||||
"""Post admin header row as sx wire format."""
|
||||
# Label
|
||||
label_parts = ['(i :class "fa fa-shield-halved" :aria-hidden "true")', '" admin"']
|
||||
if selected:
|
||||
label_parts.append(f'(span :class "text-white" "{escape(selected)}")')
|
||||
label_sx = "(<> " + " ".join(label_parts) + ")"
|
||||
label_sx = await render_to_sx("post-admin-label",
|
||||
selected=str(escape(selected)) if selected else None)
|
||||
|
||||
nav_sx = await _post_admin_nav_items_sx(ctx, slug, selected) or None
|
||||
|
||||
@@ -385,44 +345,6 @@ def _build_component_ast(__name: str, **kwargs: Any) -> list:
|
||||
return ast
|
||||
|
||||
|
||||
def _ctx_to_env(ctx: dict, *, oob: bool = False) -> dict:
|
||||
"""Convert template context dict → SX evaluation env dict.
|
||||
|
||||
Applies ``_as_sx()`` to HTML fragments, ``call_url()`` to URL helpers,
|
||||
extracts rights/admin flags. Returns kebab-case keys matching SX
|
||||
symbol conventions so .sx defcomps can read them as free variables.
|
||||
"""
|
||||
rights = ctx.get("rights") or {}
|
||||
is_admin = rights.get("admin") if isinstance(rights, dict) else getattr(rights, "admin", False)
|
||||
env = {
|
||||
# Root header values (match ~header-row-sx &key params)
|
||||
"cart-mini": _as_sx(ctx.get("cart_mini")),
|
||||
"blog-url": call_url(ctx, "blog_url", ""),
|
||||
"site-title": ctx.get("base_title", ""),
|
||||
"app-label": ctx.get("app_label", ""),
|
||||
"nav-tree": _as_sx(ctx.get("nav_tree")),
|
||||
"auth-menu": _as_sx(ctx.get("auth_menu")),
|
||||
"nav-panel": _as_sx(ctx.get("nav_panel")),
|
||||
"settings-url": call_url(ctx, "blog_url", "/settings/") if is_admin else "",
|
||||
"is-admin": is_admin,
|
||||
"oob": oob,
|
||||
# URL helpers (pre-resolved to strings)
|
||||
"account-url": call_url(ctx, "account_url", ""),
|
||||
"events-url": call_url(ctx, "events_url", ""),
|
||||
"market-url": call_url(ctx, "market_url", ""),
|
||||
"cart-url": call_url(ctx, "cart_url", ""),
|
||||
# Common values
|
||||
"select-colours": ctx.get("select_colours", ""),
|
||||
"rights": rights,
|
||||
# Fragments (used by various services)
|
||||
"container-nav": _as_sx(ctx.get("container_nav")),
|
||||
"account-nav": _as_sx(ctx.get("account_nav")),
|
||||
# Post context
|
||||
"post": ctx.get("post") or {},
|
||||
}
|
||||
return env
|
||||
|
||||
|
||||
async def render_to_sx_with_env(__name: str, extra_env: dict, **kwargs: Any) -> str:
|
||||
"""Like ``render_to_sx`` but merges *extra_env* into the evaluation
|
||||
environment before eval. Used by ``register_sx_layout`` so .sx
|
||||
@@ -584,8 +506,6 @@ def sx_response(source: str, status: int = 200,
|
||||
cumulative_classes: set[str] = set()
|
||||
if registry_loaded():
|
||||
new_classes = scan_classes_from_sx(source)
|
||||
# Include pre-computed helper classes (menu bars, admin nav, etc.)
|
||||
new_classes.update(HELPER_CSS_CLASSES)
|
||||
if comp_defs:
|
||||
# Scan only the component definitions actually being sent
|
||||
new_classes.update(scan_classes_from_sx(comp_defs))
|
||||
@@ -717,8 +637,6 @@ def sx_page(ctx: dict, page_sx: str, *,
|
||||
for val in _COMPONENT_ENV.values():
|
||||
if isinstance(val, Component) and val.css_classes:
|
||||
classes.update(val.css_classes)
|
||||
# Include pre-computed helper classes (menu bars, admin nav, etc.)
|
||||
classes.update(HELPER_CSS_CLASSES)
|
||||
# Page sx is unique per request — scan it
|
||||
classes.update(scan_classes_from_sx(page_sx))
|
||||
# Always include body classes
|
||||
|
||||
@@ -146,30 +146,27 @@ def register_sx_layout(name: str, full_defcomp: str, oob_defcomp: str,
|
||||
mobile_defcomp: str | None = None) -> None:
|
||||
"""Register a layout that delegates entirely to .sx defcomps.
|
||||
|
||||
The defcomps read ctx values as free variables from the evaluation
|
||||
environment (populated by ``_ctx_to_env``). Python layouts become
|
||||
one-liners::
|
||||
Layout defcomps use IO primitives (via auto-fetching macros) to
|
||||
self-populate — no Python env injection needed. Any extra kwargs
|
||||
from the caller are passed as kebab-case env entries::
|
||||
|
||||
register_sx_layout("account", "account-layout-full",
|
||||
"account-layout-oob", "account-layout-mobile")
|
||||
"""
|
||||
from .helpers import render_to_sx_with_env, _ctx_to_env
|
||||
from .helpers import render_to_sx_with_env
|
||||
|
||||
async def full_fn(ctx: dict, **kw: Any) -> str:
|
||||
env = _ctx_to_env(ctx)
|
||||
env.update({k.replace("_", "-"): v for k, v in kw.items()})
|
||||
env = {k.replace("_", "-"): v for k, v in kw.items()}
|
||||
return await render_to_sx_with_env(full_defcomp, env)
|
||||
|
||||
async def oob_fn(ctx: dict, **kw: Any) -> str:
|
||||
env = _ctx_to_env(ctx, oob=True)
|
||||
env.update({k.replace("_", "-"): v for k, v in kw.items()})
|
||||
env = {k.replace("_", "-"): v for k, v in kw.items()}
|
||||
return await render_to_sx_with_env(oob_defcomp, env)
|
||||
|
||||
mobile_fn = None
|
||||
if mobile_defcomp:
|
||||
async def mobile_fn(ctx: dict, **kw: Any) -> str:
|
||||
env = _ctx_to_env(ctx)
|
||||
env.update({k.replace("_", "-"): v for k, v in kw.items()})
|
||||
env = {k.replace("_", "-"): v for k, v in kw.items()}
|
||||
return await render_to_sx_with_env(mobile_defcomp, env)
|
||||
|
||||
register_layout(Layout(name, full_fn, oob_fn, mobile_fn))
|
||||
|
||||
@@ -45,6 +45,10 @@ IO_PRIMITIVES: frozenset[str] = frozenset({
|
||||
"abort",
|
||||
"url-for",
|
||||
"route-prefix",
|
||||
"root-header-ctx",
|
||||
"select-colours",
|
||||
"account-nav-ctx",
|
||||
"app-rights",
|
||||
})
|
||||
|
||||
|
||||
@@ -378,6 +382,106 @@ async def _io_route_prefix(
|
||||
return route_prefix()
|
||||
|
||||
|
||||
async def _io_root_header_ctx(
|
||||
args: list[Any], kwargs: dict[str, Any], ctx: RequestContext
|
||||
) -> dict[str, Any]:
|
||||
"""``(root-header-ctx)`` → dict with all root header values.
|
||||
|
||||
Fetches cart-mini, auth-menu, nav-tree fragments and computes
|
||||
settings-url / is-admin from rights. Result is cached on ``g``
|
||||
per request so multiple calls (e.g. header + mobile) are free.
|
||||
"""
|
||||
from quart import g, current_app, request
|
||||
cached = getattr(g, "_root_header_ctx", None)
|
||||
if cached is not None:
|
||||
return cached
|
||||
|
||||
from shared.infrastructure.fragments import fetch_fragments
|
||||
from shared.infrastructure.cart_identity import current_cart_identity
|
||||
from shared.infrastructure.urls import app_url
|
||||
from shared.config import config
|
||||
from .types import NIL
|
||||
|
||||
user = getattr(g, "user", None)
|
||||
ident = current_cart_identity()
|
||||
|
||||
cart_params: dict[str, Any] = {}
|
||||
if ident["user_id"] is not None:
|
||||
cart_params["user_id"] = ident["user_id"]
|
||||
if ident["session_id"] is not None:
|
||||
cart_params["session_id"] = ident["session_id"]
|
||||
|
||||
auth_params: dict[str, Any] = {}
|
||||
if user and getattr(user, "email", None):
|
||||
auth_params["email"] = user.email
|
||||
|
||||
nav_params = {"app_name": current_app.name, "path": request.path}
|
||||
|
||||
cart_mini, auth_menu, nav_tree = await fetch_fragments([
|
||||
("cart", "cart-mini", cart_params or None),
|
||||
("account", "auth-menu", auth_params or None),
|
||||
("blog", "nav-tree", nav_params),
|
||||
])
|
||||
|
||||
rights = getattr(g, "rights", None) or {}
|
||||
is_admin = (
|
||||
rights.get("admin", False)
|
||||
if isinstance(rights, dict)
|
||||
else getattr(rights, "admin", False)
|
||||
)
|
||||
|
||||
result = {
|
||||
"cart-mini": cart_mini or NIL,
|
||||
"blog-url": app_url("blog", ""),
|
||||
"site-title": config()["title"],
|
||||
"app-label": current_app.name,
|
||||
"nav-tree": nav_tree or NIL,
|
||||
"auth-menu": auth_menu or NIL,
|
||||
"nav-panel": NIL,
|
||||
"settings-url": app_url("blog", "/settings/") if is_admin else "",
|
||||
"is-admin": is_admin,
|
||||
}
|
||||
g._root_header_ctx = result
|
||||
return result
|
||||
|
||||
|
||||
async def _io_select_colours(
|
||||
args: list[Any], kwargs: dict[str, Any], ctx: RequestContext
|
||||
) -> str:
|
||||
"""``(select-colours)`` → the shared select/hover CSS class string."""
|
||||
from quart import current_app
|
||||
return current_app.jinja_env.globals.get("select_colours", "")
|
||||
|
||||
|
||||
async def _io_account_nav_ctx(
|
||||
args: list[Any], kwargs: dict[str, Any], ctx: RequestContext
|
||||
) -> Any:
|
||||
"""``(account-nav-ctx)`` → account nav fragments as SxExpr, or NIL.
|
||||
|
||||
Reads ``g.account_nav`` (set by account service's before_request hook),
|
||||
wrapping HTML strings in ``~rich-text`` for SX rendering.
|
||||
"""
|
||||
from quart import g
|
||||
from .types import NIL
|
||||
from .parser import SxExpr
|
||||
val = getattr(g, "account_nav", None)
|
||||
if not val:
|
||||
return NIL
|
||||
if isinstance(val, SxExpr):
|
||||
return val
|
||||
# HTML string → wrap for SX rendering
|
||||
escaped = str(val).replace("\\", "\\\\").replace('"', '\\"')
|
||||
return SxExpr(f'(~rich-text :html "{escaped}")')
|
||||
|
||||
|
||||
async def _io_app_rights(
|
||||
args: list[Any], kwargs: dict[str, Any], ctx: RequestContext
|
||||
) -> dict[str, Any]:
|
||||
"""``(app-rights)`` → user rights dict from ``g.rights``."""
|
||||
from quart import g
|
||||
return getattr(g, "rights", None) or {}
|
||||
|
||||
|
||||
_IO_HANDLERS: dict[str, Any] = {
|
||||
"frag": _io_frag,
|
||||
"query": _io_query,
|
||||
@@ -394,4 +498,8 @@ _IO_HANDLERS: dict[str, Any] = {
|
||||
"abort": _io_abort,
|
||||
"url-for": _io_url_for,
|
||||
"route-prefix": _io_route_prefix,
|
||||
"root-header-ctx": _io_root_header_ctx,
|
||||
"select-colours": _io_select_colours,
|
||||
"account-nav-ctx": _io_account_nav_ctx,
|
||||
"app-rights": _io_app_rights,
|
||||
}
|
||||
|
||||
@@ -30,6 +30,27 @@
|
||||
:link-label "account" :icon "fa-solid fa-user"
|
||||
:child-id "auth-header-child" :oob oob))
|
||||
|
||||
;; Auto-fetching auth header — uses IO primitives, no free variables needed.
|
||||
;; Expands inline (defmacro) so IO calls resolve in _aser mode.
|
||||
(defmacro ~auth-header-row-auto (oob)
|
||||
(quasiquote
|
||||
(~auth-header-row :account-url (app-url "account" "")
|
||||
:select-colours (select-colours)
|
||||
:account-nav (account-nav-ctx)
|
||||
:oob (unquote oob))))
|
||||
|
||||
(defmacro ~auth-header-row-simple-auto (oob)
|
||||
(quasiquote
|
||||
(~auth-header-row-simple :account-url (app-url "account" "")
|
||||
:oob (unquote oob))))
|
||||
|
||||
;; Auto-fetching auth nav items — for mobile menus
|
||||
(defmacro ~auth-nav-items-auto ()
|
||||
(quasiquote
|
||||
(~auth-nav-items :account-url (app-url "account" "")
|
||||
:select-colours (select-colours)
|
||||
:account-nav (account-nav-ctx))))
|
||||
|
||||
;; Orders header row
|
||||
(defcomp ~orders-header-row (&key list-url)
|
||||
(~menu-row-sx :id "orders-row" :level 2 :colour "sky"
|
||||
|
||||
@@ -162,24 +162,46 @@
|
||||
(defcomp ~root-mobile (&key nav-tree auth-menu)
|
||||
(~mobile-root-nav :nav-tree nav-tree :auth-menu auth-menu))
|
||||
|
||||
;; ---------------------------------------------------------------------------
|
||||
;; Auto-fetching header/mobile macros — use IO primitives to self-populate.
|
||||
;; These expand inline so IO calls resolve in _aser mode within layout bodies.
|
||||
;; Replaces the 10-parameter ~root-header boilerplate in layout defcomps.
|
||||
;; ---------------------------------------------------------------------------
|
||||
|
||||
(defmacro ~root-header-auto (oob)
|
||||
(quasiquote
|
||||
(let ((__rhctx (root-header-ctx)))
|
||||
(~header-row-sx :cart-mini (get __rhctx "cart-mini")
|
||||
:blog-url (get __rhctx "blog-url")
|
||||
:site-title (get __rhctx "site-title")
|
||||
:app-label (get __rhctx "app-label")
|
||||
:nav-tree (get __rhctx "nav-tree")
|
||||
:auth-menu (get __rhctx "auth-menu")
|
||||
:nav-panel (get __rhctx "nav-panel")
|
||||
:settings-url (get __rhctx "settings-url")
|
||||
:is-admin (get __rhctx "is-admin")
|
||||
:oob (unquote oob)))))
|
||||
|
||||
(defmacro ~root-mobile-auto ()
|
||||
(quasiquote
|
||||
(let ((__rhctx (root-header-ctx)))
|
||||
(~mobile-root-nav :nav-tree (get __rhctx "nav-tree")
|
||||
:auth-menu (get __rhctx "auth-menu")))))
|
||||
|
||||
;; ---------------------------------------------------------------------------
|
||||
;; Built-in layout defcomps — used by register_sx_layout("root", ...)
|
||||
;; Free variables (cart-mini, blog-url, etc.) come from _ctx_to_env().
|
||||
;; These use ~root-header-auto / ~root-mobile-auto macros (IO primitives).
|
||||
;; ---------------------------------------------------------------------------
|
||||
|
||||
(defcomp ~layout-root-full ()
|
||||
(~root-header :cart-mini cart-mini :blog-url blog-url :site-title site-title
|
||||
:app-label app-label :nav-tree nav-tree :auth-menu auth-menu
|
||||
:nav-panel nav-panel :settings-url settings-url :is-admin is-admin))
|
||||
(~root-header-auto))
|
||||
|
||||
(defcomp ~layout-root-oob ()
|
||||
(~oob-header-sx :parent-id "root-header-child"
|
||||
:row (~root-header :cart-mini cart-mini :blog-url blog-url :site-title site-title
|
||||
:app-label app-label :nav-tree nav-tree :auth-menu auth-menu
|
||||
:nav-panel nav-panel :settings-url settings-url :is-admin is-admin)))
|
||||
:row (~root-header-auto true)))
|
||||
|
||||
(defcomp ~layout-root-mobile ()
|
||||
(~root-mobile :nav-tree nav-tree :auth-menu auth-menu))
|
||||
(~root-mobile-auto))
|
||||
|
||||
(defcomp ~error-content (&key errnum message image)
|
||||
(div :class "text-center p-8 max-w-lg mx-auto"
|
||||
@@ -189,6 +211,33 @@
|
||||
(div :class "flex justify-center"
|
||||
(img :src image :width "300" :height "300")))))
|
||||
|
||||
(defcomp ~clear-oob-div (&key id)
|
||||
(div :id id :sx-swap-oob "outerHTML"))
|
||||
|
||||
;; ---------------------------------------------------------------------------
|
||||
;; Shared nav helpers — used by post_header_sx / post_admin_header_sx
|
||||
;; ---------------------------------------------------------------------------
|
||||
|
||||
(defcomp ~container-nav-wrapper (&key content)
|
||||
(div :id "entries-calendars-nav-wrapper"
|
||||
:class "flex flex-col sm:flex-row sm:items-center gap-2 border-r border-stone-200 mr-2 sm:max-w-2xl"
|
||||
content))
|
||||
|
||||
; @css justify-center cursor-pointer flex flex-row items-center gap-2 rounded bg-stone-200 text-black p-3 !bg-stone-500 !text-white
|
||||
(defcomp ~admin-cog-button (&key href is-admin-page)
|
||||
(div :class "relative nav-group"
|
||||
(a :href href
|
||||
:class (str "justify-center cursor-pointer flex flex-row items-center gap-2 rounded bg-stone-200 text-black p-3 "
|
||||
(if is-admin-page "!bg-stone-500 !text-white" ""))
|
||||
(i :class "fa fa-cog" :aria-hidden "true"))))
|
||||
|
||||
(defcomp ~post-admin-label (&key selected)
|
||||
(<>
|
||||
(i :class "fa fa-shield-halved" :aria-hidden "true")
|
||||
" admin"
|
||||
(when selected
|
||||
(span :class "text-white" selected))))
|
||||
|
||||
(defcomp ~nav-link (&key href hx-select label icon aclass select-colours is-selected)
|
||||
(div :class "relative nav-group"
|
||||
(a :href href
|
||||
|
||||
Reference in New Issue
Block a user