"""
Blog service s-expression page components.

Renders home, blog index (posts/pages), new post/page, post detail,
post admin, post data, post entries, post edit, post settings,
settings home, cache, snippets, menu items, and tag groups pages.
Called from route handlers in place of ``render_template()``.
"""
from __future__ import annotations

import os
from typing import Any
from markupsafe import escape

from shared.sx.jinja_bridge import load_service_components
from shared.sx.parser import serialize as sx_serialize
from shared.sx.helpers import (
    SxExpr, sx_call,
    call_url, get_asset_url,
    root_header_sx,
    post_header_sx,
    post_admin_header_sx,
    header_child_sx,
    oob_header_sx,
    oob_page_sx,
    search_mobile_sx,
    search_desktop_sx,
    full_page_sx,
    mobile_menu_sx,
    mobile_root_nav_sx,
    post_mobile_nav_sx,
    post_admin_mobile_nav_sx,
)

# Load blog service .sx component definitions + handler definitions
load_service_components(os.path.dirname(os.path.dirname(__file__)), service_name="blog")


def _ctx_csrf(ctx: dict) -> str:
    """Get CSRF token from context, handling Jinja callable globals."""
    val = ctx.get("csrf_token", "")
    return val() if callable(val) else val


# ---------------------------------------------------------------------------
# OOB header helper — delegates to shared
# ---------------------------------------------------------------------------

_oob_header_sx = oob_header_sx


# ---------------------------------------------------------------------------
# Blog header (root-header-child -> blog-header-child)
# ---------------------------------------------------------------------------

def _blog_header_sx(ctx: dict, *, oob: bool = False) -> str:
    """Blog header row — empty child of root."""
    return sx_call("menu-row-sx",
        id="blog-row", level=1,
        link_label_content=SxExpr("(div)"),
        child_id="blog-header-child", oob=oob,
    )

# ---------------------------------------------------------------------------
# Post header helpers — thin wrapper over shared post_header_sx
# ---------------------------------------------------------------------------

def _post_header_sx(ctx: dict, *, oob: bool = False) -> str:
    """Build the post-level header row as sx — delegates to shared helper."""
    return post_header_sx(ctx, oob=oob)


# ---------------------------------------------------------------------------
# Post admin header
# ---------------------------------------------------------------------------

def _post_admin_header_sx(ctx: dict, *, oob: bool = False, selected: str = "") -> str:
    """Post admin header row as sx — delegates to shared helper."""
    slug = (ctx.get("post") or {}).get("slug", "")
    return post_admin_header_sx(ctx, slug, oob=oob, selected=selected)


def _post_admin_mobile_menu(ctx: dict, selected: str = "") -> str:
    """Full mobile menu for any post admin page (admin + post + root)."""
    slug = (ctx.get("post") or {}).get("slug", "")
    return mobile_menu_sx(
        post_admin_mobile_nav_sx(ctx, slug, selected),
        post_mobile_nav_sx(ctx),
        mobile_root_nav_sx(ctx),
    )


# ---------------------------------------------------------------------------
# Settings header (root-header-child -> root-settings-header-child)
# ---------------------------------------------------------------------------

def _settings_header_sx(ctx: dict, *, oob: bool = False) -> str:
    """Settings header row with admin icon and nav links (sx)."""
    from quart import url_for as qurl

    settings_href = qurl("settings.defpage_settings_home")
    label_sx = sx_call("blog-admin-label")
    nav_sx = _settings_nav_sx(ctx)

    return sx_call("menu-row-sx",
        id="root-settings-row", level=1,
        link_href=settings_href,
        link_label_content=SxExpr(label_sx),
        nav=SxExpr(nav_sx) if nav_sx else None,
        child_id="root-settings-header-child", oob=oob,
    )



def _settings_nav_sx(ctx: dict) -> str:
    """Settings desktop nav as sx."""
    from quart import url_for as qurl

    select_colours = ctx.get("select_colours", "")
    parts = []

    for endpoint, icon, label in [
        ("menu_items.defpage_menu_items_page", "bars", "Menu Items"),
        ("snippets.defpage_snippets_page", "puzzle-piece", "Snippets"),
        ("blog.tag_groups_admin.defpage_tag_groups_page", "tags", "Tag Groups"),
        ("settings.defpage_cache_page", "refresh", "Cache"),
    ]:
        href = qurl(endpoint)
        parts.append(sx_call("nav-link",
            href=href, icon=f"fa fa-{icon}", label=label,
            select_colours=select_colours,
        ))

    return "(<> " + " ".join(parts) + ")" if parts else ""



# ---------------------------------------------------------------------------
# Sub-settings headers (root-settings-header-child -> X-header-child)
# ---------------------------------------------------------------------------

def _sub_settings_header_sx(row_id: str, child_id: str, href: str,
                               icon: str, label: str, ctx: dict,
                               *, oob: bool = False, nav_sx: str = "") -> str:
    """Generic sub-settings header row as sx."""
    label_sx = sx_call("blog-sub-settings-label",
        icon=f"fa fa-{icon}", label=label,
    )

    return sx_call("menu-row-sx",
        id=row_id, level=2,
        link_href=href,
        link_label_content=SxExpr(label_sx),
        nav=SxExpr(nav_sx) if nav_sx else None,
        child_id=child_id, oob=oob,
    )



# ---------------------------------------------------------------------------
# Blog index main panel helpers
# ---------------------------------------------------------------------------



def _blog_sentinel_sx(ctx: dict) -> str:
    """Infinite scroll sentinels as sx calls (for wire format)."""
    from shared.sx.helpers import sx_call
    page = ctx.get("page", 1)
    total_pages = ctx.get("total_pages", 1)
    if isinstance(total_pages, str):
        total_pages = int(total_pages)

    if page >= total_pages:
        return sx_call("end-of-results")

    current_local_href = ctx.get("current_local_href", "/index")
    next_url = f"{current_local_href}?page={page + 1}"

    mobile_hs = (
        "init if not me.dataset.retryMs then set me.dataset.retryMs to 1000 end"
        " if window.matchMedia('(min-width: 768px)').matches then set @hx-disabled to '' end"
        " on resize from window if window.matchMedia('(min-width: 768px)').matches then set @hx-disabled to '' else remove @hx-disabled end"
        " on htmx:beforeRequest if window.matchMedia('(min-width: 768px)').matches then halt end"
        " add .hidden to .js-neterr in me remove .hidden from .js-loading in me remove .opacity-100 from me add .opacity-0 to me"
        " def backoff() set ms to me.dataset.retryMs if ms > 30000 then set ms to 30000 end"
        " add .hidden to .js-loading in me remove .hidden from .js-neterr in me remove .opacity-0 from me add .opacity-100 to me"
        " wait ms ms trigger sentinelmobile:retry set ms to ms * 2 if ms > 30000 then set ms to 30000 end set me.dataset.retryMs to ms end"
        " on htmx:sendError call backoff() on htmx:responseError call backoff() on htmx:timeout call backoff()"
    )
    desktop_hs = (
        "init if not me.dataset.retryMs then set me.dataset.retryMs to 1000 end"
        " on htmx:beforeRequest(event) add .hidden to .js-neterr in me remove .hidden from .js-loading in me"
        " remove .opacity-100 from me add .opacity-0 to me"
        " set trig to null if event.detail and event.detail.triggeringEvent then set trig to event.detail.triggeringEvent end"
        " if trig and trig.type is 'intersect' set scroller to the closest .js-grid-viewport"
        " if scroller is null then halt end if scroller.scrollTop < 20 then halt end end"
        " def backoff() set ms to me.dataset.retryMs if ms > 30000 then set ms to 30000 end"
        " add .hidden to .js-loading in me remove .hidden from .js-neterr in me remove .opacity-0 from me add .opacity-100 to me"
        " wait ms ms trigger sentinel:retry set ms to ms * 2 if ms > 30000 then set ms to 30000 end set me.dataset.retryMs to ms end"
        " on htmx:sendError call backoff() on htmx:responseError call backoff() on htmx:timeout call backoff()"
    )

    return (
        sx_call("sentinel-mobile", id=f"sentinel-{page}-m", next_url=next_url, hyperscript=mobile_hs)
        + " "
        + sx_call("sentinel-desktop", id=f"sentinel-{page}-d", next_url=next_url, hyperscript=desktop_hs)
    )


def _blog_cards_sx(ctx: dict) -> str:
    """S-expression wire format for blog cards (client renders)."""
    posts = ctx.get("posts") or []
    view = ctx.get("view")
    post_dicts = [_blog_card_data(p, ctx) for p in posts]
    sentinel = SxExpr(_blog_sentinel_sx(ctx))
    return sx_call("blog-cards-from-data", posts=post_dicts, view=view, sentinel=sentinel)


def _format_ts(dt) -> str:
    if not dt:
        return ""
    return dt.strftime("%-d %b %Y at %H:%M") if hasattr(dt, "strftime") else str(dt)


def _tag_data(tags: list) -> list[dict]:
    """Extract pure data for tags."""
    result = []
    for t in tags:
        name = t.get("name") or getattr(t, "name", "")
        fi = t.get("feature_image") or getattr(t, "feature_image", None)
        initial = (name[:1]) if name else ""
        result.append({"name": name, "src": fi or "", "initial": initial})
    return result


def _author_data(authors: list) -> list[dict]:
    """Extract pure data for authors."""
    result = []
    for a in authors:
        name = a.get("name") or getattr(a, "name", "")
        img = a.get("profile_image") or getattr(a, "profile_image", None)
        result.append({"name": name, "image": img or ""})
    return result


def _blog_card_data(post: dict, ctx: dict) -> dict:
    """Serialize a blog post to a display-data dict for sx rendering."""
    from quart import g

    slug = post.get("slug", "")
    href = call_url(ctx, "blog_url", f"/{slug}/")
    hx_select = ctx.get("hx_select_search", "#main-panel")
    user = getattr(g, "user", None)

    status = post.get("status", "published")
    is_draft = status == "draft"

    if is_draft:
        status_timestamp = _format_ts(post.get("updated_at"))
    else:
        status_timestamp = _format_ts(post.get("published_at"))

    fi = post.get("feature_image")
    excerpt = post.get("custom_excerpt") or post.get("excerpt", "")
    card_widgets = ctx.get("card_widgets_html") or {}
    widget = card_widgets.get(str(post.get("id", "")), "")

    tags = _tag_data(post.get("tags") or [])
    authors = _author_data(post.get("authors") or [])

    d: dict[str, Any] = dict(
        slug=slug, href=href, hx_select=hx_select,
        title=post.get("title", ""),
        feature_image=fi, excerpt=excerpt,
        is_draft=is_draft,
        publish_requested=post.get("publish_requested", False) if is_draft else False,
        status_timestamp=status_timestamp,
        has_like=bool(user),
    )

    if user:
        d["liked"] = post.get("is_liked", False)
        d["like_url"] = call_url(ctx, "blog_url", f"/{slug}/like/toggle/")
        d["csrf_token"] = _ctx_csrf(ctx)

    if tags:
        d["tags"] = tags
    if authors:
        d["authors"] = authors
    if widget:
        d["widget"] = widget

    return d


def _at_bar_sx(post: dict, ctx: dict) -> str:
    """Tags + authors bar below a card as sx."""
    tags = post.get("tags") or []
    authors = post.get("authors") or []
    if not tags and not authors:
        return ""

    tag_data = [
        {"src": t.get("feature_image") or getattr(t, "feature_image", None),
         "name": t.get("name") or getattr(t, "name", ""),
         "initial": (t.get("name") or getattr(t, "name", ""))[:1]}
        for t in tags
    ] if tags else []

    author_data = [
        {"image": a.get("profile_image") or getattr(a, "profile_image", None),
         "name": a.get("name") or getattr(a, "name", "")}
        for a in authors
    ] if authors else []

    return sx_call("blog-at-bar", tags=tag_data, authors=author_data)



def _page_card_data(page: dict, ctx: dict) -> dict:
    """Serialize a page to a display-data dict for sx rendering."""
    slug = page.get("slug", "")
    href = call_url(ctx, "blog_url", f"/{slug}/")
    hx_select = ctx.get("hx_select_search", "#main-panel")
    features = page.get("features") or {}
    return dict(
        href=href, hx_select=hx_select, title=page.get("title", ""),
        has_calendar=features.get("calendar", False),
        has_market=features.get("market", False),
        pub_timestamp=_format_ts(page.get("published_at")),
        feature_image=page.get("feature_image"),
        excerpt=page.get("custom_excerpt") or page.get("excerpt", ""),
    )


def _page_cards_sx(ctx: dict) -> str:
    """Render page cards with sentinel (sx)."""
    pages = ctx.get("pages") or ctx.get("posts") or []
    page_num = ctx.get("page", 1)
    total_pages = ctx.get("total_pages", 1)
    if isinstance(total_pages, str):
        total_pages = int(total_pages)

    page_dicts = [_page_card_data(pg, ctx) for pg in pages]

    if page_num < total_pages:
        current_local_href = ctx.get("current_local_href", "/index?type=pages")
        next_url = f"{current_local_href}&page={page_num + 1}" if "?" in current_local_href else f"{current_local_href}?page={page_num + 1}"
        sentinel = SxExpr(sx_call("sentinel-simple",
            id=f"sentinel-{page_num}-d", next_url=next_url))
    elif pages:
        sentinel = SxExpr(sx_call("end-of-results"))
    elif not pages:
        sentinel = SxExpr(sx_call("blog-no-pages"))
    else:
        sentinel = None

    return sx_call("page-cards-from-data", pages=page_dicts, sentinel=sentinel)


def _view_toggle_sx(ctx: dict) -> str:
    """View toggle bar (list/tile) for desktop."""
    view = ctx.get("view")
    current_local_href = ctx.get("current_local_href", "/index")
    hx_select = ctx.get("hx_select_search", "#main-panel")

    list_cls = "bg-stone-200 text-stone-800" if view != "tile" else "text-stone-400 hover:text-stone-600"
    tile_cls = "bg-stone-200 text-stone-800" if view == "tile" else "text-stone-400 hover:text-stone-600"

    list_href = f"{current_local_href}"
    tile_href = f"{current_local_href}{'&' if '?' in current_local_href else '?'}view=tile"

    list_svg_sx = sx_call("list-svg")
    tile_svg_sx = sx_call("tile-svg")

    return sx_call("view-toggle",
        list_href=list_href, tile_href=tile_href, hx_select=hx_select,
        list_cls=list_cls, tile_cls=tile_cls, storage_key="blog_view",
        list_svg=SxExpr(list_svg_sx), tile_svg=SxExpr(tile_svg_sx),
    )


def _content_type_tabs_sx(ctx: dict) -> str:
    """Posts/Pages tabs."""
    content_type = ctx.get("content_type", "posts")
    hx_select = ctx.get("hx_select_search", "#main-panel")

    posts_href = call_url(ctx, "blog_url", "/index")
    pages_href = f"{posts_href}?type=pages"

    posts_cls = "bg-stone-700 text-white" if content_type != "pages" else "bg-stone-100 text-stone-600 hover:bg-stone-200"
    pages_cls = "bg-stone-700 text-white" if content_type == "pages" else "bg-stone-100 text-stone-600 hover:bg-stone-200"

    return sx_call("blog-content-type-tabs",
        posts_href=posts_href, pages_href=pages_href, hx_select=hx_select,
        posts_cls=posts_cls, pages_cls=pages_cls,
    )


def _blog_main_panel_sx(ctx: dict) -> str:
    """Blog index main panel with tabs, toggle, and cards."""
    content_type = ctx.get("content_type", "posts")
    view = ctx.get("view")

    tabs = _content_type_tabs_sx(ctx)

    if content_type == "pages":
        cards = _page_cards_sx(ctx)
        return sx_call("blog-main-panel-pages",
            tabs=SxExpr(tabs), cards=SxExpr(cards),
        )
    else:
        toggle = _view_toggle_sx(ctx)
        grid_cls = "max-w-full px-3 py-3 grid grid-cols-1 sm:grid-cols-2 md:grid-cols-3 gap-4" if view == "tile" else "max-w-full px-3 py-3 space-y-3"
        cards = _blog_cards_sx(ctx)
        return sx_call("blog-main-panel-posts",
            tabs=SxExpr(tabs), toggle=SxExpr(toggle), grid_cls=grid_cls,
            cards=SxExpr(cards),
        )


# ---------------------------------------------------------------------------
# Desktop aside (filter sidebar)
# ---------------------------------------------------------------------------

def _blog_aside_sx(ctx: dict) -> str:
    """Desktop aside with search, action buttons, and filters."""
    sd = search_desktop_sx(ctx)
    ab = _action_buttons_sx(ctx)
    tgf = _tag_groups_filter_sx(ctx)
    af = _authors_filter_sx(ctx)
    return sx_call("blog-aside",
        search=SxExpr(sd), action_buttons=SxExpr(ab),
        tag_groups_filter=SxExpr(tgf), authors_filter=SxExpr(af),
    )


def _blog_filter_sx(ctx: dict) -> str:
    """Mobile filter (details/summary)."""
    # Mobile filter summary tags
    summary_parts = []
    tg_summary = _tag_groups_filter_summary_sx(ctx)
    au_summary = _authors_filter_summary_sx(ctx)
    if tg_summary:
        summary_parts.append(tg_summary)
    if au_summary:
        summary_parts.append(au_summary)

    search_sx = search_mobile_sx(ctx)
    if summary_parts:
        filter_content = "(<> " + search_sx + " " + " ".join(summary_parts) + ")"
    else:
        filter_content = search_sx

    action_buttons = _action_buttons_sx(ctx)
    tgf = _tag_groups_filter_sx(ctx)
    af = _authors_filter_sx(ctx)
    filter_details = "(<> " + tgf + " " + af + ")"

    return sx_call("mobile-filter",
        filter_summary=SxExpr(filter_content),
        action_buttons=SxExpr(action_buttons),
        filter_details=SxExpr(filter_details),
    )


def _action_buttons_sx(ctx: dict) -> str:
    """New Post/Page + Drafts toggle buttons (sx)."""
    from quart import g

    rights = ctx.get("rights") or {}
    has_admin = rights.get("admin") if isinstance(rights, dict) else getattr(rights, "admin", False)
    user = getattr(g, "user", None)
    hx_select = ctx.get("hx_select_search", "#main-panel")
    drafts = ctx.get("drafts")
    draft_count = ctx.get("draft_count", 0)
    current_local_href = ctx.get("current_local_href", "/index")

    parts = []

    if has_admin:
        new_href = call_url(ctx, "blog_url", "/new/")
        parts.append(sx_call("blog-action-button",
            href=new_href, hx_select=hx_select,
            btn_class="px-3 py-1 rounded bg-stone-700 text-white text-sm hover:bg-stone-800 transition-colors",
            title="New Post", icon_class="fa fa-plus mr-1", label=" New Post",
        ))
        new_page_href = call_url(ctx, "blog_url", "/new-page/")
        parts.append(sx_call("blog-action-button",
            href=new_page_href, hx_select=hx_select,
            btn_class="px-3 py-1 rounded bg-blue-600 text-white text-sm hover:bg-blue-700 transition-colors",
            title="New Page", icon_class="fa fa-plus mr-1", label=" New Page",
        ))

    if user and (draft_count or drafts):
        if drafts:
            off_href = f"{current_local_href}"
            parts.append(sx_call("blog-drafts-button",
                href=off_href, hx_select=hx_select,
                btn_class="px-3 py-1 rounded bg-stone-700 text-white text-sm hover:bg-stone-800 transition-colors",
                title="Hide Drafts", label=" Drafts ", draft_count=str(draft_count),
            ))
        else:
            on_href = f"{current_local_href}{'&' if '?' in current_local_href else '?'}drafts=1"
            parts.append(sx_call("blog-drafts-button-amber",
                href=on_href, hx_select=hx_select,
                btn_class="px-3 py-1 rounded bg-amber-600 text-white text-sm hover:bg-amber-700 transition-colors",
                title="Show Drafts", label=" Drafts ", draft_count=str(draft_count),
            ))

    inner = "(<> " + " ".join(parts) + ")" if parts else ""
    return sx_call("blog-action-buttons-wrapper",
        inner=SxExpr(inner) if inner else None,
    )


def _tag_groups_filter_sx(ctx: dict) -> str:
    """Tag group filter bar as sx."""
    tag_groups = ctx.get("tag_groups") or []
    selected_groups = list(ctx.get("selected_groups") or ())
    hx_select = ctx.get("hx_select_search", "#main-panel")

    group_dicts = []
    for group in tag_groups:
        g_slug = getattr(group, "slug", "") if hasattr(group, "slug") else group.get("slug", "")
        g_name = getattr(group, "name", "") if hasattr(group, "name") else group.get("name", "")
        g_fi = getattr(group, "feature_image", None) if hasattr(group, "feature_image") else group.get("feature_image")
        g_colour = getattr(group, "colour", None) if hasattr(group, "colour") else group.get("colour")
        g_count = getattr(group, "post_count", 0) if hasattr(group, "post_count") else group.get("post_count", 0)

        if g_count <= 0 and g_slug not in selected_groups:
            continue

        style = (f"background-color: {g_colour}; color: white;" if g_colour
                 else "background-color: #e7e5e4; color: #57534e;") if not g_fi else None
        group_dicts.append(dict(
            slug=g_slug, name=g_name, feature_image=g_fi,
            style=style, initial=g_name[:1], count=str(g_count),
        ))

    return sx_call("blog-tag-groups-filter-from-data",
        groups=group_dicts, selected_groups=selected_groups, hx_select=hx_select)


def _authors_filter_sx(ctx: dict) -> str:
    """Author filter bar as sx."""
    authors = ctx.get("authors") or []
    selected_authors = list(ctx.get("selected_authors") or ())
    hx_select = ctx.get("hx_select_search", "#main-panel")

    author_dicts = []
    for author in authors:
        a_slug = getattr(author, "slug", "") if hasattr(author, "slug") else author.get("slug", "")
        a_name = getattr(author, "name", "") if hasattr(author, "name") else author.get("name", "")
        a_img = getattr(author, "profile_image", None) if hasattr(author, "profile_image") else author.get("profile_image")
        a_count = getattr(author, "published_post_count", 0) if hasattr(author, "published_post_count") else author.get("published_post_count", 0)
        author_dicts.append(dict(
            slug=a_slug, name=a_name, profile_image=a_img, count=str(a_count),
        ))

    return sx_call("blog-authors-filter-from-data",
        authors=author_dicts, selected_authors=selected_authors, hx_select=hx_select)


def _tag_groups_filter_summary_sx(ctx: dict) -> str:
    """Mobile filter summary for tag groups (sx)."""
    selected_groups = ctx.get("selected_groups") or ()
    tag_groups = ctx.get("tag_groups") or []
    if not selected_groups:
        return ""
    names = []
    for g in tag_groups:
        g_slug = getattr(g, "slug", "") if hasattr(g, "slug") else g.get("slug", "")
        g_name = getattr(g, "name", "") if hasattr(g, "name") else g.get("name", "")
        if g_slug in selected_groups:
            names.append(g_name)
    if not names:
        return ""
    return sx_call("blog-filter-summary", text=", ".join(names))



def _authors_filter_summary_sx(ctx: dict) -> str:
    """Mobile filter summary for authors (sx)."""
    selected_authors = ctx.get("selected_authors") or ()
    authors = ctx.get("authors") or []
    if not selected_authors:
        return ""
    names = []
    for a in authors:
        a_slug = getattr(a, "slug", "") if hasattr(a, "slug") else a.get("slug", "")
        a_name = getattr(a, "name", "") if hasattr(a, "name") else a.get("name", "")
        if a_slug in selected_authors:
            names.append(a_name)
    if not names:
        return ""
    return sx_call("blog-filter-summary", text=", ".join(names))



# ---------------------------------------------------------------------------
# Post detail main panel
# ---------------------------------------------------------------------------

def _post_main_panel_sx(ctx: dict) -> str:
    """Post/page article content."""
    from quart import g, url_for as qurl

    post = ctx.get("post") or {}
    slug = post.get("slug", "")
    user = getattr(g, "user", None)
    rights = ctx.get("rights") or {}
    is_admin = rights.get("admin") if isinstance(rights, dict) else getattr(rights, "admin", False)
    hx_select = ctx.get("hx_select_search", "#main-panel")

    # Draft indicator
    draft_sx = ""
    if post.get("status") == "draft":
        edit_sx = ""
        if is_admin or (user and post.get("user_id") == getattr(user, "id", None)):
            edit_href = qurl("blog.post.admin.defpage_post_edit", slug=slug)
            edit_sx = sx_call("blog-detail-edit-link",
                href=edit_href, hx_select=hx_select,
            )
        draft_sx = sx_call("blog-detail-draft",
            publish_requested=post.get("publish_requested"),
            edit=SxExpr(edit_sx) if edit_sx else None,
        )

    # Blog post chrome (not for pages)
    chrome_sx = ""
    if not post.get("is_page"):
        like_sx = ""
        if user:
            liked = post.get("is_liked", False)
            like_url = call_url(ctx, "blog_url", f"/{slug}/like/toggle/")
            like_sx = sx_call("blog-detail-like",
                like_url=like_url,
                hx_headers=f'{{"X-CSRFToken": "{_ctx_csrf(ctx)}"}}',
                heart="\u2764\ufe0f" if liked else "\U0001f90d",
            )

        excerpt_sx = ""
        if post.get("custom_excerpt"):
            excerpt_sx = sx_call("blog-detail-excerpt",
                excerpt=post["custom_excerpt"],
            )

        at_bar = _at_bar_sx(post, ctx)
        chrome_sx = sx_call("blog-detail-chrome",
            like=SxExpr(like_sx) if like_sx else None,
            excerpt=SxExpr(excerpt_sx) if excerpt_sx else None,
            at_bar=SxExpr(at_bar) if at_bar else None,
        )

    fi = post.get("feature_image")
    html_content = post.get("html", "")
    sx_content = post.get("sx_content", "")

    return sx_call("blog-detail-main",
        draft=SxExpr(draft_sx) if draft_sx else None,
        chrome=SxExpr(chrome_sx) if chrome_sx else None,
        feature_image=fi, html_content=html_content,
        sx_content=SxExpr(sx_content) if sx_content else None,
    )


def _post_meta_sx(ctx: dict) -> str:
    """Post SEO meta tags as sx (auto-hoisted to <head> by sx.js)."""
    post = ctx.get("post") or {}
    base_title = ctx.get("base_title", "")

    is_public = post.get("visibility") == "public"
    is_published = post.get("status") == "published"
    email_only = post.get("email_only", False)
    robots = "index,follow" if (is_public and is_published and not email_only) else "noindex,nofollow"

    # Description
    desc = (post.get("meta_description") or post.get("og_description") or
            post.get("twitter_description") or post.get("custom_excerpt") or
            post.get("excerpt") or "")
    if not desc and post.get("html"):
        import re
        desc = re.sub(r'<[^>]+>', '', post["html"])
    desc = desc.replace("\n", " ").replace("\r", " ").strip()[:160]

    # Image
    image = (post.get("og_image") or post.get("twitter_image") or post.get("feature_image") or "")

    # Canonical
    from quart import request as req
    canonical = post.get("canonical_url") or (req.url if req else "")

    post_title = post.get("meta_title") or post.get("title") or ""
    page_title = f"{post_title} \u2014 {base_title}" if post_title else base_title
    og_title = post.get("og_title") or page_title
    tw_title = post.get("twitter_title") or page_title
    is_article = not post.get("is_page")

    return sx_call("blog-meta",
        robots=robots, page_title=page_title, desc=desc, canonical=canonical,
        og_type="article" if is_article else "website",
        og_title=og_title, image=image,
        twitter_card="summary_large_image" if image else "summary",
        twitter_title=tw_title,
    )


# ---------------------------------------------------------------------------
# Home page (Ghost "home" page)
# ---------------------------------------------------------------------------

def _home_main_panel_sx(ctx: dict) -> str:
    """Home page content — renders the Ghost page HTML or sx_content."""
    post = ctx.get("post") or {}
    html = post.get("html", "")
    sx_content = post.get("sx_content", "")
    return sx_call("blog-home-main",
                   html_content=html,
                   sx_content=SxExpr(sx_content) if sx_content else None)


# ---------------------------------------------------------------------------
# Post admin - empty main panel
# ---------------------------------------------------------------------------

def _post_admin_main_panel_sx(ctx: dict) -> str:
    return '(div :class "pb-8")'


# ---------------------------------------------------------------------------
# Settings main panels
# ---------------------------------------------------------------------------

def _settings_main_panel_sx(ctx: dict) -> str:
    return '(div :class "max-w-2xl mx-auto px-4 py-6")'


def _cache_main_panel_sx(ctx: dict) -> str:
    from quart import url_for as qurl

    csrf = _ctx_csrf(ctx)
    clear_url = qurl("settings.cache_clear")
    return sx_call("blog-cache-panel", clear_url=clear_url, csrf=csrf)


# ---------------------------------------------------------------------------
# Snippets main panel
# ---------------------------------------------------------------------------

def _snippets_main_panel_sx(ctx: dict) -> str:
    sl = _snippets_list_sx(ctx)
    return sx_call("blog-snippets-panel", list=SxExpr(sl))


def _snippets_list_sx(ctx: dict) -> str:
    """Snippets list with visibility badges and delete buttons."""
    from quart import url_for as qurl, g

    snippets = ctx.get("snippets") or []
    is_admin = ctx.get("is_admin", False)
    csrf = _ctx_csrf(ctx)
    user = getattr(g, "user", None)
    user_id = getattr(user, "id", None)

    if not snippets:
        return sx_call("empty-state", icon="fa fa-puzzle-piece", message="No snippets yet. Create one from the blog editor.")

    badge_colours = {
        "private": "bg-stone-200 text-stone-700",
        "shared": "bg-blue-100 text-blue-700",
        "admin": "bg-amber-100 text-amber-700",
    }

    snippet_dicts = []
    for s in snippets:
        s_id = getattr(s, "id", None) or s.get("id")
        s_name = getattr(s, "name", "") if hasattr(s, "name") else s.get("name", "")
        s_uid = getattr(s, "user_id", None) if hasattr(s, "user_id") else s.get("user_id")
        s_vis = getattr(s, "visibility", "private") if hasattr(s, "visibility") else s.get("visibility", "private")
        snippet_dicts.append(dict(
            id=s_id, name=s_name, user_id=s_uid, visibility=s_vis,
            patch_url=qurl("snippets.patch_visibility", snippet_id=s_id),
            delete_url=qurl("snippets.delete_snippet", snippet_id=s_id),
        ))

    return sx_call("blog-snippets-from-data",
        snippets=snippet_dicts, user_id=user_id, is_admin=is_admin,
        csrf=csrf, badge_colours=badge_colours)


# ---------------------------------------------------------------------------
# Menu items main panel
# ---------------------------------------------------------------------------

def _menu_items_main_panel_sx(ctx: dict) -> str:
    from quart import url_for as qurl

    new_url = qurl("menu_items.new_menu_item")
    ml = _menu_items_list_sx(ctx)
    return sx_call("blog-menu-items-panel", new_url=new_url, list=SxExpr(ml))


def _menu_items_list_sx(ctx: dict) -> str:
    from quart import url_for as qurl

    menu_items = ctx.get("menu_items") or []
    csrf = _ctx_csrf(ctx)

    if not menu_items:
        return sx_call("empty-state", icon="fa fa-inbox", message="No menu items yet. Add one to get started!")

    item_dicts = []
    for item in menu_items:
        i_id = getattr(item, "id", None) or item.get("id")
        label = getattr(item, "label", "") if hasattr(item, "label") else item.get("label", "")
        slug = getattr(item, "slug", "") if hasattr(item, "slug") else item.get("slug", "")
        fi = getattr(item, "feature_image", None) if hasattr(item, "feature_image") else item.get("feature_image")
        sort = getattr(item, "sort_order", 0) if hasattr(item, "sort_order") else item.get("sort_order", 0)
        item_dicts.append(dict(
            label=label, slug=slug, feature_image=fi,
            sort_order=str(sort),
            edit_url=qurl("menu_items.edit_menu_item", item_id=i_id),
            delete_url=qurl("menu_items.delete_menu_item_route", item_id=i_id),
        ))

    return sx_call("blog-menu-items-from-data", items=item_dicts, csrf=csrf)


# ---------------------------------------------------------------------------
# Tag groups main panel
# ---------------------------------------------------------------------------

def _tag_groups_main_panel_sx(ctx: dict) -> str:
    from quart import url_for as qurl

    groups = ctx.get("groups") or []
    unassigned_tags = ctx.get("unassigned_tags") or []
    csrf = _ctx_csrf(ctx)
    create_url = qurl("blog.tag_groups_admin.create")

    group_dicts = []
    for group in groups:
        g_id = getattr(group, "id", None) or group.get("id")
        g_name = getattr(group, "name", "") if hasattr(group, "name") else group.get("name", "")
        g_slug = getattr(group, "slug", "") if hasattr(group, "slug") else group.get("slug", "")
        g_fi = getattr(group, "feature_image", None) if hasattr(group, "feature_image") else group.get("feature_image")
        g_colour = getattr(group, "colour", None) if hasattr(group, "colour") else group.get("colour")
        g_sort = getattr(group, "sort_order", 0) if hasattr(group, "sort_order") else group.get("sort_order", 0)
        style = (f"background-color: {g_colour}; color: white;" if g_colour
                 else "background-color: #e7e5e4; color: #57534e;") if not g_fi else None
        group_dicts.append(dict(
            name=g_name, slug=g_slug, feature_image=g_fi,
            style=style, initial=g_name[:1], sort_order=str(g_sort),
            edit_href=qurl("blog.tag_groups_admin.defpage_tag_group_edit", id=g_id),
        ))

    unassigned_dicts = []
    for tag in unassigned_tags:
        t_name = getattr(tag, "name", "") if hasattr(tag, "name") else tag.get("name", "")
        unassigned_dicts.append(dict(name=t_name))

    return sx_call("blog-tag-groups-from-data",
        groups=group_dicts, unassigned_tags=unassigned_dicts,
        csrf=csrf, create_url=create_url)


def _tag_groups_edit_main_panel_sx(ctx: dict) -> str:
    from quart import url_for as qurl

    group = ctx.get("group")
    all_tags = ctx.get("all_tags") or []
    assigned_tag_ids = ctx.get("assigned_tag_ids") or set()
    csrf = _ctx_csrf(ctx)

    g_id = getattr(group, "id", None) or group.get("id") if group else None
    g_name = getattr(group, "name", "") if hasattr(group, "name") else (group.get("name", "") if group else "")
    g_colour = getattr(group, "colour", "") if hasattr(group, "colour") else (group.get("colour", "") if group else "")
    g_sort = getattr(group, "sort_order", 0) if hasattr(group, "sort_order") else (group.get("sort_order", 0) if group else 0)
    g_fi = getattr(group, "feature_image", "") if hasattr(group, "feature_image") else (group.get("feature_image", "") if group else "")

    save_url = qurl("blog.tag_groups_admin.save", id=g_id)
    del_url = qurl("blog.tag_groups_admin.delete_group", id=g_id)

    # Tag checkboxes — pass data dicts to defcomp
    tag_dicts = []
    for tag in all_tags:
        t_id = getattr(tag, "id", None) or tag.get("id")
        t_name = getattr(tag, "name", "") if hasattr(tag, "name") else tag.get("name", "")
        t_fi = getattr(tag, "feature_image", None) if hasattr(tag, "feature_image") else tag.get("feature_image")
        tag_dicts.append(dict(
            tag_id=str(t_id), name=t_name, feature_image=t_fi,
            checked=t_id in assigned_tag_ids,
        ))

    tags_sx = sx_call("blog-tag-checkboxes-from-data", tags=tag_dicts)

    edit_form = sx_call("blog-tag-group-edit-form",
        save_url=save_url, csrf=csrf,
        name=g_name, colour=g_colour or "", sort_order=str(g_sort),
        feature_image=g_fi or "",
        tags=SxExpr(tags_sx),
    )

    del_form = sx_call("blog-tag-group-delete-form",
        delete_url=del_url, csrf=csrf,
    )

    return sx_call("blog-tag-group-edit-main",
        edit_form=SxExpr(edit_form), delete_form=SxExpr(del_form),
    )


# ---------------------------------------------------------------------------
# New post/page main panel — left as render_template (uses Koenig editor JS)
# Post edit main panel — left as render_template (uses Koenig editor JS)
# Post settings main panel — left as render_template (complex form macros)
# Post entries main panel — left as render_template (calendar browser lazy-loads)
# Post data main panel — left as render_template (uses ORM introspection macros)
# ---------------------------------------------------------------------------


# ===========================================================================
# PUBLIC API — called from route handlers
# ===========================================================================

# ---- Home page ----

async def render_home_page(ctx: dict) -> str:
    root_hdr = root_header_sx(ctx)
    post_hdr = _post_header_sx(ctx)
    header_rows = "(<> " + root_hdr + " " + post_hdr + ")"
    content = _home_main_panel_sx(ctx)
    meta = _post_meta_sx(ctx)
    menu = mobile_menu_sx(post_mobile_nav_sx(ctx), mobile_root_nav_sx(ctx))
    return full_page_sx(ctx, header_rows=header_rows, content=content,
                          meta=meta, menu=menu)


async def render_home_oob(ctx: dict) -> str:
    root_hdr = root_header_sx(ctx)
    post_hdr = _post_header_sx(ctx)
    rows = "(<> " + root_hdr + " " + post_hdr + ")"
    header_oob = _oob_header_sx("root-header-child", "post-header-child", rows)
    content = _home_main_panel_sx(ctx)
    return oob_page_sx(oobs=header_oob, content=content)


# ---- Blog index ----

async def render_blog_page(ctx: dict) -> str:
    root_hdr = root_header_sx(ctx)
    blog_hdr = _blog_header_sx(ctx)
    header_rows = "(<> " + root_hdr + " " + blog_hdr + ")"
    content = _blog_main_panel_sx(ctx)
    aside = _blog_aside_sx(ctx)
    filter_sx = _blog_filter_sx(ctx)
    return full_page_sx(ctx, header_rows=header_rows, content=content,
                          aside=aside, filter=filter_sx)


async def render_blog_oob(ctx: dict) -> str:
    root_hdr = root_header_sx(ctx)
    blog_hdr = _blog_header_sx(ctx)
    rows = "(<> " + root_hdr + " " + blog_hdr + ")"
    header_oob = _oob_header_sx("root-header-child", "blog-header-child", rows)
    content = _blog_main_panel_sx(ctx)
    aside = _blog_aside_sx(ctx)
    filter_sx = _blog_filter_sx(ctx)
    return oob_page_sx(oobs=header_oob, content=content, aside=aside,
                         filter=filter_sx)


async def render_blog_cards(ctx: dict) -> str:
    """Pagination-only response (page > 1) — sx wire format."""
    return _blog_cards_sx(ctx)


async def render_blog_page_cards(ctx: dict) -> str:
    """Page cards pagination response."""
    return _page_cards_sx(ctx)


# ---- New post/page editor panel ----

def render_editor_panel(save_error: str | None = None, is_page: bool = False) -> str:
    """Build the WYSIWYG editor panel HTML (replaces _main_panel.html template).

    This is synchronous — it just assembles an HTML string from the current
    request context (url_for, CSRF token, asset URLs, config).
    """
    import os
    from quart import url_for as qurl, current_app
    from shared.browser.app.csrf import generate_csrf_token
    from markupsafe import escape as esc

    csrf = generate_csrf_token()
    asset_url_fn = current_app.jinja_env.globals.get("asset_url", lambda p: "")
    editor_css = asset_url_fn("scripts/editor.css")
    editor_js = asset_url_fn("scripts/editor.js")
    sx_editor_js = asset_url_fn("scripts/sx-editor.js")

    upload_image_url = qurl("blog.editor_api.upload_image")
    upload_media_url = qurl("blog.editor_api.upload_media")
    upload_file_url = qurl("blog.editor_api.upload_file")
    oembed_url = qurl("blog.editor_api.oembed_proxy")
    snippets_url = qurl("blog.editor_api.list_snippets")
    unsplash_key = os.environ.get("UNSPLASH_ACCESS_KEY", "")

    title_placeholder = "Page title..." if is_page else "Post title..."
    create_label = "Create Page" if is_page else "Create Post"

    parts: list[str] = []

    # Error banner
    if save_error:
        parts.append(sx_call("blog-editor-error", error=str(save_error)))

    # Form structure
    form_html = sx_call("blog-editor-form",
        csrf=csrf, title_placeholder=title_placeholder,
        create_label=create_label,
    )
    parts.append(form_html)

    # Editor CSS + inline styles + sx editor styles
    parts.append(sx_call("blog-editor-styles", css_href=editor_css))
    parts.append(sx_call("sx-editor-styles"))

    # Editor JS + init script
    init_js = (
        "console.log('[EDITOR-DEBUG] init script running');\n"
        "(function() {\n"
        "  console.log('[EDITOR-DEBUG] IIFE entered, mountEditor=', typeof window.mountEditor);\n"
        "  // Font size overrides disabled — caused global font shrinking\n"
        "  // function applyEditorFontSize() {\n"
        "  //   document.documentElement.style.fontSize = '62.5%';\n"
        "  //   document.body.style.fontSize = '1.6rem';\n"
        "  // }\n"
        "  // applyEditorFontSize();\n"
        "\n"
        "  function init() {\n"
        "    var csrfToken = document.querySelector('input[name=\"csrf_token\"]').value;\n"
        f"    var uploadUrl = '{upload_image_url}';\n"
        "    var uploadUrls = {\n"
        "      image: uploadUrl,\n"
        f"      media: '{upload_media_url}',\n"
        f"      file:  '{upload_file_url}',\n"
        "    };\n"
        "\n"
        "    var fileInput     = document.getElementById('feature-image-file');\n"
        "    var addBtn        = document.getElementById('feature-image-add-btn');\n"
        "    var deleteBtn     = document.getElementById('feature-image-delete-btn');\n"
        "    var preview       = document.getElementById('feature-image-preview');\n"
        "    var emptyState    = document.getElementById('feature-image-empty');\n"
        "    var filledState   = document.getElementById('feature-image-filled');\n"
        "    var hiddenUrl     = document.getElementById('feature-image-input');\n"
        "    var hiddenCaption = document.getElementById('feature-image-caption-input');\n"
        "    var captionInput  = document.getElementById('feature-image-caption');\n"
        "    var uploading     = document.getElementById('feature-image-uploading');\n"
        "\n"
        "    function showFilled(url) {\n"
        "      preview.src = url;\n"
        "      hiddenUrl.value = url;\n"
        "      emptyState.classList.add('hidden');\n"
        "      filledState.classList.remove('hidden');\n"
        "      uploading.classList.add('hidden');\n"
        "    }\n"
        "\n"
        "    function showEmpty() {\n"
        "      preview.src = '';\n"
        "      hiddenUrl.value = '';\n"
        "      hiddenCaption.value = '';\n"
        "      captionInput.value = '';\n"
        "      emptyState.classList.remove('hidden');\n"
        "      filledState.classList.add('hidden');\n"
        "      uploading.classList.add('hidden');\n"
        "    }\n"
        "\n"
        "    function uploadFile(file) {\n"
        "      emptyState.classList.add('hidden');\n"
        "      uploading.classList.remove('hidden');\n"
        "      var fd = new FormData();\n"
        "      fd.append('file', file);\n"
        "      fetch(uploadUrl, {\n"
        "        method: 'POST',\n"
        "        body: fd,\n"
        "        headers: { 'X-CSRFToken': csrfToken },\n"
        "      })\n"
        "      .then(function(r) {\n"
        "        if (!r.ok) throw new Error('Upload failed (' + r.status + ')');\n"
        "        return r.json();\n"
        "      })\n"
        "      .then(function(data) {\n"
        "        var url = data.images && data.images[0] && data.images[0].url;\n"
        "        if (url) showFilled(url);\n"
        "        else { showEmpty(); alert('Upload succeeded but no image URL returned.'); }\n"
        "      })\n"
        "      .catch(function(e) {\n"
        "        showEmpty();\n"
        "        alert(e.message);\n"
        "      });\n"
        "    }\n"
        "\n"
        "    addBtn.addEventListener('click', function() { fileInput.click(); });\n"
        "    preview.addEventListener('click', function() { fileInput.click(); });\n"
        "    deleteBtn.addEventListener('click', function(e) {\n"
        "      e.stopPropagation();\n"
        "      showEmpty();\n"
        "    });\n"
        "    fileInput.addEventListener('change', function() {\n"
        "      if (fileInput.files && fileInput.files[0]) {\n"
        "        uploadFile(fileInput.files[0]);\n"
        "        fileInput.value = '';\n"
        "      }\n"
        "    });\n"
        "    captionInput.addEventListener('input', function() {\n"
        "      hiddenCaption.value = captionInput.value;\n"
        "    });\n"
        "\n"
        "    var excerpt = document.querySelector('textarea[name=\"custom_excerpt\"]');\n"
        "    function autoResize() {\n"
        "      excerpt.style.height = 'auto';\n"
        "      excerpt.style.height = excerpt.scrollHeight + 'px';\n"
        "    }\n"
        "    excerpt.addEventListener('input', autoResize);\n"
        "    autoResize();\n"
        "\n"
        "    window.mountEditor('lexical-editor', {\n"
        "      initialJson: null,\n"
        "      csrfToken: csrfToken,\n"
        "      uploadUrls: uploadUrls,\n"
        f"      oembedUrl: '{oembed_url}',\n"
        f"      unsplashApiKey: '{unsplash_key}',\n"
        f"      snippetsUrl: '{snippets_url}',\n"
        "    });\n"
        "\n"
        "    if (typeof SxEditor !== 'undefined') {\n"
        "      SxEditor.mount('sx-editor', {\n"
        "        initialSx: (document.getElementById('sx-content-input') || {}).value || null,\n"
        "        csrfToken: csrfToken,\n"
        "        uploadUrls: uploadUrls,\n"
        f"        oembedUrl: '{oembed_url}',\n"
        "        onChange: function(sx) {\n"
        "          document.getElementById('sx-content-input').value = sx;\n"
        "        }\n"
        "      });\n"
        "    }\n"
        "\n"
        "    document.addEventListener('keydown', function(e) {\n"
        "      if ((e.ctrlKey || e.metaKey) && e.key === 's') {\n"
        "        e.preventDefault();\n"
        "        document.getElementById('post-new-form').requestSubmit();\n"
        "      }\n"
        "    });\n"
        "  }\n"
        "\n"
        "  if (typeof window.mountEditor === 'function') {\n"
        "    init();\n"
        "  } else {\n"
        "    var _t = setInterval(function() {\n"
        "      if (typeof window.mountEditor === 'function') { clearInterval(_t); init(); }\n"
        "    }, 50);\n"
        "  }\n"
        "})();\n"
    )
    parts.append(sx_call("blog-editor-scripts",
                         js_src=editor_js,
                         sx_editor_js_src=sx_editor_js,
                         init_js=init_js))

    return "(<> " + " ".join(parts) + ")" if parts else ""


# ---- New post/page ----

async def render_new_post_page(ctx: dict) -> str:
    root_hdr = root_header_sx(ctx)
    blog_hdr = _blog_header_sx(ctx)
    header_rows = "(<> " + root_hdr + " " + blog_hdr + ")"
    content = ctx.get("editor_html", "")
    return full_page_sx(ctx, header_rows=header_rows, content=content)


# ---- Post detail ----

async def render_post_page(ctx: dict) -> str:
    root_hdr = root_header_sx(ctx)
    post_hdr = _post_header_sx(ctx)
    header_rows = "(<> " + root_hdr + " " + post_hdr + ")"
    content = _post_main_panel_sx(ctx)
    meta = _post_meta_sx(ctx)
    menu = mobile_menu_sx(post_mobile_nav_sx(ctx), mobile_root_nav_sx(ctx))
    return full_page_sx(ctx, header_rows=header_rows, content=content,
                          meta=meta, menu=menu)


async def render_post_oob(ctx: dict) -> str:
    root_hdr = root_header_sx(ctx)          # non-OOB (nested inside root-header-child)
    post_hdr = _post_header_sx(ctx)
    rows = "(<> " + root_hdr + " " + post_hdr + ")"
    post_oob = _oob_header_sx("root-header-child", "post-header-child", rows)
    content = _post_main_panel_sx(ctx)
    menu = mobile_menu_sx(post_mobile_nav_sx(ctx), mobile_root_nav_sx(ctx))
    oobs = post_oob
    return oob_page_sx(oobs=oobs, content=content, menu=menu)


# ---- Post admin ----

# ===========================================================================

def _post_data_content_sx(ctx: dict) -> str:
    """Build post data inspector panel natively (replaces _types/post_data/_main_panel.html)."""
    from markupsafe import escape as esc
    from quart import g

    original_post = getattr(g, "post_data", {}).get("original_post")
    if original_post is None:
        return _raw_html_sx('<div class="px-4 py-8 text-stone-400">No post data available.</div>')

    tablename = getattr(original_post, "__tablename__", "?")

    def _render_scalar_table(obj):
        rows = []
        for col in obj.__mapper__.columns:
            key = col.key
            if key == "_sa_instance_state":
                continue
            val = getattr(obj, key, None)
            if val is None:
                val_html = '<span class="text-neutral-400">\u2014</span>'
            elif hasattr(val, "isoformat"):
                val_html = f'<pre class="whitespace-pre-wrap break-words break-all text-xs"><code>{esc(val.isoformat())}</code></pre>'
            elif isinstance(val, str):
                val_html = f'<pre class="whitespace-pre-wrap break-words break-all text-xs">{esc(val)}</pre>'
            else:
                val_html = f'<pre class="whitespace-pre-wrap break-words break-all text-xs"><code>{esc(str(val))}</code></pre>'
            rows.append(
                f'<tr class="border-t border-neutral-200 align-top">'
                f'<td class="px-3 py-2 whitespace-nowrap text-neutral-600 align-top">{esc(key)}</td>'
                f'<td class="px-3 py-2 align-top">{val_html}</td></tr>'
            )
        return (
            '<div class="w-full overflow-x-auto sm:overflow-visible">'
            '<table class="w-full table-fixed text-sm border border-neutral-200 rounded-xl overflow-hidden">'
            '<thead class="bg-neutral-50/70"><tr>'
            '<th class="px-3 py-2 text-left font-medium w-40 sm:w-56">Field</th>'
            '<th class="px-3 py-2 text-left font-medium">Value</th>'
            '</tr></thead><tbody>' + "".join(rows) + '</tbody></table></div>'
        )

    def _render_model(obj, depth=0, max_depth=2):
        parts = [_render_scalar_table(obj)]
        rel_parts = []
        for rel in obj.__mapper__.relationships:
            rel_name = rel.key
            loaded = rel_name in obj.__dict__
            value = getattr(obj, rel_name, None) if loaded else None
            cardinality = "many" if rel.uselist else "one"
            cls_name = rel.mapper.class_.__name__
            loaded_label = "" if loaded else " \u2022 <em>not loaded</em>"

            inner = ""
            if value is None:
                inner = '<span class="text-neutral-400">\u2014</span>'
            elif rel.uselist:
                items = list(value) if value else []
                inner = f'<div class="text-neutral-500 mb-2">{len(items)} item{"" if len(items) == 1 else "s"}</div>'
                if items and depth < max_depth:
                    sub_rows = []
                    for i, it in enumerate(items, 1):
                        ident_parts = []
                        for k in ("id", "ghost_id", "uuid", "slug", "name", "title"):
                            if k in it.__mapper__.c:
                                v = getattr(it, k, "")
                                ident_parts.append(f"{k}={v}")
                        summary = " \u2022 ".join(ident_parts) if ident_parts else str(it)
                        child_html = ""
                        if depth < max_depth:
                            child_html = f'<div class="mt-2 pl-3 border-l border-neutral-200">{_render_model(it, depth + 1, max_depth)}</div>'
                        else:
                            child_html = '<div class="mt-1 text-xs text-neutral-500">\u2026max depth reached\u2026</div>'
                        sub_rows.append(
                            f'<tr class="border-t border-neutral-200 align-top">'
                            f'<td class="px-2 py-1 whitespace-nowrap align-top">{i}</td>'
                            f'<td class="px-2 py-1 align-top"><pre class="whitespace-pre-wrap break-words break-all text-xs"><code>{esc(summary)}</code></pre>{child_html}</td></tr>'
                        )
                    inner += (
                        '<div class="w-full overflow-x-auto sm:overflow-visible">'
                        '<table class="w-full table-fixed text-sm border border-neutral-200 rounded-lg overflow-hidden">'
                        '<thead class="bg-neutral-50/70"><tr><th class="px-2 py-1 text-left w-10">#</th>'
                        '<th class="px-2 py-1 text-left">Summary</th></tr></thead><tbody>'
                        + "".join(sub_rows) + '</tbody></table></div>'
                    )
            else:
                child = value
                ident_parts = []
                for k in ("id", "ghost_id", "uuid", "slug", "name", "title"):
                    if k in child.__mapper__.c:
                        v = getattr(child, k, "")
                        ident_parts.append(f"{k}={v}")
                summary = " \u2022 ".join(ident_parts) if ident_parts else str(child)
                inner = f'<pre class="whitespace-pre-wrap break-words break-all text-xs mb-2"><code>{esc(summary)}</code></pre>'
                if depth < max_depth:
                    inner += f'<div class="pl-3 border-l border-neutral-200">{_render_model(child, depth + 1, max_depth)}</div>'
                else:
                    inner += '<div class="text-xs text-neutral-500">\u2026max depth reached\u2026</div>'

            rel_parts.append(
                f'<div class="rounded-xl border border-neutral-200">'
                f'<div class="px-3 py-2 bg-neutral-50/70 text-sm font-medium">'
                f'Relationship: <span class="font-semibold">{esc(rel_name)}</span>'
                f' <span class="ml-2 text-xs text-neutral-500">{cardinality} \u2192 {esc(cls_name)}{loaded_label}</span></div>'
                f'<div class="p-3 text-sm">{inner}</div></div>'
            )
        if rel_parts:
            parts.append('<div class="space-y-3">' + "".join(rel_parts) + '</div>')
        return '<div class="space-y-4">' + "".join(parts) + '</div>'

    html = (
        f'<div class="px-4 py-8">'
        f'<div class="mb-6 text-sm text-neutral-500">Model: <code>Post</code> \u2022 Table: <code>{esc(tablename)}</code></div>'
        f'{_render_model(original_post, 0, 2)}</div>'
    )
    return _raw_html_sx(html)


# ===========================================================================

def _preview_main_panel_sx(ctx: dict) -> str:
    """Build the preview panel with 4 expandable sections."""
    sections: list[str] = []

    # 1. Prettified SX source
    sx_pretty = ctx.get("sx_pretty", "")
    if sx_pretty:
        sections.append(sx_call("blog-preview-section",
            title="S-Expression Source",
            content=SxExpr(sx_pretty),
        ))

    # 2. Prettified Lexical JSON
    json_pretty = ctx.get("json_pretty", "")
    if json_pretty:
        sections.append(sx_call("blog-preview-section",
            title="Lexical JSON",
            content=SxExpr(json_pretty),
        ))

    # 3. SX rendered preview
    sx_rendered = ctx.get("sx_rendered", "")
    if sx_rendered:
        rendered_sx = f'(div :class "blog-content prose max-w-none" (raw! {sx_serialize(sx_rendered)}))'
        sections.append(sx_call("blog-preview-section",
            title="SX Rendered",
            content=SxExpr(rendered_sx),
        ))

    # 4. Lexical rendered preview
    lex_rendered = ctx.get("lex_rendered", "")
    if lex_rendered:
        rendered_sx = f'(div :class "blog-content prose max-w-none" (raw! {sx_serialize(lex_rendered)}))'
        sections.append(sx_call("blog-preview-section",
            title="Lexical Rendered",
            content=SxExpr(rendered_sx),
        ))

    if not sections:
        return '(div :class "p-8 text-stone-500" "No content to preview.")'

    inner = " ".join(sections)
    return sx_call("blog-preview-panel", sections=SxExpr(f"(<> {inner})"))


# ===========================================================================

def _post_entries_content_sx(ctx: dict) -> str:
    """Build post entries panel natively (replaces _types/post_entries/_main_panel.html)."""
    from quart import g, url_for as qurl
    from shared.utils import host_url

    all_calendars = ctx.get("all_calendars", [])
    associated_entry_ids = ctx.get("associated_entry_ids", set())
    post_slug = g.post_data["post"]["slug"]

    # Associated entries list (reuse existing render function)
    assoc_html = render_associated_entries(all_calendars, associated_entry_ids, post_slug)

    # Calendar browser
    cal_items: list[str] = []
    for cal in all_calendars:
        cal_post = getattr(cal, "post", None)
        cal_fi = getattr(cal_post, "feature_image", None) if cal_post else None
        cal_title = escape(getattr(cal_post, "title", "")) if cal_post else ""
        cal_name = escape(getattr(cal, "name", ""))
        cal_view_url = host_url(qurl("blog.post.admin.calendar_view", slug=post_slug, calendar_id=cal.id))

        img_html = (
            f'<img src="{escape(cal_fi)}" alt="{cal_title}" class="w-12 h-12 rounded object-cover flex-shrink-0" />'
            if cal_fi else
            '<div class="w-12 h-12 rounded bg-stone-200 flex-shrink-0"></div>'
        )
        cal_items.append(
            f'<details class="border rounded-lg bg-white" data-toggle-group="calendar-browser">'
            f'<summary class="p-4 cursor-pointer hover:bg-stone-50 flex items-center gap-3">'
            f'{img_html}'
            f'<div class="flex-1">'
            f'<div class="font-semibold flex items-center gap-2"><i class="fa fa-calendar text-stone-500"></i> {cal_name}</div>'
            f'<div class="text-sm text-stone-600">{cal_title}</div>'
            f'</div></summary>'
            f'<div class="p-4 border-t" sx-get="{escape(cal_view_url)}" sx-trigger="intersect once" sx-swap="innerHTML">'
            f'<div class="text-sm text-stone-400">Loading calendar...</div>'
            f'</div></details>'
        )

    if cal_items:
        browser_html = (
            '<div class="space-y-3"><h3 class="text-lg font-semibold">Browse Calendars</h3>'
            + "".join(cal_items) + '</div>'
        )
    else:
        browser_html = '<div class="space-y-3"><h3 class="text-lg font-semibold">Browse Calendars</h3><div class="text-sm text-stone-400">No calendars found.</div></div>'

    # assoc_html is sx (from render_associated_entries); browser is raw HTML
    # Wrap the whole thing: open div as raw, then associated entries (sx), then browser (raw), close div
    return (
        _raw_html_sx('<div id="post-entries-content" class="space-y-6 p-4">')
        + assoc_html
        + _raw_html_sx(browser_html + '</div>')
    )


# ---- Calendar view (for entries browser) ----

def render_calendar_view(
    calendar, year, month, month_name, weekday_names, weeks,
    prev_month, prev_month_year, next_month, next_month_year,
    prev_year, next_year, month_entries, associated_entry_ids,
    post_slug: str,
) -> str:
    """Build calendar month grid HTML (replaces _types/post/admin/_calendar_view.html)."""
    from quart import url_for as qurl
    from shared.browser.app.csrf import generate_csrf_token
    from shared.utils import host_url
    esc = escape

    csrf = generate_csrf_token()
    cal_id = calendar.id

    def cal_url(y, m):
        return esc(host_url(qurl("blog.post.admin.calendar_view", slug=post_slug, calendar_id=cal_id, year=y, month=m)))

    cur_url = cal_url(year, month)
    toggle_url_fn = lambda eid: esc(host_url(qurl("blog.post.admin.toggle_entry", slug=post_slug, entry_id=eid)))

    # Navigation header
    nav = (
        f'<header class="flex items-center justify-center mb-4">'
        f'<nav class="flex items-center gap-2 text-xl">'
        f'<a class="px-2 py-1 hover:bg-stone-100 rounded" sx-get="{cal_url(prev_year, month)}" sx-target="#calendar-view-{cal_id}" sx-swap="outerHTML">&laquo;</a>'
        f'<a class="px-2 py-1 hover:bg-stone-100 rounded" sx-get="{cal_url(prev_month_year, prev_month)}" sx-target="#calendar-view-{cal_id}" sx-swap="outerHTML">&lsaquo;</a>'
        f'<div class="px-3 font-medium">{esc(month_name)} {year}</div>'
        f'<a class="px-2 py-1 hover:bg-stone-100 rounded" sx-get="{cal_url(next_month_year, next_month)}" sx-target="#calendar-view-{cal_id}" sx-swap="outerHTML">&rsaquo;</a>'
        f'<a class="px-2 py-1 hover:bg-stone-100 rounded" sx-get="{cal_url(next_year, month)}" sx-target="#calendar-view-{cal_id}" sx-swap="outerHTML">&raquo;</a>'
        f'</nav></header>'
    )

    # Weekday header
    wd_cells = "".join(f'<div class="py-2">{esc(wd)}</div>' for wd in weekday_names)
    wd_row = f'<div class="hidden sm:grid grid-cols-7 text-center text-xs font-semibold text-stone-700 bg-stone-50 border-b">{wd_cells}</div>'

    # Grid cells
    cells: list[str] = []
    for week in weeks:
        for day in week:
            extra_cls = " bg-stone-50 text-stone-400" if not day.in_month else ""
            day_date = day.date

            entry_btns: list[str] = []
            for e in month_entries:
                e_start = getattr(e, "start_at", None)
                if not e_start or e_start.date() != day_date:
                    continue
                e_id = getattr(e, "id", None)
                e_name = esc(getattr(e, "name", ""))
                t_url = toggle_url_fn(e_id)
                hx_hdrs = f'{{"X-CSRFToken": "{csrf}"}}'

                if e_id in associated_entry_ids:
                    entry_btns.append(
                        f'<div class="flex items-center gap-1 text-[10px] rounded px-1 py-0.5 bg-green-200 text-green-900">'
                        f'<span class="truncate flex-1">{e_name}</span>'
                        f'<button type="button" class="flex-shrink-0 hover:text-red-600"'
                        f' data-confirm data-confirm-title="Remove entry?"'
                        f' data-confirm-text="Remove {e_name} from this post?"'
                        f' data-confirm-icon="warning" data-confirm-confirm-text="Yes, remove it"'
                        f' data-confirm-cancel-text="Cancel" data-confirm-event="confirmed"'
                        f' sx-post="{t_url}" sx-trigger="confirmed"'
                        f' sx-target="#associated-entries-list" sx-swap="outerHTML"'
                        f""" sx-headers='{hx_hdrs}'"""
                        f' sx-on:afterSwap="document.body.dispatchEvent(new CustomEvent(\'entryToggled\'))"'
                        f'><i class="fa fa-times"></i></button></div>'
                    )
                else:
                    entry_btns.append(
                        f'<button type="button" class="w-full text-left text-[10px] rounded px-1 py-0.5 bg-stone-100 text-stone-700 hover:bg-stone-200"'
                        f' data-confirm data-confirm-title="Add entry?"'
                        f' data-confirm-text="Add {e_name} to this post?"'
                        f' data-confirm-icon="question" data-confirm-confirm-text="Yes, add it"'
                        f' data-confirm-cancel-text="Cancel" data-confirm-event="confirmed"'
                        f' sx-post="{t_url}" sx-trigger="confirmed"'
                        f' sx-target="#associated-entries-list" sx-swap="outerHTML"'
                        f""" sx-headers='{hx_hdrs}'"""
                        f' sx-on:afterSwap="document.body.dispatchEvent(new CustomEvent(\'entryToggled\'))"'
                        f'><span class="truncate block">{e_name}</span></button>'
                    )

            entries_html = '<div class="space-y-0.5">' + "".join(entry_btns) + '</div>' if entry_btns else ''
            cells.append(
                f'<div class="min-h-20 bg-white px-2 py-2 text-xs{extra_cls}">'
                f'<div class="font-medium mb-1">{day_date.day}</div>{entries_html}</div>'
            )

    grid = f'<div class="grid grid-cols-1 sm:grid-cols-7 gap-px bg-stone-200">{"".join(cells)}</div>'

    html = (
        f'<div id="calendar-view-{cal_id}"'
        f' sx-get="{cur_url}" sx-trigger="entryToggled from:body" sx-swap="outerHTML">'
        f'{nav}'
        f'<div class="rounded border bg-white">{wd_row}{grid}</div>'
        f'</div>'
    )
    return _raw_html_sx(html)


# ---- Post edit ----

def _raw_html_sx(html: str) -> str:
    """Wrap raw HTML in (raw! "...") so it's valid inside sx source."""
    if not html:
        return ""
    return "(raw! " + sx_serialize(html) + ")"


def _post_edit_content_sx(ctx: dict) -> str:
    """Build WYSIWYG editor panel as SX expression (edit page)."""
    from quart import url_for as qurl, current_app, g, request as qrequest
    from shared.browser.app.csrf import generate_csrf_token
    from shared.sx.helpers import sx_call
    from shared.sx.parser import SxExpr

    ghost_post = ctx.get("ghost_post", {}) or {}
    save_success = ctx.get("save_success", False)
    save_error = ctx.get("save_error", "")
    newsletters = ctx.get("newsletters", [])

    csrf = generate_csrf_token()
    asset_url_fn = current_app.jinja_env.globals.get("asset_url", lambda p: "")
    editor_css = asset_url_fn("scripts/editor.css")
    editor_js = asset_url_fn("scripts/editor.js")
    sx_editor_js = asset_url_fn("scripts/sx-editor.js")

    upload_image_url = qurl("blog.editor_api.upload_image")
    upload_media_url = qurl("blog.editor_api.upload_media")
    upload_file_url = qurl("blog.editor_api.upload_file")
    oembed_url = qurl("blog.editor_api.oembed_proxy")
    snippets_url = qurl("blog.editor_api.list_snippets")
    unsplash_key = os.environ.get("UNSPLASH_ACCESS_KEY", "")

    post = g.post_data.get("post", {}) if hasattr(g, "post_data") else {}
    is_page = post.get("is_page", False)

    feature_image = ghost_post.get("feature_image") or ""
    feature_image_caption = ghost_post.get("feature_image_caption") or ""
    title_val = ghost_post.get("title") or ""
    excerpt_val = ghost_post.get("custom_excerpt") or ""
    updated_at = ghost_post.get("updated_at") or ""
    status = ghost_post.get("status") or "draft"
    lexical_json = ghost_post.get("lexical") or '{"root":{"children":[{"children":[],"direction":null,"format":"","indent":0,"type":"paragraph","version":1}],"direction":null,"format":"","indent":0,"type":"root","version":1}}'
    sx_content = ghost_post.get("sx_content") or ""
    has_sx = bool(sx_content)

    already_emailed = bool(ghost_post and ghost_post.get("email") and (ghost_post["email"] if isinstance(ghost_post["email"], dict) else {}).get("status"))
    email_obj = ghost_post.get("email")
    if email_obj and not isinstance(email_obj, dict):
        already_emailed = bool(getattr(email_obj, "status", None))

    title_placeholder = "Page title..." if is_page else "Post title..."

    # Newsletter options as SX fragment
    nl_parts = ['(option :value "" "Select newsletter\u2026")']
    for nl in newsletters:
        nl_slug = sx_serialize(getattr(nl, "slug", ""))
        nl_name = sx_serialize(getattr(nl, "name", ""))
        nl_parts.append(f"(option :value {nl_slug} {nl_name})")
    nl_opts_sx = SxExpr("(<> " + " ".join(nl_parts) + ")")

    # Footer extra badges as SX fragment
    badge_parts: list[str] = []
    if save_success:
        badge_parts.append('(span :class "text-[14px] text-green-600" "Saved.")')
    publish_requested = qrequest.args.get("publish_requested") if hasattr(qrequest, 'args') else None
    if publish_requested:
        badge_parts.append('(span :class "text-[14px] text-blue-600" "Publish requested \u2014 an admin will review.")')
    if post.get("publish_requested"):
        badge_parts.append('(span :class "inline-block px-2 py-0.5 rounded-full text-xs font-semibold bg-blue-100 text-blue-800" "Publish requested")')
    if already_emailed:
        nl_name = ""
        newsletter = ghost_post.get("newsletter")
        if newsletter:
            nl_name = getattr(newsletter, "name", "") if not isinstance(newsletter, dict) else newsletter.get("name", "")
        suffix = f" to {nl_name}" if nl_name else ""
        badge_parts.append(f'(span :class "inline-block px-2 py-0.5 rounded-full text-xs font-semibold bg-green-100 text-green-800" "Emailed{suffix}")')
    footer_extra_sx = SxExpr("(<> " + " ".join(badge_parts) + ")") if badge_parts else None

    parts: list[str] = []

    # Error banner
    if save_error:
        parts.append(sx_call("blog-editor-error", error=save_error))

    # Form (sx_content_val populates #sx-content-input; JS reads from there)
    parts.append(sx_call("blog-editor-edit-form",
        csrf=csrf,
        updated_at=str(updated_at),
        title_val=title_val,
        excerpt_val=excerpt_val,
        feature_image=feature_image,
        feature_image_caption=feature_image_caption,
        sx_content_val=sx_content,
        lexical_json=lexical_json,
        has_sx=has_sx,
        title_placeholder=title_placeholder,
        status=status,
        already_emailed=already_emailed,
        newsletter_options=nl_opts_sx,
        footer_extra=footer_extra_sx,
    ))

    # Publish-mode JS
    parts.append(sx_call("blog-editor-publish-js", already_emailed=already_emailed))

    # Editor CSS + styles
    parts.append(sx_call("blog-editor-styles", css_href=editor_css))
    parts.append(sx_call("sx-editor-styles"))

    # Editor JS + init
    init_js = (
        '(function() {'
        "  function applyEditorFontSize() {"
        "    document.documentElement.style.fontSize = '62.5%';"
        "    document.body.style.fontSize = '1.6rem';"
        '  }'
        "  function restoreDefaultFontSize() {"
        "    document.documentElement.style.fontSize = '';"
        "    document.body.style.fontSize = '';"
        '  }'
        '  applyEditorFontSize();'
        "  document.body.addEventListener('htmx:beforeSwap', function cleanup(e) {"
        "    if (e.detail.target && e.detail.target.id === 'main-panel') {"
        '      restoreDefaultFontSize();'
        "      document.body.removeEventListener('htmx:beforeSwap', cleanup);"
        '    }'
        '  });'
        '  function init() {'
        "    var csrfToken = document.querySelector('input[name=\"csrf_token\"]').value;"
        f"    var uploadUrl = '{upload_image_url}';"
        '    var uploadUrls = {'
        '      image: uploadUrl,'
        f"      media: '{upload_media_url}',"
        f"      file: '{upload_file_url}',"
        '    };'
        "    var fileInput = document.getElementById('feature-image-file');"
        "    var addBtn = document.getElementById('feature-image-add-btn');"
        "    var deleteBtn = document.getElementById('feature-image-delete-btn');"
        "    var preview = document.getElementById('feature-image-preview');"
        "    var emptyState = document.getElementById('feature-image-empty');"
        "    var filledState = document.getElementById('feature-image-filled');"
        "    var hiddenUrl = document.getElementById('feature-image-input');"
        "    var hiddenCaption = document.getElementById('feature-image-caption-input');"
        "    var captionInput = document.getElementById('feature-image-caption');"
        "    var uploading = document.getElementById('feature-image-uploading');"
        '    function showFilled(url) {'
        '      preview.src = url; hiddenUrl.value = url;'
        "      emptyState.classList.add('hidden'); filledState.classList.remove('hidden'); uploading.classList.add('hidden');"
        '    }'
        '    function showEmpty() {'
        "      preview.src = ''; hiddenUrl.value = ''; hiddenCaption.value = ''; captionInput.value = '';"
        "      emptyState.classList.remove('hidden'); filledState.classList.add('hidden'); uploading.classList.add('hidden');"
        '    }'
        '    function uploadFile(file) {'
        "      emptyState.classList.add('hidden'); uploading.classList.remove('hidden');"
        "      var fd = new FormData(); fd.append('file', file);"
        "      fetch(uploadUrl, { method: 'POST', body: fd, headers: { 'X-CSRFToken': csrfToken } })"
        "      .then(function(r) { if (!r.ok) throw new Error('Upload failed (' + r.status + ')'); return r.json(); })"
        '      .then(function(data) {'
        '        var url = data.images && data.images[0] && data.images[0].url;'
        "        if (url) showFilled(url); else { showEmpty(); alert('Upload succeeded but no image URL returned.'); }"
        '      })'
        '      .catch(function(e) { showEmpty(); alert(e.message); });'
        '    }'
        "    addBtn.addEventListener('click', function() { fileInput.click(); });"
        "    preview.addEventListener('click', function() { fileInput.click(); });"
        "    deleteBtn.addEventListener('click', function(e) { e.stopPropagation(); showEmpty(); });"
        "    fileInput.addEventListener('change', function() {"
        '      if (fileInput.files && fileInput.files[0]) { uploadFile(fileInput.files[0]); fileInput.value = \'\'; }'
        '    });'
        "    captionInput.addEventListener('input', function() { hiddenCaption.value = captionInput.value; });"
        "    var excerpt = document.querySelector('textarea[name=\"custom_excerpt\"]');"
        "    function autoResize() { excerpt.style.height = 'auto'; excerpt.style.height = excerpt.scrollHeight + 'px'; }"
        "    excerpt.addEventListener('input', autoResize); autoResize();"
        '    var dataEl = document.getElementById(\'lexical-initial-data\');'
        '    var initialJson = dataEl ? dataEl.textContent.trim() : null;'
        '    if (initialJson) { var hidden = document.getElementById(\'lexical-json-input\'); if (hidden) hidden.value = initialJson; }'
        "    window.mountEditor('lexical-editor', {"
        '      initialJson: initialJson,'
        '      csrfToken: csrfToken,'
        '      uploadUrls: uploadUrls,'
        f"      oembedUrl: '{oembed_url}',"
        f"      unsplashApiKey: '{unsplash_key}',"
        f"      snippetsUrl: '{snippets_url}',"
        '    });'
        "    if (typeof SxEditor !== 'undefined') {"
        "      SxEditor.mount('sx-editor', {"
        "        initialSx: (document.getElementById('sx-content-input') || {}).value || null,"
        '        csrfToken: csrfToken,'
        '        uploadUrls: uploadUrls,'
        f"        oembedUrl: '{oembed_url}',"
        '        onChange: function(sx) {'
        "          document.getElementById('sx-content-input').value = sx;"
        '        }'
        '      });'
        '    }'
        "    document.addEventListener('keydown', function(e) {"
        "      if ((e.ctrlKey || e.metaKey) && e.key === 's') {"
        "        e.preventDefault(); document.getElementById('post-edit-form').requestSubmit();"
        '      }'
        '    });'
        '  }'
        "  if (typeof window.mountEditor === 'function') { init(); }"
        '  else { var _t = setInterval(function() {'
        "    if (typeof window.mountEditor === 'function') { clearInterval(_t); init(); }"
        '  }, 50); }'
        '})();'
    )
    parts.append(sx_call("blog-editor-scripts",
                         js_src=editor_js,
                         sx_editor_js_src=sx_editor_js,
                         init_js=init_js))

    return "(<> " + " ".join(parts) + ")"


# ===========================================================================

def _post_settings_content_sx(ctx: dict) -> str:
    """Build settings form natively (replaces _types/post_settings/_main_panel.html)."""
    from quart import g
    from shared.browser.app.csrf import generate_csrf_token
    esc = escape

    ghost_post = ctx.get("ghost_post", {}) or {}
    save_success = ctx.get("save_success", False)
    csrf = generate_csrf_token()

    post = g.post_data.get("post", {}) if hasattr(g, "post_data") else {}
    is_page = post.get("is_page", False)

    def field_label(text, field_for=None):
        for_attr = f' for="{field_for}"' if field_for else ''
        return f'<label{for_attr} class="block text-[13px] font-medium text-stone-500 mb-[4px]">{esc(text)}</label>'

    input_cls = ('w-full text-[14px] rounded-[6px] border border-stone-200 px-[10px] py-[7px] '
                 'bg-white text-stone-700 placeholder:text-stone-300 '
                 'focus:outline-none focus:border-stone-400 focus:ring-1 focus:ring-stone-300')
    textarea_cls = input_cls + ' resize-y'

    def text_input(name, value='', placeholder='', input_type='text', maxlength=None):
        ml = f' maxlength="{maxlength}"' if maxlength else ''
        return (f'<input type="{input_type}" name="{name}" id="settings-{name}" value="{esc(value)}"'
                f' placeholder="{esc(placeholder)}"{ml} class="{input_cls}">')

    def textarea_input(name, value='', placeholder='', rows=3, maxlength=None):
        ml = f' maxlength="{maxlength}"' if maxlength else ''
        return (f'<textarea name="{name}" id="settings-{name}" rows="{rows}"'
                f' placeholder="{esc(placeholder)}"{ml} class="{textarea_cls}">{esc(value)}</textarea>')

    def checkbox_input(name, checked=False, label=''):
        chk = ' checked' if checked else ''
        return (f'<label class="inline-flex items-center gap-[8px] cursor-pointer">'
                f'<input type="checkbox" name="{name}" id="settings-{name}"{chk}'
                f' class="rounded border-stone-300 text-stone-600 focus:ring-stone-300">'
                f'<span class="text-[14px] text-stone-600">{esc(label)}</span></label>')

    def section(title, content, is_open=False):
        open_attr = ' open' if is_open else ''
        return (f'<details class="border border-stone-200 rounded-[8px] overflow-hidden"{open_attr}>'
                f'<summary class="px-[16px] py-[10px] bg-stone-50 text-[14px] font-medium text-stone-600 cursor-pointer select-none hover:bg-stone-100 transition-colors">{esc(title)}</summary>'
                f'<div class="px-[16px] py-[12px] space-y-[12px]">{content}</div></details>')

    gp = ghost_post

    # General section
    slug_placeholder = 'page-slug' if is_page else 'post-slug'
    pub_at = gp.get("published_at") or ""
    pub_at_val = pub_at[:16] if pub_at else ""
    vis = gp.get("visibility") or "public"
    vis_opts = "".join(
        f'<option value="{v}"{" selected" if vis == v else ""}>{l}</option>'
        for v, l in [("public", "Public"), ("members", "Members"), ("paid", "Paid")]
    )

    general = (
        f'<div>{field_label("Slug", "settings-slug")}{text_input("slug", gp.get("slug") or "", slug_placeholder)}</div>'
        f'<div>{field_label("Published at", "settings-published_at")}'
        f'<input type="datetime-local" name="published_at" id="settings-published_at" value="{esc(pub_at_val)}" class="{input_cls}"></div>'
        f'<div>{checkbox_input("featured", gp.get("featured"), "Featured page" if is_page else "Featured post")}</div>'
        f'<div>{field_label("Visibility", "settings-visibility")}'
        f'<select name="visibility" id="settings-visibility" class="{input_cls}">{vis_opts}</select></div>'
        f'<div>{checkbox_input("email_only", gp.get("email_only"), "Email only")}</div>'
    )

    # Tags
    tags = gp.get("tags") or []
    if tags:
        tag_names = ", ".join(getattr(t, "name", t.get("name", "") if isinstance(t, dict) else str(t)) for t in tags)
    else:
        tag_names = ""
    tags_sec = (
        f'<div>{field_label("Tags (comma-separated)", "settings-tags")}'
        f'{text_input("tags", tag_names, "news, updates, featured")}'
        f'<p class="text-[12px] text-stone-400 mt-[4px]">Unknown tags will be created automatically.</p></div>'
    )

    # Feature image
    fi_sec = f'<div>{field_label("Alt text", "settings-feature_image_alt")}{text_input("feature_image_alt", gp.get("feature_image_alt") or "", "Describe the feature image")}</div>'

    # SEO
    seo_sec = (
        f'<div>{field_label("Meta title", "settings-meta_title")}{text_input("meta_title", gp.get("meta_title") or "", "SEO title", maxlength=300)}'
        f'<p class="text-[12px] text-stone-400 mt-[2px]">Recommended: 70 characters. Max: 300.</p></div>'
        f'<div>{field_label("Meta description", "settings-meta_description")}{textarea_input("meta_description", gp.get("meta_description") or "", "SEO description", rows=2, maxlength=500)}'
        f'<p class="text-[12px] text-stone-400 mt-[2px]">Recommended: 156 characters.</p></div>'
        f'<div>{field_label("Canonical URL", "settings-canonical_url")}{text_input("canonical_url", gp.get("canonical_url") or "", "https://example.com/original-post", input_type="url")}</div>'
    )

    # Facebook / OG
    og_sec = (
        f'<div>{field_label("OG title", "settings-og_title")}{text_input("og_title", gp.get("og_title") or "")}</div>'
        f'<div>{field_label("OG description", "settings-og_description")}{textarea_input("og_description", gp.get("og_description") or "", rows=2)}</div>'
        f'<div>{field_label("OG image URL", "settings-og_image")}{text_input("og_image", gp.get("og_image") or "", "https://...", input_type="url")}</div>'
    )

    # Twitter
    tw_sec = (
        f'<div>{field_label("Twitter title", "settings-twitter_title")}{text_input("twitter_title", gp.get("twitter_title") or "")}</div>'
        f'<div>{field_label("Twitter description", "settings-twitter_description")}{textarea_input("twitter_description", gp.get("twitter_description") or "", rows=2)}</div>'
        f'<div>{field_label("Twitter image URL", "settings-twitter_image")}{text_input("twitter_image", gp.get("twitter_image") or "", "https://...", input_type="url")}</div>'
    )

    # Advanced
    tmpl_placeholder = 'custom-page.hbs' if is_page else 'custom-post.hbs'
    adv_sec = f'<div>{field_label("Custom template", "settings-custom_template")}{text_input("custom_template", gp.get("custom_template") or "", tmpl_placeholder)}</div>'

    sections = (
        section("General", general, is_open=True)
        + section("Tags", tags_sec)
        + section("Feature Image", fi_sec)
        + section("SEO / Meta", seo_sec)
        + section("Facebook / OpenGraph", og_sec)
        + section("X / Twitter", tw_sec)
        + section("Advanced", adv_sec)
    )

    saved_html = '<span class="text-[14px] text-green-600">Saved.</span>' if save_success else ''

    html = (
        f'<form method="post" class="max-w-[640px] mx-auto pb-[48px] px-[16px]">'
        f'<input type="hidden" name="csrf_token" value="{csrf}">'
        f'<input type="hidden" name="updated_at" value="{esc(gp.get("updated_at") or "")}">'
        f'<div class="space-y-[12px] mt-[16px]">{sections}</div>'
        f'<div class="flex items-center gap-[16px] mt-[24px] pt-[16px] border-t border-stone-200">'
        f'<button type="submit" class="px-[20px] py-[6px] bg-stone-700 text-white text-[14px] rounded-[8px] hover:bg-stone-800 transition-colors cursor-pointer">Save settings</button>'
        f'{saved_html}</div></form>'
    )
    return _raw_html_sx(html)


# ===========================================================================

# ===========================================================================

# ===========================================================================

# ===========================================================================

# ===========================================================================

# ===========================================================================

# ===========================================================================
# PUBLIC API — HTMX fragment renderers for POST/PUT/DELETE handlers
# ===========================================================================

# ---- Like toggle button (delegates to market impl) ----

def render_like_toggle_button(slug: str, liked: bool, like_url: str) -> str:
    """Render a like toggle button for HTMX POST response."""
    from market.sx.sx_components import render_like_toggle_button as _market_like
    return _market_like(slug, liked, like_url=like_url, item_type="post")


# ---- Snippets list ----

def render_snippets_list(snippets, is_admin: bool) -> str:
    """Render the snippets list fragment for HTMX DELETE/PATCH responses."""
    from shared.browser.app.csrf import generate_csrf_token
    from quart import g

    ctx = {
        "snippets": snippets,
        "is_admin": is_admin,
        "csrf_token": generate_csrf_token(),
    }
    return _snippets_list_sx(ctx)


# ---- Menu items list + nav OOB ----

def render_menu_items_list(menu_items) -> str:
    """Render the menu items list fragment for HTMX responses."""
    from shared.browser.app.csrf import generate_csrf_token

    ctx = {
        "menu_items": menu_items,
        "csrf_token": generate_csrf_token(),
    }
    return _menu_items_list_sx(ctx)


def render_menu_item_form(menu_item=None) -> str:
    """Render menu item add/edit form (replaces _types/menu_items/_form.html)."""
    from quart import url_for as qurl
    from shared.browser.app.csrf import generate_csrf_token

    csrf = generate_csrf_token()
    search_url = qurl("menu_items.search_pages_route")
    is_edit = menu_item is not None

    if is_edit:
        action_url = qurl("menu_items.update_menu_item_route", item_id=menu_item.id)
        action_attr = f'sx-put="{action_url}"'
        post_id = str(menu_item.container_id) if menu_item.container_id else ""
        label = getattr(menu_item, "label", "") or ""
        slug = getattr(menu_item, "slug", "") or ""
        fi = getattr(menu_item, "feature_image", None) or ""
    else:
        action_url = qurl("menu_items.create_menu_item_route")
        action_attr = f'sx-post="{action_url}"'
        post_id = ""
        label = ""
        slug = ""
        fi = ""

    # Build selected page display
    if post_id:
        img_html = (f'<img src="{fi}" alt="{label}" class="w-10 h-10 rounded-full object-cover" />'
                    if fi else '<div class="w-10 h-10 rounded-full bg-stone-200"></div>')
        selected = (f'<div id="selected-page-display" class="mb-3 p-3 bg-stone-50 rounded flex items-center gap-3">'
                    f'{img_html}<div class="flex-1"><div class="font-medium">{label}</div>'
                    f'<div class="text-xs text-stone-500">{slug}</div></div></div>')
    else:
        selected = '<div id="selected-page-display" class="mb-3 hidden"></div>'

    close_js = "document.getElementById('menu-item-form').innerHTML = ''"
    title = "Edit Menu Item" if is_edit else "Add Menu Item"

    html = f'''<div class="bg-white rounded-lg shadow p-6 mb-6" id="menu-item-form-container">
  <div class="flex justify-between items-center mb-4">
    <h2 class="text-xl font-semibold">{title}</h2>
    <button type="button" onclick="{close_js}" class="text-stone-400 hover:text-stone-600">
      <i class="fa fa-times"></i></button>
  </div>
  <input type="hidden" name="post_id" id="selected-post-id" value="{post_id}" />
  {selected}
  <form {action_attr} sx-target="#menu-items-list" sx-swap="innerHTML"
    sx-include="#selected-post-id"
    sx-on:afterRequest="if(event.detail.successful) {{ {close_js} }}"
    class="space-y-4">
    <input type="hidden" name="csrf_token" value="{csrf}">
    <div class="flex gap-2 pb-3 border-b">
      <button type="submit" class="px-4 py-2 bg-blue-600 text-white rounded hover:bg-blue-700">
        <i class="fa fa-save"></i> Save</button>
      <button type="button" onclick="{close_js}"
        class="px-4 py-2 border border-stone-300 rounded hover:bg-stone-50">Cancel</button>
    </div>
  </form>
  <div class="mt-4">
    <label class="block text-sm font-medium text-stone-700 mb-2">Select Page</label>
    <input type="text" placeholder="Search for a page... (or leave blank for all)"
      sx-get="{search_url}" sx-trigger="keyup changed delay:300ms, focus once"
      sx-target="#page-search-results" sx-swap="innerHTML"
      name="q" id="page-search-input"
      class="w-full px-3 py-2 border border-stone-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500" />
    <div id="page-search-results" class="mt-2"></div>
  </div>
</div>
<script>
document.addEventListener('click', function(e) {{
  var pageOption = e.target.closest('[data-page-id]');
  if (pageOption) {{
    var postId = pageOption.dataset.pageId;
    var postTitle = pageOption.dataset.pageTitle;
    var postSlug = pageOption.dataset.pageSlug;
    var postImage = pageOption.dataset.pageImage;
    document.getElementById('selected-post-id').value = postId;
    var display = document.getElementById('selected-page-display');
    display.innerHTML = '<div class="p-3 bg-stone-50 rounded flex items-center gap-3">' +
      (postImage ? '<img src="' + postImage + '" alt="' + postTitle + '" class="w-10 h-10 rounded-full object-cover" />' : '<div class="w-10 h-10 rounded-full bg-stone-200"></div>') +
      '<div class="flex-1"><div class="font-medium">' + postTitle + '</div><div class="text-xs text-stone-500">' + postSlug + '</div></div></div>';
    display.classList.remove('hidden');
    document.getElementById('page-search-results').innerHTML = '';
  }}
}});
</script>'''
    return html


def render_page_search_results(pages, query, page, has_more) -> str:
    """Render page search results (replaces _types/menu_items/_page_search_results.html)."""
    from quart import url_for as qurl

    if not pages and query:
        return sx_call("page-search-empty", query=query)

    if not pages:
        return ""

    page_dicts = [
        dict(id=post.id, title=post.title, slug=post.slug,
             feature_image=post.feature_image or None)
        for post in pages
    ]

    search_url = qurl("menu_items.search_pages_route") if has_more else None

    return sx_call("page-search-results-from-data",
        pages=page_dicts, query=query, has_more=has_more,
        search_url=search_url, next_page=page + 1)


def render_menu_items_nav_oob(menu_items, ctx: dict | None = None) -> str:
    """Render the OOB nav update for menu items.

    Produces the same DOM structure as ``_types/menu_items/_nav_oob.html``:
    a scrolling nav wrapper with ``id="menu-items-nav-wrapper"`` and
    ``hx-swap-oob="outerHTML"``.
    """
    from quart import request as qrequest

    if not menu_items:
        return sx_call("blog-nav-empty", wrapper_id="menu-items-nav-wrapper")

    if ctx is None:
        ctx = {}

    first_seg = qrequest.path.strip("/").split("/")[0] if qrequest else ""

    select_colours = (
        "[.hover-capable_&]:hover:bg-yellow-300"
        " aria-selected:bg-stone-500 aria-selected:text-white"
        " [.hover-capable_&[aria-selected=true]:hover]:bg-orange-500"
    )
    nav_cls = (
        f"justify-center cursor-pointer flex flex-row items-center gap-2"
        f" rounded bg-stone-200 text-black {select_colours} p-3"
    )

    container_id = "menu-items-container"
    arrow_cls = f"scrolling-menu-arrow-{container_id}"
    scroll_hs = (
        f"on load or scroll"
        f" if window.innerWidth >= 640 and my.scrollWidth > my.clientWidth"
        f" remove .hidden from .{arrow_cls} add .flex to .{arrow_cls}"
        f" else add .hidden to .{arrow_cls} remove .flex from .{arrow_cls} end"
    )

    blog_url_fn = ctx.get("blog_url")
    cart_url_fn = ctx.get("cart_url")
    app_name = ctx.get("app_name", "")

    item_dicts = []
    for item in menu_items:
        item_slug = getattr(item, "slug", "") if hasattr(item, "slug") else item.get("slug", "")
        label = getattr(item, "label", "") if hasattr(item, "label") else item.get("label", "")
        fi = getattr(item, "feature_image", None) if hasattr(item, "feature_image") else item.get("feature_image")

        if item_slug == "cart" and cart_url_fn:
            href = cart_url_fn("/")
        elif blog_url_fn:
            href = blog_url_fn(f"/{item_slug}/")
        else:
            href = f"/{item_slug}/"

        selected = "true" if (item_slug == first_seg or item_slug == app_name) else "false"
        hx_get = f"/{item_slug}/" if item_slug != "cart" else None

        item_dicts.append(dict(
            slug=item_slug, label=label, feature_image=fi,
            href=href, hx_get=hx_get, selected=selected,
        ))

    return sx_call("blog-menu-nav-from-data",
        items=item_dicts, nav_cls=nav_cls, container_id=container_id,
        arrow_cls=arrow_cls, scroll_hs=scroll_hs)


# ---- Features panel ----

def render_features_panel(features: dict, post: dict,
                          sumup_configured: bool,
                          sumup_merchant_code: str,
                          sumup_checkout_prefix: str) -> str:
    """Render the features panel fragment for HTMX PUT responses."""
    from shared.utils import host_url
    from quart import url_for as qurl

    slug = post.get("slug", "")
    features_url = host_url(qurl("blog.post.admin.update_features", slug=slug))
    sumup_url = host_url(qurl("blog.post.admin.update_sumup", slug=slug))

    hs_trigger = "on change trigger submit on closest <form/>"

    form_sx = sx_call("blog-features-form",
        features_url=features_url,
        calendar_checked=bool(features.get("calendar")),
        market_checked=bool(features.get("market")),
        hs_trigger=hs_trigger,
    )

    sumup_sx = ""
    if features.get("calendar") or features.get("market"):
        placeholder = "\u2022" * 8 if sumup_configured else "sup_sk_..."

        sumup_sx = sx_call("blog-sumup-form",
            sumup_url=sumup_url, merchant_code=sumup_merchant_code,
            placeholder=placeholder,
            sumup_configured=sumup_configured,
            checkout_prefix=sumup_checkout_prefix,
        )

    return sx_call("blog-features-panel",
        form=SxExpr(form_sx),
        sumup=SxExpr(sumup_sx) if sumup_sx else None,
    )


# ---- Markets panel ----

def render_markets_panel(markets, post: dict) -> str:
    """Render the markets panel fragment for HTMX responses."""
    from shared.utils import host_url
    from quart import url_for as qurl

    slug = post.get("slug", "")
    create_url = host_url(qurl("blog.post.admin.create_market", slug=slug))

    list_sx = ""
    if markets:
        li_parts = []
        for m in markets:
            m_name = getattr(m, "name", "") if hasattr(m, "name") else m.get("name", "")
            m_slug = getattr(m, "slug", "") if hasattr(m, "slug") else m.get("slug", "")
            del_url = host_url(qurl("blog.post.admin.delete_market", slug=slug, market_slug=m_slug))
            li_parts.append(sx_call("blog-market-item",
                name=m_name, slug=m_slug, delete_url=del_url,
                confirm_text=f"Delete market '{m_name}'?",
            ))
        list_sx = sx_call("blog-markets-list", items=SxExpr("(<> " + " ".join(li_parts) + ")"))
    else:
        list_sx = sx_call("blog-markets-empty")

    return sx_call("blog-markets-panel",
        list=SxExpr(list_sx), create_url=create_url,
    )


# ---- Associated entries ----

def render_associated_entries(all_calendars, associated_entry_ids, post_slug: str) -> str:
    """Render the associated entries panel for HTMX POST responses."""
    from shared.browser.app.csrf import generate_csrf_token
    from quart import url_for as qurl
    from shared.utils import host_url

    csrf = generate_csrf_token()

    has_entries = False
    entry_items: list[str] = []
    for calendar in all_calendars:
        entries = getattr(calendar, "entries", []) or []
        cal_name = getattr(calendar, "name", "")
        cal_post = getattr(calendar, "post", None)
        cal_fi = getattr(cal_post, "feature_image", None) if cal_post else None
        cal_title = getattr(cal_post, "title", "") if cal_post else ""

        for entry in entries:
            e_id = getattr(entry, "id", None)
            if e_id not in associated_entry_ids:
                continue
            if getattr(entry, "deleted_at", None) is not None:
                continue
            has_entries = True
            e_name = getattr(entry, "name", "")
            e_start = getattr(entry, "start_at", None)
            e_end = getattr(entry, "end_at", None)

            toggle_url = host_url(qurl("blog.post.admin.toggle_entry", slug=post_slug, entry_id=e_id))

            img_sx = sx_call("blog-entry-image", src=cal_fi, title=cal_title)

            date_str = e_start.strftime("%A, %B %d, %Y at %H:%M") if e_start else ""
            if e_end:
                date_str += f" \u2013 {e_end.strftime('%H:%M')}"

            entry_items.append(sx_call("blog-associated-entry",
                confirm_text=f"This will remove {e_name} from this post",
                toggle_url=toggle_url,
                hx_headers=f'{{"X-CSRFToken": "{csrf}"}}',
                img=SxExpr(img_sx), name=e_name,
                date_str=f"{cal_name} \u2022 {date_str}",
            ))

    if has_entries:
        content_sx = sx_call("blog-associated-entries-content",
            items=SxExpr("(<> " + " ".join(entry_items) + ")"),
        )
    else:
        content_sx = sx_call("blog-associated-entries-empty")

    return sx_call("blog-associated-entries-panel", content=SxExpr(content_sx))


# ---- Nav entries OOB ----

def render_nav_entries_oob(associated_entries, calendars, post: dict, ctx: dict | None = None) -> str:
    """Render the OOB nav entries swap.

    Produces the ``entries-calendars-nav-wrapper`` OOB element with links
    to associated entries and calendars.
    """
    if ctx is None:
        ctx = {}

    entries_list = []
    if associated_entries and hasattr(associated_entries, "entries"):
        entries_list = associated_entries.entries or []

    has_items = bool(entries_list or calendars)

    if not has_items:
        return sx_call("blog-nav-entries-empty")

    events_url_fn = ctx.get("events_url")

    # nav_button_less_pad style
    select_colours = (
        "[.hover-capable_&]:hover:bg-yellow-300"
        " aria-selected:bg-stone-500 aria-selected:text-white"
        " [.hover-capable_&[aria-selected=true]:hover]:bg-orange-500"
    )
    nav_cls = (
        f"justify-center cursor-pointer flex flex-row items-center gap-2"
        f" rounded bg-stone-200 text-black {select_colours} p-2"
    )

    post_slug = post.get("slug", "")

    scroll_hs = (
        "on load or scroll"
        " if window.innerWidth >= 640 and my.scrollWidth > my.clientWidth"
        " remove .hidden from .entries-nav-arrow add .flex to .entries-nav-arrow"
        " else add .hidden to .entries-nav-arrow remove .flex from .entries-nav-arrow end"
    )

    item_parts = []

    # Entry links
    for entry in entries_list:
        e_name = getattr(entry, "name", "")
        e_start = getattr(entry, "start_at", None)
        e_end = getattr(entry, "end_at", None)
        cal_slug = getattr(entry, "calendar_slug", "")

        if e_start:
            entry_path = (
                f"/{post_slug}/{cal_slug}/"
                f"{e_start.year}/{e_start.month}/{e_start.day}"
                f"/entries/{getattr(entry, 'id', '')}/"
            )
            date_str = e_start.strftime("%b %d, %Y at %H:%M")
            if e_end:
                date_str += f" \u2013 {e_end.strftime('%H:%M')}"
        else:
            entry_path = f"/{post_slug}/{cal_slug}/"
            date_str = ""

        href = events_url_fn(entry_path) if events_url_fn else entry_path

        item_parts.append(sx_call("calendar-entry-nav",
            href=href, nav_class=nav_cls, name=e_name, date_str=date_str,
        ))

    # Calendar links
    for calendar in (calendars or []):
        cal_name = getattr(calendar, "name", "")
        cal_slug = getattr(calendar, "slug", "")
        cal_path = f"/{post_slug}/{cal_slug}/"
        href = events_url_fn(cal_path) if events_url_fn else cal_path

        item_parts.append(sx_call("blog-nav-calendar-item",
            href=href, nav_cls=nav_cls, name=cal_name,
        ))

    items_sx = "(<> " + " ".join(item_parts) + ")" if item_parts else ""

    return sx_call("scroll-nav-wrapper",
        wrapper_id="entries-calendars-nav-wrapper", container_id="associated-items-container",
        arrow_cls="entries-nav-arrow",
        left_hs="on click set #associated-items-container.scrollLeft to #associated-items-container.scrollLeft - 200",
        scroll_hs=scroll_hs,
        right_hs="on click set #associated-items-container.scrollLeft to #associated-items-container.scrollLeft + 200",
        items=SxExpr(items_sx) if items_sx else None, oob=True,
    )
