diff --git a/account/sx/layouts.sx b/account/sx/layouts.sx new file mode 100644 index 0000000..06af8df --- /dev/null +++ b/account/sx/layouts.sx @@ -0,0 +1,27 @@ +;; Account layout defcomps — read ctx values from env free variables. +;; Registered via register_sx_layout("account", ...) in __init__.py. + +;; Full page: root header + auth header row in header-child +(defcomp ~account-layout-full () + (<> (~root-header) + (~header-child-sx + :inner (~auth-header-row :account-url account-url + :select-colours select-colours + :account-nav account-nav)))) + +;; OOB (HTMX): auth row + root header, both with oob=true +(defcomp ~account-layout-oob () + (<> (~auth-header-row :account-url account-url + :select-colours select-colours + :account-nav account-nav + :oob true) + (~root-header :oob true))) + +;; Mobile menu: auth section + root nav +(defcomp ~account-layout-mobile () + (<> (~mobile-menu-section + :label "account" :href "/" :level 1 :colour "sky" + :items (~auth-nav-items :account-url account-url + :select-colours select-colours + :account-nav account-nav)) + (~root-mobile))) diff --git a/account/sxc/pages/__init__.py b/account/sxc/pages/__init__.py index 7f624ed..79a123d 100644 --- a/account/sxc/pages/__init__.py +++ b/account/sxc/pages/__init__.py @@ -1,8 +1,6 @@ """Account defpage setup — registers layouts and loads .sx pages.""" from __future__ import annotations -from typing import Any - def setup_account_pages() -> None: """Register account-specific layouts and load page definitions.""" @@ -16,76 +14,6 @@ def _load_account_page_files() -> None: 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) - - -async def _account_full(ctx: dict, **kw: Any) -> str: - from shared.sx.helpers import root_header_sx, header_child_sx, render_to_sx - - root_hdr = await root_header_sx(ctx) - auth_hdr = await render_to_sx("auth-header-row", - account_url=_call_url(ctx, "account_url", ""), - select_colours=ctx.get("select_colours", ""), - account_nav=_as_sx_nav(ctx), - ) - hdr_child = await header_child_sx(auth_hdr) - return "(<> " + root_hdr + " " + hdr_child + ")" - - -async def _account_oob(ctx: dict, **kw: Any) -> str: - from shared.sx.helpers import root_header_sx, render_to_sx - - auth_hdr = await render_to_sx("auth-header-row", - account_url=_call_url(ctx, "account_url", ""), - select_colours=ctx.get("select_colours", ""), - account_nav=_as_sx_nav(ctx), - oob=True, - ) - return "(<> " + auth_hdr + " " + await root_header_sx(ctx, oob=True) + ")" - - -async def _account_mobile(ctx: dict, **kw: Any) -> str: - from shared.sx.helpers import mobile_menu_sx, mobile_root_nav_sx, render_to_sx - from shared.sx.parser import SxExpr - - ctx = _inject_account_nav(ctx) - nav_items = await render_to_sx("auth-nav-items", - account_url=_call_url(ctx, "account_url", ""), - select_colours=ctx.get("select_colours", ""), - account_nav=_as_sx_nav(ctx), - ) - auth_section = await render_to_sx("mobile-menu-section", - label="account", href="/", level=1, colour="sky", - items=SxExpr(nav_items)) - return mobile_menu_sx(auth_section, await mobile_root_nav_sx(ctx)) - - -def _call_url(ctx: dict, key: str, path: str = "/") -> str: - fn = ctx.get(key) - if callable(fn): - return fn(path) - return str(fn or "") + path - - -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 - - -def _as_sx_nav(ctx: dict) -> Any: - """Convert account_nav fragment to SxExpr for use in component calls.""" - from shared.sx.helpers import _as_sx - ctx = _inject_account_nav(ctx) - return _as_sx(ctx.get("account_nav")) - - + from shared.sx.layouts import register_sx_layout + register_sx_layout("account", "account-layout-full", "account-layout-oob", "account-layout-mobile") diff --git a/federation/sx/layouts.sx b/federation/sx/layouts.sx new file mode 100644 index 0000000..321eb55 --- /dev/null +++ b/federation/sx/layouts.sx @@ -0,0 +1,17 @@ +;; Federation layout defcomps — read ctx values from env free variables. +;; `actor` is injected into env by the layout registration in __init__.py. + +;; Full page: root header + social header in header-child +(defcomp ~social-layout-full () + (<> (~root-header) + (~header-child-sx + :inner (~federation-social-header + :nav (~federation-social-nav :actor actor))))) + +;; OOB (HTMX): social header oob + root header oob +(defcomp ~social-layout-oob () + (<> (~oob-header-sx + :parent-id "root-header-child" + :row (~federation-social-header + :nav (~federation-social-nav :actor actor))) + (~root-header :oob true))) diff --git a/federation/sxc/pages/__init__.py b/federation/sxc/pages/__init__.py index c8c25ed..b8af21f 100644 --- a/federation/sxc/pages/__init__.py +++ b/federation/sxc/pages/__init__.py @@ -17,7 +17,7 @@ def _load_federation_page_files() -> None: # --------------------------------------------------------------------------- -# Layouts +# Layouts — .sx defcomps read free variables from env # --------------------------------------------------------------------------- def _register_federation_layouts() -> None: @@ -25,36 +25,30 @@ def _register_federation_layouts() -> None: register_custom_layout("social", _social_full, _social_oob) -async def _social_full(ctx: dict, **kw: Any) -> str: - from shared.sx.helpers import root_header_sx, header_child_sx, render_to_sx - from shared.sx.parser import SxExpr - +def _actor_data(ctx: dict) -> dict | None: actor = ctx.get("actor") - actor_data = _serialize_actor(actor) if actor else None - nav = await render_to_sx("federation-social-nav", actor=actor_data) - social_hdr = await render_to_sx("federation-social-header", nav=SxExpr(nav)) - root_hdr = await root_header_sx(ctx) - child = await header_child_sx(social_hdr) - return "(<> " + root_hdr + " " + child + ")" + if not actor: + return None + from services.federation_page import _serialize_actor + return _serialize_actor(actor) + + +async def _social_full(ctx: dict, **kw: Any) -> str: + from shared.sx.helpers import render_to_sx_with_env, _ctx_to_env + env = _ctx_to_env(ctx) + env["actor"] = kw.get("actor") or _actor_data(ctx) + return await render_to_sx_with_env("social-layout-full", env) async def _social_oob(ctx: dict, **kw: Any) -> str: - from shared.sx.helpers import root_header_sx, render_to_sx - from shared.sx.parser import SxExpr - - actor = ctx.get("actor") - actor_data = _serialize_actor(actor) if actor else None - nav = await render_to_sx("federation-social-nav", actor=actor_data) - social_hdr = await render_to_sx("federation-social-header", nav=SxExpr(nav)) - child_oob = await render_to_sx("oob-header-sx", - parent_id="root-header-child", - row=SxExpr(social_hdr)) - root_hdr_oob = await root_header_sx(ctx, oob=True) - return "(<> " + child_oob + " " + root_hdr_oob + ")" + from shared.sx.helpers import render_to_sx_with_env, _ctx_to_env + env = _ctx_to_env(ctx, oob=True) + env["actor"] = kw.get("actor") or _actor_data(ctx) + return await render_to_sx_with_env("social-layout-oob", env) # --------------------------------------------------------------------------- -# Serializers and helpers — still used by layouts and route handlers +# Helpers still used by route handlers # --------------------------------------------------------------------------- def _serialize_actor(actor) -> dict | None: @@ -78,16 +72,12 @@ def _serialize_remote_actor(a) -> dict: async def _social_page(ctx: dict, actor, *, content: str, title: str = "Rose Ash", meta_html: str = "") -> str: """Build a full social page with social header.""" - from shared.sx.helpers import render_to_sx, root_header_sx, header_child_sx, full_page_sx - from shared.sx.parser import SxExpr + from shared.sx.helpers import render_to_sx_with_env, _ctx_to_env, full_page_sx from markupsafe import escape - actor_data = _serialize_actor(actor) - nav = await render_to_sx("federation-social-nav", actor=actor_data) - social_hdr = await render_to_sx("federation-social-header", nav=SxExpr(nav)) - hdr = await root_header_sx(ctx) - child = await header_child_sx(social_hdr) - header_rows = "(<> " + hdr + " " + child + ")" + env = _ctx_to_env(ctx) + env["actor"] = _serialize_actor(actor) if actor else None + header_rows = await render_to_sx_with_env("social-layout-full", env) return await full_page_sx(ctx, header_rows=header_rows, content=content, meta_html=meta_html or f'{escape(title)}') diff --git a/orders/sx/layouts.sx b/orders/sx/layouts.sx new file mode 100644 index 0000000..d141577 --- /dev/null +++ b/orders/sx/layouts.sx @@ -0,0 +1,49 @@ +;; Orders layout defcomps — read ctx values from env free variables. +;; Registered via register_sx_layout("orders", ...) in __init__.py. + +;; --- orders layout: root + auth + orders rows --- + +(defcomp ~orders-layout-full (&key list-url) + (<> (~root-header) + (~header-child-sx + :inner (<> (~auth-header-row :account-url account-url + :select-colours select-colours + :account-nav account-nav) + (~orders-header-row :list-url (or list-url "/")))))) + +(defcomp ~orders-layout-oob (&key list-url) + (<> (~auth-header-row :account-url account-url + :select-colours select-colours + :account-nav account-nav + :oob true) + (~oob-header-sx + :parent-id "auth-header-child" + :row (~orders-header-row :list-url (or list-url "/"))) + (~root-header :oob true))) + +(defcomp ~orders-layout-mobile () + (~root-mobile)) + +;; --- order-detail layout: root + auth + orders + order rows --- + +(defcomp ~order-detail-layout-full (&key list-url detail-url) + (<> (~root-header) + (~order-detail-header-stack + :auth (~auth-header-row :account-url account-url + :select-colours select-colours + :account-nav account-nav) + :orders (~orders-header-row :list-url (or list-url "/")) + :order (~menu-row-sx :id "order-row" :level 3 :colour "sky" + :link-href (or detail-url "/") :link-label "Order" + :icon "fa fa-gbp")))) + +(defcomp ~order-detail-layout-oob (&key detail-url) + (<> (~oob-header-sx + :parent-id "orders-header-child" + :row (~menu-row-sx :id "order-row" :level 3 :colour "sky" + :link-href (or detail-url "/") :link-label "Order" + :icon "fa fa-gbp" :oob true)) + (~root-header :oob true))) + +(defcomp ~order-detail-layout-mobile () + (~root-mobile)) diff --git a/orders/sxc/pages/__init__.py b/orders/sxc/pages/__init__.py index 97b25ef..15314af 100644 --- a/orders/sxc/pages/__init__.py +++ b/orders/sxc/pages/__init__.py @@ -1,8 +1,6 @@ """Orders defpage setup — registers layouts and loads .sx pages.""" from __future__ import annotations -from typing import Any - def setup_orders_pages() -> None: """Register orders-specific layouts and load page definitions.""" @@ -17,110 +15,7 @@ def _load_orders_page_files() -> None: load_page_dir(os.path.dirname(__file__), "orders") -# --------------------------------------------------------------------------- -# Layouts -# --------------------------------------------------------------------------- - def _register_orders_layouts() -> None: - from shared.sx.layouts import register_custom_layout - register_custom_layout("orders", _orders_full, _orders_oob, _orders_mobile) - register_custom_layout("order-detail", _order_detail_full, _order_detail_oob, _order_detail_mobile) - - -async def _orders_full(ctx: dict, **kw: Any) -> str: - from shared.sx.helpers import root_header_sx, header_child_sx, call_url, render_to_sx - - list_url = kw.get("list_url", "/") - account_url = call_url(ctx, "account_url", "") - root_hdr = await root_header_sx(ctx) - auth_hdr = await render_to_sx("auth-header-row", - account_url=account_url, - select_colours=ctx.get("select_colours", ""), - account_nav=_as_sx_nav(ctx), - ) - orders_hdr = await render_to_sx("orders-header-row", list_url=list_url) - inner = "(<> " + auth_hdr + " " + orders_hdr + ")" - return "(<> " + root_hdr + " " + await header_child_sx(inner) + ")" - - -async def _orders_oob(ctx: dict, **kw: Any) -> str: - from shared.sx.helpers import root_header_sx, render_to_sx - from shared.sx.helpers import call_url - from shared.sx.parser import SxExpr - - list_url = kw.get("list_url", "/") - account_url = call_url(ctx, "account_url", "") - auth_hdr = await render_to_sx("auth-header-row", - account_url=account_url, - select_colours=ctx.get("select_colours", ""), - account_nav=_as_sx_nav(ctx), - oob=True, - ) - orders_hdr = await render_to_sx("orders-header-row", list_url=list_url) - auth_child_oob = await render_to_sx("oob-header-sx", - parent_id="auth-header-child", - row=SxExpr(orders_hdr)) - root_hdr = await root_header_sx(ctx, oob=True) - return "(<> " + auth_hdr + " " + auth_child_oob + " " + root_hdr + ")" - - -async def _orders_mobile(ctx: dict, **kw: Any) -> str: - from shared.sx.helpers import mobile_menu_sx, mobile_root_nav_sx - return mobile_menu_sx(await mobile_root_nav_sx(ctx)) - - -async def _order_detail_full(ctx: dict, **kw: Any) -> str: - from shared.sx.helpers import root_header_sx, render_to_sx - from shared.sx.helpers import call_url - from shared.sx.parser import SxExpr - - list_url = kw.get("list_url", "/") - detail_url = kw.get("detail_url", "/") - account_url = call_url(ctx, "account_url", "") - root_hdr = await root_header_sx(ctx) - order_row = await render_to_sx( - "menu-row-sx", - id="order-row", level=3, colour="sky", link_href=detail_url, - link_label="Order", icon="fa fa-gbp", - ) - auth_hdr = await render_to_sx("auth-header-row", - account_url=account_url, - select_colours=ctx.get("select_colours", ""), - account_nav=_as_sx_nav(ctx), - ) - orders_hdr = await render_to_sx("orders-header-row", list_url=list_url) - detail_header = await render_to_sx( - "order-detail-header-stack", - auth=SxExpr(auth_hdr), - orders=SxExpr(orders_hdr), - order=SxExpr(order_row), - ) - return "(<> " + root_hdr + " " + detail_header + ")" - - -async def _order_detail_oob(ctx: dict, **kw: Any) -> str: - from shared.sx.helpers import root_header_sx, render_to_sx - from shared.sx.parser import SxExpr - - detail_url = kw.get("detail_url", "/") - order_row_oob = await render_to_sx( - "menu-row-sx", - id="order-row", level=3, colour="sky", link_href=detail_url, - link_label="Order", icon="fa fa-gbp", oob=True, - ) - header_child_oob = await render_to_sx("oob-header-sx", - parent_id="orders-header-child", - row=SxExpr(order_row_oob)) - root_hdr = await root_header_sx(ctx, oob=True) - return "(<> " + header_child_oob + " " + root_hdr + ")" - - -async def _order_detail_mobile(ctx: dict, **kw: Any) -> str: - from shared.sx.helpers import mobile_menu_sx, mobile_root_nav_sx - return mobile_menu_sx(await mobile_root_nav_sx(ctx)) - - -def _as_sx_nav(ctx: dict) -> Any: - """Convert account_nav fragment to SxExpr for use in component calls.""" - from shared.sx.helpers import _as_sx - return _as_sx(ctx.get("account_nav")) + from shared.sx.layouts import register_sx_layout + register_sx_layout("orders", "orders-layout-full", "orders-layout-oob", "orders-layout-mobile") + register_sx_layout("order-detail", "order-detail-layout-full", "order-detail-layout-oob", "order-detail-layout-mobile") diff --git a/shared/sx/helpers.py b/shared/sx/helpers.py index 30c76f3..bd7aebe 100644 --- a/shared/sx/helpers.py +++ b/shared/sx/helpers.py @@ -385,6 +385,59 @@ 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 + defcomps can read ctx values as free variables. + """ + from .jinja_bridge import get_component_env, _get_request_context + from .async_eval import async_eval_to_sx + + ast = _build_component_ast(__name, **kwargs) + env = dict(get_component_env()) + env.update(extra_env) + ctx = _get_request_context() + return await async_eval_to_sx(ast, env, ctx) + + async def render_to_sx(__name: str, **kwargs: Any) -> str: """Call a defcomp and get SX wire format back. No SX string literals. diff --git a/shared/sx/layouts.py b/shared/sx/layouts.py index 7ba5f4c..df69649 100644 --- a/shared/sx/layouts.py +++ b/shared/sx/layouts.py @@ -15,7 +15,7 @@ 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, + oob_header_sx, mobile_menu_sx, mobile_root_nav_sx, post_mobile_nav_sx, post_admin_mobile_nav_sx, ) @@ -87,15 +87,6 @@ def get_layout(name: str) -> Layout | None: # Built-in layouts # --------------------------------------------------------------------------- -async def _root_full(ctx: dict, **kw: Any) -> str: - return await root_header_sx(ctx) - - -async def _root_oob(ctx: dict, **kw: Any) -> str: - root_hdr = await root_header_sx(ctx) - return await oob_header_sx("root-header-child", "root-header-child", root_hdr) - - async def _post_full(ctx: dict, **kw: Any) -> str: root_hdr = await root_header_sx(ctx) post_hdr = await post_header_sx(ctx) @@ -127,10 +118,6 @@ async def _post_admin_oob(ctx: dict, **kw: Any) -> str: return "(<> " + post_hdr + " " + admin_oob + ")" -async def _root_mobile(ctx: dict, **kw: Any) -> str: - return await mobile_root_nav_sx(ctx) - - async def _post_mobile(ctx: dict, **kw: Any) -> str: return mobile_menu_sx(await post_mobile_nav_sx(ctx), await mobile_root_nav_sx(ctx)) @@ -145,11 +132,53 @@ async def _post_admin_mobile(ctx: dict, **kw: Any) -> str: ) -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)) +# --------------------------------------------------------------------------- +# register_sx_layout — declarative layout from .sx defcomp names +# --------------------------------------------------------------------------- +# (defined below, used immediately after for built-in "root" layout) +# --------------------------------------------------------------------------- + +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:: + + register_sx_layout("account", "account-layout-full", + "account-layout-oob", "account-layout-mobile") + """ + from .helpers import render_to_sx_with_env, _ctx_to_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()}) + 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()}) + 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()}) + return await render_to_sx_with_env(mobile_defcomp, env) + + register_layout(Layout(name, full_fn, oob_fn, mobile_fn)) + + +# Register built-in "root" layout via .sx defcomps +register_sx_layout("root", "layout-root-full", "layout-root-oob", "layout-root-mobile") + + # --------------------------------------------------------------------------- # Callable layout — services register custom Python layout functions # --------------------------------------------------------------------------- diff --git a/shared/sx/templates/layout.sx b/shared/sx/templates/layout.sx index ba7571e..11b1dd4 100644 --- a/shared/sx/templates/layout.sx +++ b/shared/sx/templates/layout.sx @@ -146,6 +146,33 @@ (when auth-menu (div :class "p-3 border-t border-stone-200" auth-menu)))) +;; --------------------------------------------------------------------------- +;; Root header/mobile wrappers — read ctx values from env free variables +;; Used by register_sx_layout so .sx defcomps compose without Python +;; --------------------------------------------------------------------------- + +(defcomp ~root-header (&key oob) + (~header-row-sx :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 + :oob oob)) + +(defcomp ~root-mobile () + (~mobile-root-nav :nav-tree nav-tree :auth-menu auth-menu)) + +;; --------------------------------------------------------------------------- +;; Built-in layout defcomps — used by register_sx_layout("root", ...) +;; --------------------------------------------------------------------------- + +(defcomp ~layout-root-full () + (~root-header)) + +(defcomp ~layout-root-oob () + (~oob-header-sx :parent-id "root-header-child" :row (~root-header))) + +(defcomp ~layout-root-mobile () + (~root-mobile)) + (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)