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:
2026-03-04 18:20:57 +00:00
parent 8be00df6d9
commit 7fda7a8027
41 changed files with 551 additions and 523 deletions

View File

@@ -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

View File

@@ -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))

View File

@@ -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,
}

View File

@@ -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"

View File

@@ -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