Remove render_to_sx from public API: enforce sx_call for all service code
All checks were successful
Build and Deploy / build-and-deploy (push) Successful in 1m44s

Replace ~250 render_to_sx calls across all services with sync sx_call,
converting many async functions to sync where no other awaits remained.
Make render_to_sx/render_to_sx_with_env private (_render_to_sx).
Add (post-header-ctx) IO primitive and shared post/post-admin defmacros.
Convert built-in post/post-admin layouts from Python to register_sx_layout
with .sx defcomps. Remove dead post_admin_mobile_nav_sx.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-03-04 19:30:45 +00:00
parent 57e0d0c341
commit 959e63d440
61 changed files with 1352 additions and 1208 deletions

View File

@@ -149,7 +149,7 @@ async def _rich_error_page(errnum: str, message: str, image: str | None = None)
# Root header (site nav bar)
from shared.sx.helpers import (
root_header_sx, post_header_sx,
header_child_sx, full_page_sx, render_to_sx,
header_child_sx, full_page_sx, sx_call,
)
hdr = await root_header_sx(ctx)
@@ -162,7 +162,7 @@ async def _rich_error_page(errnum: str, message: str, image: str | None = None)
hdr = "(<> " + hdr + " " + await header_child_sx(post_row) + ")"
# Error content
error_content = await render_to_sx("error-content", errnum=errnum, message=message, image=image)
error_content = sx_call("error-content", errnum=errnum, message=message, image=image)
return await full_page_sx(ctx, header_rows=hdr, content=error_content)
except Exception:

View File

@@ -60,7 +60,7 @@ async def root_header_sx(ctx: dict, *, oob: bool = False) -> str:
rights = ctx.get("rights") or {}
is_admin = rights.get("admin") if isinstance(rights, dict) else getattr(rights, "admin", False)
settings_url = call_url(ctx, "blog_url", "/settings/") if is_admin else ""
return await render_to_sx("header-row-sx",
return await _render_to_sx("header-row-sx",
cart_mini=_as_sx(ctx.get("cart_mini")),
blog_url=call_url(ctx, "blog_url", ""),
site_title=ctx.get("base_title", ""),
@@ -86,7 +86,7 @@ async def mobile_root_nav_sx(ctx: dict) -> str:
auth_menu = ctx.get("auth_menu") or ""
if not nav_tree and not auth_menu:
return ""
return await render_to_sx("mobile-root-nav",
return await _render_to_sx("mobile-root-nav",
nav_tree=_as_sx(nav_tree),
auth_menu=_as_sx(auth_menu),
)
@@ -107,13 +107,13 @@ async def _post_nav_items_sx(ctx: dict) -> 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}/")
parts.append(await render_to_sx("page-cart-badge", href=cart_href,
parts.append(await _render_to_sx("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(await render_to_sx("container-nav-wrapper",
parts.append(await _render_to_sx("container-nav-wrapper",
content=SxExpr(container_nav)))
# Admin cog
@@ -125,7 +125,7 @@ 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
admin_nav = await render_to_sx("admin-cog-button",
admin_nav = await _render_to_sx("admin-cog-button",
href=admin_href,
is_admin_page=is_admin_page or None)
if admin_nav:
@@ -155,7 +155,7 @@ async def _post_admin_nav_items_sx(ctx: dict, slug: str,
continue
href = url_fn(path)
is_sel = label == selected
parts.append(await render_to_sx("nav-link", href=href, label=label,
parts.append(await _render_to_sx("nav-link", href=href, label=label,
select_colours=select_colours,
is_selected=is_sel or None))
return "(<> " + " ".join(parts) + ")" if parts else ""
@@ -173,7 +173,7 @@ async def post_mobile_nav_sx(ctx: dict) -> str:
post = ctx.get("post") or {}
slug = post.get("slug", "")
title = (post.get("title") or slug)[:40]
return await render_to_sx("mobile-menu-section",
return await _render_to_sx("mobile-menu-section",
label=title,
href=call_url(ctx, "blog_url", f"/{slug}/"),
level=1,
@@ -181,22 +181,10 @@ async def post_mobile_nav_sx(ctx: dict) -> str:
)
async def post_admin_mobile_nav_sx(ctx: dict, slug: str,
selected: str = "") -> str:
"""Post-admin mobile menu section."""
nav = await _post_admin_nav_items_sx(ctx, slug, selected)
if not nav:
return ""
admin_href = call_url(ctx, "blog_url", f"/{slug}/admin/")
return await render_to_sx("mobile-menu-section",
label="admin", href=admin_href, level=2,
items=SxExpr(nav),
)
async def search_mobile_sx(ctx: dict) -> str:
"""Build mobile search input as sx wire format."""
return await render_to_sx("search-mobile",
return await _render_to_sx("search-mobile",
current_local_href=ctx.get("current_local_href", "/"),
search=ctx.get("search", ""),
search_count=ctx.get("search_count", ""),
@@ -207,7 +195,7 @@ async def search_mobile_sx(ctx: dict) -> str:
async def search_desktop_sx(ctx: dict) -> str:
"""Build desktop search input as sx wire format."""
return await render_to_sx("search-desktop",
return await _render_to_sx("search-desktop",
current_local_href=ctx.get("current_local_href", "/"),
search=ctx.get("search", ""),
search_count=ctx.get("search_count", ""),
@@ -225,11 +213,11 @@ async def post_header_sx(ctx: dict, *, oob: bool = False, child: str = "") -> st
title = (post.get("title") or "")[:160]
feature_image = post.get("feature_image")
label_sx = await render_to_sx("post-label", feature_image=feature_image, title=title)
label_sx = await _render_to_sx("post-label", feature_image=feature_image, title=title)
nav_sx = await _post_nav_items_sx(ctx) or None
link_href = call_url(ctx, "blog_url", f"/{slug}/")
return await render_to_sx("menu-row-sx",
return await _render_to_sx("menu-row-sx",
id="post-row", level=1,
link_href=link_href,
link_label_content=SxExpr(label_sx),
@@ -244,7 +232,7 @@ 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_sx = await render_to_sx("post-admin-label",
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
@@ -253,7 +241,7 @@ async def post_admin_header_sx(ctx: dict, slug: str, *, oob: bool = False,
blog_fn = ctx.get("blog_url")
admin_href = blog_fn(f"/{slug}/admin/") if callable(blog_fn) else f"/{slug}/admin/"
return await render_to_sx("menu-row-sx",
return await _render_to_sx("menu-row-sx",
id="post-admin-row", level=2,
link_href=admin_href,
link_label_content=SxExpr(label_sx),
@@ -268,7 +256,7 @@ async def oob_header_sx(parent_id: str, child_id: str, row_sx: str) -> str:
child_id is accepted for call-site compatibility but no longer used —
the child placeholder is created by ~menu-row-sx itself.
"""
return await render_to_sx("oob-header-sx",
return await _render_to_sx("oob-header-sx",
parent_id=parent_id,
row=SxExpr(row_sx),
)
@@ -276,7 +264,7 @@ async def oob_header_sx(parent_id: str, child_id: str, row_sx: str) -> str:
async def header_child_sx(inner_sx: str, *, id: str = "root-header-child") -> str:
"""Wrap inner sx in a header-child div."""
return await render_to_sx("header-child-sx",
return await _render_to_sx("header-child-sx",
id=id, inner=SxExpr(f"(<> {inner_sx})"),
)
@@ -284,7 +272,7 @@ async def header_child_sx(inner_sx: str, *, id: str = "root-header-child") -> st
async def oob_page_sx(*, oobs: str = "", filter: str = "", aside: str = "",
content: str = "", menu: str = "") -> str:
"""Build OOB response as sx wire format."""
return await render_to_sx("oob-sx",
return await _render_to_sx("oob-sx",
oobs=SxExpr(f"(<> {oobs})") if oobs else None,
filter=SxExpr(filter) if filter else None,
aside=SxExpr(aside) if aside else None,
@@ -305,7 +293,7 @@ async def full_page_sx(ctx: dict, *, header_rows: str,
# Auto-generate mobile nav from context when no menu provided
if not menu:
menu = await mobile_root_nav_sx(ctx)
body_sx = await render_to_sx("app-body",
body_sx = await _render_to_sx("app-body",
header_rows=SxExpr(f"(<> {header_rows})") if header_rows else None,
filter=SxExpr(filter) if filter else None,
aside=SxExpr(aside) if aside else None,
@@ -345,8 +333,8 @@ def _build_component_ast(__name: str, **kwargs: Any) -> list:
return ast
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
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.
@@ -354,6 +342,8 @@ async def render_to_sx_with_env(__name: str, extra_env: dict, **kwargs: Any) ->
top-level component body is expanded server-side — free variables
from *extra_env* are resolved during expansion rather than being
serialized as unresolved symbols for the client.
**Private** — service code should use ``sx_call()`` or defmacros instead.
"""
from .jinja_bridge import get_component_env, _get_request_context
from .async_eval import async_eval_slot_to_sx
@@ -365,16 +355,15 @@ async def render_to_sx_with_env(__name: str, extra_env: dict, **kwargs: Any) ->
return await async_eval_slot_to_sx(ast, env, ctx)
async def render_to_sx(__name: str, **kwargs: Any) -> str:
async def _render_to_sx(__name: str, **kwargs: Any) -> str:
"""Call a defcomp and get SX wire format back. No SX string literals.
Builds an AST from Python values and evaluates it through the SX
evaluator, which resolves IO primitives and serializes component/tag
calls as SX wire format.
await render_to_sx("card", title="hello", count=3)
# equivalent to old: sx_call("card", title="hello", count=3)
# but values flow as native objects, not serialized strings
**Private** — service code should use ``sx_call()`` or defmacros instead.
Only infrastructure code (helpers.py, layouts.py) should call this.
"""
from .jinja_bridge import get_component_env, _get_request_context
from .async_eval import async_eval_to_sx
@@ -385,6 +374,11 @@ async def render_to_sx(__name: str, **kwargs: Any) -> str:
return await async_eval_to_sx(ast, env, ctx)
# Backwards-compat alias — layout infrastructure still imports this.
# Will be removed once all layouts use register_sx_layout().
render_to_sx_with_env = _render_to_sx_with_env
async def render_to_html(__name: str, **kwargs: Any) -> str:
"""Call a defcomp and get HTML back. No SX string literals.

View File

@@ -2,8 +2,8 @@
Named layout presets for defpage.
Each layout generates header rows for full-page and OOB rendering.
Layouts wrap existing helper functions from ``shared.sx.helpers`` so
defpage can reference them by name (e.g. ``:layout :root``).
Built-in layouts delegate to .sx defcomps via ``register_sx_layout``.
Services register custom layouts via ``register_custom_layout``.
Layouts are registered in ``_LAYOUT_REGISTRY`` and looked up by
``get_layout()`` at request time.
@@ -13,12 +13,6 @@ from __future__ import annotations
from typing import Any, Callable, Awaitable
from .helpers import (
root_header_sx, post_header_sx, post_admin_header_sx,
oob_header_sx,
mobile_menu_sx, mobile_root_nav_sx,
post_mobile_nav_sx, post_admin_mobile_nav_sx,
)
# ---------------------------------------------------------------------------
@@ -83,57 +77,8 @@ def get_layout(name: str) -> Layout | None:
return _LAYOUT_REGISTRY.get(name)
# ---------------------------------------------------------------------------
# Built-in layouts
# ---------------------------------------------------------------------------
async def _post_full(ctx: dict, **kw: Any) -> str:
root_hdr = await root_header_sx(ctx)
post_hdr = await post_header_sx(ctx)
return "(<> " + root_hdr + " " + post_hdr + ")"
async def _post_oob(ctx: dict, **kw: Any) -> str:
post_hdr = await post_header_sx(ctx, oob=True)
# Also replace #post-header-child (empty — clears any nested admin rows)
child_oob = await oob_header_sx("post-header-child", "", "")
return "(<> " + post_hdr + " " + child_oob + ")"
async def _post_admin_full(ctx: dict, **kw: Any) -> str:
slug = ctx.get("post", {}).get("slug", "")
selected = kw.get("selected", "")
root_hdr = await root_header_sx(ctx)
admin_hdr = await post_admin_header_sx(ctx, slug, selected=selected)
post_hdr = await post_header_sx(ctx, child=admin_hdr)
return "(<> " + root_hdr + " " + post_hdr + ")"
async def _post_admin_oob(ctx: dict, **kw: Any) -> str:
slug = ctx.get("post", {}).get("slug", "")
selected = kw.get("selected", "")
post_hdr = await post_header_sx(ctx, oob=True)
admin_hdr = await post_admin_header_sx(ctx, slug, selected=selected)
admin_oob = await oob_header_sx("post-header-child", "post-admin-header-child", admin_hdr)
return "(<> " + post_hdr + " " + admin_oob + ")"
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))
async def _post_admin_mobile(ctx: dict, **kw: Any) -> str:
slug = ctx.get("post", {}).get("slug", "")
selected = kw.get("selected", "")
return mobile_menu_sx(
await post_admin_mobile_nav_sx(ctx, slug, selected),
await post_mobile_nav_sx(ctx),
await mobile_root_nav_sx(ctx),
)
register_layout(Layout("post", _post_full, _post_oob, _post_mobile))
register_layout(Layout("post-admin", _post_admin_full, _post_admin_oob, _post_admin_mobile))
# Built-in post/post-admin layouts are registered below via register_sx_layout,
# after that function is defined.
# ---------------------------------------------------------------------------
@@ -153,27 +98,29 @@ def register_sx_layout(name: str, full_defcomp: str, oob_defcomp: str,
register_sx_layout("account", "account-layout-full",
"account-layout-oob", "account-layout-mobile")
"""
from .helpers import render_to_sx_with_env
from .helpers import _render_to_sx_with_env
async def full_fn(ctx: dict, **kw: Any) -> str:
env = {k.replace("_", "-"): v for k, v in kw.items()}
return await render_to_sx_with_env(full_defcomp, env)
return await _render_to_sx_with_env(full_defcomp, env)
async def oob_fn(ctx: dict, **kw: Any) -> str:
env = {k.replace("_", "-"): v for k, v in kw.items()}
return await render_to_sx_with_env(oob_defcomp, env)
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 = {k.replace("_", "-"): v for k, v in kw.items()}
return await render_to_sx_with_env(mobile_defcomp, env)
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 built-in layouts via .sx defcomps
register_sx_layout("root", "layout-root-full", "layout-root-oob", "layout-root-mobile")
register_sx_layout("post", "layout-post-full", "layout-post-oob", "layout-post-mobile")
register_sx_layout("post-admin", "layout-post-admin-full", "layout-post-admin-oob", "layout-post-admin-mobile")
# ---------------------------------------------------------------------------

View File

@@ -46,6 +46,7 @@ IO_PRIMITIVES: frozenset[str] = frozenset({
"url-for",
"route-prefix",
"root-header-ctx",
"post-header-ctx",
"select-colours",
"account-nav-ctx",
"app-rights",
@@ -482,6 +483,80 @@ async def _io_app_rights(
return getattr(g, "rights", None) or {}
async def _io_post_header_ctx(
args: list[Any], kwargs: dict[str, Any], ctx: RequestContext
) -> dict[str, Any]:
"""``(post-header-ctx)`` → dict with post-level header values.
Reads post data from ``g._defpage_ctx`` (set by per-service page
helpers), fetches container-nav and page cart count. Result is
cached on ``g`` per request.
Returns dict with keys: slug, title, feature-image, link-href,
container-nav, page-cart-count, cart-href, admin-href, is-admin,
is-admin-page, select-colours.
"""
from quart import g, request
cached = getattr(g, "_post_header_ctx", None)
if cached is not None:
return cached
from shared.infrastructure.urls import app_url
from .types import NIL
from .parser import SxExpr
dctx = getattr(g, "_defpage_ctx", None) or {}
post = dctx.get("post") or {}
slug = post.get("slug", "")
if not slug:
result: dict[str, Any] = {"slug": ""}
g._post_header_ctx = result
return result
title = (post.get("title") or "")[:160]
feature_image = post.get("feature_image") or NIL
# Container nav (pre-fetched by page helper into defpage ctx)
raw_nav = dctx.get("container_nav") or ""
container_nav: Any = NIL
nav_str = str(raw_nav).strip()
if nav_str and nav_str.replace("(<>", "").replace(")", "").strip():
if isinstance(raw_nav, SxExpr):
container_nav = raw_nav
else:
container_nav = SxExpr(nav_str)
page_cart_count = dctx.get("page_cart_count", 0) or 0
rights = getattr(g, "rights", None) or {}
is_admin = (
rights.get("admin", False)
if isinstance(rights, dict)
else getattr(rights, "admin", False)
)
is_admin_page = dctx.get("is_admin_section") or "/admin" in request.path
from quart import current_app
select_colours = current_app.jinja_env.globals.get("select_colours", "")
result = {
"slug": slug,
"title": title,
"feature-image": feature_image,
"link-href": app_url("blog", f"/{slug}/"),
"container-nav": container_nav,
"page-cart-count": page_cart_count,
"cart-href": app_url("cart", f"/{slug}/") if page_cart_count else "",
"admin-href": app_url("blog", f"/{slug}/admin/"),
"is-admin": is_admin,
"is-admin-page": is_admin_page or NIL,
"select-colours": select_colours,
}
g._post_header_ctx = result
return result
_IO_HANDLERS: dict[str, Any] = {
"frag": _io_frag,
"query": _io_query,
@@ -499,6 +574,7 @@ _IO_HANDLERS: dict[str, Any] = {
"url-for": _io_url_for,
"route-prefix": _io_route_prefix,
"root-header-ctx": _io_root_header_ctx,
"post-header-ctx": _io_post_header_ctx,
"select-colours": _io_select_colours,
"account-nav-ctx": _io_account_nav_ctx,
"app-rights": _io_app_rights,

View File

@@ -203,6 +203,56 @@
(defcomp ~layout-root-mobile ()
(~root-mobile-auto))
;; Post layout — root + post header
(defcomp ~layout-post-full ()
(<> (~root-header-auto)
(~header-child-sx :inner (~post-header-auto))))
(defcomp ~layout-post-oob ()
(<> (~post-header-auto true)
(~oob-header-sx :parent-id "post-header-child" :row "")))
(defcomp ~layout-post-mobile ()
(let ((__phctx (post-header-ctx))
(__rhctx (root-header-ctx)))
(<>
(when (get __phctx "slug")
(~mobile-menu-section
:label (slice (get __phctx "title") 0 40)
:href (get __phctx "link-href")
:level 1
:items (~post-nav-auto)))
(~root-mobile-auto))))
;; Post-admin layout — root + post header with nested admin row
(defcomp ~layout-post-admin-full (&key selected)
(let ((__admin-hdr (~post-admin-header-auto nil selected)))
(<> (~root-header-auto)
(~header-child-sx
:inner (~post-header-auto nil)))))
(defcomp ~layout-post-admin-oob (&key selected)
(<> (~post-header-auto true)
(~oob-header-sx :parent-id "post-header-child"
:row (~post-admin-header-auto nil selected))))
(defcomp ~layout-post-admin-mobile (&key selected)
(let ((__phctx (post-header-ctx)))
(<>
(when (get __phctx "slug")
(~mobile-menu-section
:label "admin"
:href (get __phctx "admin-href")
:level 2
:items (~post-admin-nav-auto selected)))
(when (get __phctx "slug")
(~mobile-menu-section
:label (slice (get __phctx "title") 0 40)
:href (get __phctx "link-href")
:level 1
:items (~post-nav-auto)))
(~root-mobile-auto))))
(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)
@@ -214,6 +264,85 @@
(defcomp ~clear-oob-div (&key id)
(div :id id :sx-swap-oob "outerHTML"))
;; ---------------------------------------------------------------------------
;; Post-level auto-fetching macros — use (post-header-ctx) IO primitive
;; ---------------------------------------------------------------------------
(defmacro ~post-nav-auto ()
"Post-level nav items: page cart badge + container nav + admin cog."
(quasiquote
(let ((__phctx (post-header-ctx)))
(when (get __phctx "slug")
(<>
(when (> (get __phctx "page-cart-count") 0)
(~page-cart-badge :href (get __phctx "cart-href")
:count (str (get __phctx "page-cart-count"))))
(when (get __phctx "container-nav")
(~container-nav-wrapper :content (get __phctx "container-nav")))
(when (get __phctx "is-admin")
(~admin-cog-button :href (get __phctx "admin-href")
:is-admin-page (get __phctx "is-admin-page"))))))))
(defmacro ~post-header-auto (oob)
"Post-level header row. Reads post data via (post-header-ctx)."
(quasiquote
(let ((__phctx (post-header-ctx)))
(when (get __phctx "slug")
(~menu-row-sx :id "post-row" :level 1
:link-href (get __phctx "link-href")
:link-label-content (~post-label
:feature-image (get __phctx "feature-image")
:title (get __phctx "title"))
:nav (~post-nav-auto)
:child-id "post-header-child"
:oob (unquote oob) :external true)))))
(defmacro ~post-admin-nav-auto (selected)
"Post-admin nav items: calendars, markets, etc."
(quasiquote
(let ((__phctx (post-header-ctx)))
(when (get __phctx "slug")
(let ((__slug (get __phctx "slug"))
(__sc (get __phctx "select-colours")))
(<>
(~nav-link :href (app-url "events" (str "/" __slug "/admin/"))
:label "calendars" :select-colours __sc
:is-selected (when (= (unquote selected) "calendars") "true"))
(~nav-link :href (app-url "market" (str "/" __slug "/admin/"))
:label "markets" :select-colours __sc
:is-selected (when (= (unquote selected) "markets") "true"))
(~nav-link :href (app-url "cart" (str "/" __slug "/admin/payments/"))
:label "payments" :select-colours __sc
:is-selected (when (= (unquote selected) "payments") "true"))
(~nav-link :href (app-url "blog" (str "/" __slug "/admin/entries/"))
:label "entries" :select-colours __sc
:is-selected (when (= (unquote selected) "entries") "true"))
(~nav-link :href (app-url "blog" (str "/" __slug "/admin/data/"))
:label "data" :select-colours __sc
:is-selected (when (= (unquote selected) "data") "true"))
(~nav-link :href (app-url "blog" (str "/" __slug "/admin/preview/"))
:label "preview" :select-colours __sc
:is-selected (when (= (unquote selected) "preview") "true"))
(~nav-link :href (app-url "blog" (str "/" __slug "/admin/edit/"))
:label "edit" :select-colours __sc
:is-selected (when (= (unquote selected) "edit") "true"))
(~nav-link :href (app-url "blog" (str "/" __slug "/admin/settings/"))
:label "settings" :select-colours __sc
:is-selected (when (= (unquote selected) "settings") "true"))))))))
(defmacro ~post-admin-header-auto (oob selected)
"Post-admin header row. Uses (post-header-ctx) for slug + URLs."
(quasiquote
(let ((__phctx (post-header-ctx)))
(when (get __phctx "slug")
(~menu-row-sx :id "post-admin-row" :level 2
:link-href (get __phctx "admin-href")
:link-label-content (~post-admin-label
:selected (unquote selected))
:nav (~post-admin-nav-auto (unquote selected))
:child-id "post-admin-header-child"
:oob (unquote oob))))))
;; ---------------------------------------------------------------------------
;; Shared nav helpers — used by post_header_sx / post_admin_header_sx
;; ---------------------------------------------------------------------------