Files
rose-ash/shared/sx/layouts.py
giles a98354c0f0
All checks were successful
Build and Deploy / build-and-deploy (push) Successful in 4m14s
Fix duplicate headers on HTMX nav, editor content loading, and double mount
- Nest admin header inside post-header-child (layouts.py/helpers.py) so
  full-page DOM matches OOB swap structure, eliminating duplicate headers
- Clear post-header-child on post layout OOB to remove stale admin rows
- Read SX initial content from #sx-content-input instead of
  window.__SX_INITIAL__ to avoid escaping issues through SX pipeline
- Fix client-side SX parser RE_STRING to handle escaped newlines
- Clear root element in SxEditor.mount() to prevent double content on
  HTMX re-mount
- Remove unused ~blog-editor-sx-initial component

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-03 16:27:47 +00:00

174 lines
5.6 KiB
Python

"""
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``).
Layouts are registered in ``_LAYOUT_REGISTRY`` and looked up by
``get_layout()`` at request time.
"""
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, header_child_sx,
mobile_menu_sx, mobile_root_nav_sx,
post_mobile_nav_sx, post_admin_mobile_nav_sx,
)
# ---------------------------------------------------------------------------
# Layout protocol
# ---------------------------------------------------------------------------
class Layout:
"""A named layout that generates header rows for full and OOB rendering."""
__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)
if hasattr(result, "__await__"):
result = await result
return result
async def oob_headers(self, ctx: dict, **kwargs: Any) -> str:
result = self._oob_fn(ctx, **kwargs)
if hasattr(result, "__await__"):
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}>"
# ---------------------------------------------------------------------------
# Registry
# ---------------------------------------------------------------------------
_LAYOUT_REGISTRY: dict[str, Layout] = {}
def register_layout(layout: Layout) -> None:
"""Register a layout preset."""
_LAYOUT_REGISTRY[layout.name] = layout
def get_layout(name: str) -> Layout | None:
"""Look up a layout by name."""
return _LAYOUT_REGISTRY.get(name)
# ---------------------------------------------------------------------------
# Built-in layouts
# ---------------------------------------------------------------------------
def _root_full(ctx: dict, **kw: Any) -> str:
return root_header_sx(ctx)
def _root_oob(ctx: dict, **kw: Any) -> str:
root_hdr = root_header_sx(ctx)
return oob_header_sx("root-header-child", "root-header-child", root_hdr)
def _post_full(ctx: dict, **kw: Any) -> str:
root_hdr = root_header_sx(ctx)
post_hdr = post_header_sx(ctx)
return "(<> " + root_hdr + " " + post_hdr + ")"
def _post_oob(ctx: dict, **kw: Any) -> str:
post_hdr = post_header_sx(ctx, oob=True)
# Also replace #post-header-child (empty — clears any nested admin rows)
child_oob = oob_header_sx("post-header-child", "", "")
return "(<> " + post_hdr + " " + child_oob + ")"
def _post_admin_full(ctx: dict, **kw: Any) -> str:
slug = ctx.get("post", {}).get("slug", "")
selected = kw.get("selected", "")
root_hdr = root_header_sx(ctx)
admin_hdr = post_admin_header_sx(ctx, slug, selected=selected)
post_hdr = post_header_sx(ctx, child=admin_hdr)
return "(<> " + root_hdr + " " + post_hdr + ")"
def _post_admin_oob(ctx: dict, **kw: Any) -> str:
slug = ctx.get("post", {}).get("slug", "")
selected = kw.get("selected", "")
post_hdr = post_header_sx(ctx, oob=True)
admin_hdr = post_admin_header_sx(ctx, slug, selected=selected)
admin_oob = oob_header_sx("post-header-child", "post-admin-header-child", admin_hdr)
return "(<> " + post_hdr + " " + 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))
# ---------------------------------------------------------------------------
# Callable layout — services register custom Python layout functions
# ---------------------------------------------------------------------------
_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]],
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,
mobile_fn=my_mobile_menu)
"""
register_layout(Layout(name, full_fn, oob_fn, mobile_fn))