""" Shared helper functions for s-expression page rendering. These are used by per-service sexp_components.py files to build common page elements (headers, search, etc.) from template context. """ from __future__ import annotations from typing import Any from markupsafe import escape from .page import SEARCH_HEADERS_MOBILE, SEARCH_HEADERS_DESKTOP from .parser import SexpExpr def call_url(ctx: dict, key: str, path: str = "/") -> str: """Call a URL helper from context (e.g., blog_url, account_url).""" fn = ctx.get(key) if callable(fn): return fn(path) return str(fn or "") + path def get_asset_url(ctx: dict) -> str: """Extract the asset URL base from context.""" au = ctx.get("asset_url") if callable(au): result = au("") return result.rsplit("/", 1)[0] if "/" in result else result return au or "" # --------------------------------------------------------------------------- # Sexp-native helper functions — return sexp source (not HTML) # --------------------------------------------------------------------------- def root_header_sexp(ctx: dict, *, oob: bool = False) -> str: """Build the root header row as a sexp call string.""" rights = ctx.get("rights") or {} is_admin = rights.get("admin") if isinstance(rights, dict) else getattr(rights, "admin", False) settings_url = call_url(ctx, "blog_url", "/settings/") if is_admin else "" return sexp_call("header-row-sx", cart_mini=ctx.get("cart_mini") and SexpExpr(str(ctx.get("cart_mini"))), blog_url=call_url(ctx, "blog_url", ""), site_title=ctx.get("base_title", ""), app_label=ctx.get("app_label", ""), nav_tree=ctx.get("nav_tree") and SexpExpr(str(ctx.get("nav_tree"))), auth_menu=ctx.get("auth_menu") and SexpExpr(str(ctx.get("auth_menu"))), nav_panel=ctx.get("nav_panel") and SexpExpr(str(ctx.get("nav_panel"))), settings_url=settings_url, is_admin=is_admin, oob=oob, ) def search_mobile_sexp(ctx: dict) -> str: """Build mobile search input as sexp call string.""" return sexp_call("search-mobile", current_local_href=ctx.get("current_local_href", "/"), search=ctx.get("search", ""), search_count=ctx.get("search_count", ""), hx_select=ctx.get("hx_select", "#main-panel"), search_headers_mobile=SEARCH_HEADERS_MOBILE, ) def search_desktop_sexp(ctx: dict) -> str: """Build desktop search input as sexp call string.""" return sexp_call("search-desktop", current_local_href=ctx.get("current_local_href", "/"), search=ctx.get("search", ""), search_count=ctx.get("search_count", ""), hx_select=ctx.get("hx_select", "#main-panel"), search_headers_desktop=SEARCH_HEADERS_DESKTOP, ) def post_header_sexp(ctx: dict, *, oob: bool = False) -> str: """Build the post-level header row as sexp call string.""" post = ctx.get("post") or {} slug = post.get("slug", "") if not slug: return "" title = (post.get("title") or "")[:160] feature_image = post.get("feature_image") label_sexp = sexp_call("post-label", feature_image=feature_image, title=title) nav_parts: list[str] = [] page_cart_count = ctx.get("page_cart_count", 0) if page_cart_count and page_cart_count > 0: cart_href = call_url(ctx, "cart_url", f"/{slug}/") nav_parts.append(sexp_call("page-cart-badge", href=cart_href, count=str(page_cart_count))) container_nav = ctx.get("container_nav") if container_nav: nav_parts.append( f'(div :id "entries-calendars-nav-wrapper"' f' :class "flex flex-col sm:flex-row sm:items-center gap-2 border-r border-stone-200 mr-2 sm:max-w-2xl"' f' {container_nav})' ) # Admin cog admin_nav = ctx.get("post_admin_nav") if not admin_nav: rights = ctx.get("rights") or {} has_admin = rights.get("admin") if isinstance(rights, dict) else getattr(rights, "admin", False) if has_admin and slug: from quart import request admin_href = call_url(ctx, "blog_url", f"/{slug}/admin/") is_admin_page = "/admin" in request.path sel_cls = "!bg-stone-500 !text-white" if is_admin_page else "" base_cls = ("justify-center cursor-pointer flex flex-row" " items-center gap-2 rounded bg-stone-200 text-black p-3") admin_nav = ( f'(div :class "relative nav-group"' f' (a :href "{admin_href}"' f' :class "{base_cls} {sel_cls}"' f' (i :class "fa fa-cog" :aria-hidden "true")))' ) if admin_nav: nav_parts.append(admin_nav) nav_sexp = "(<> " + " ".join(nav_parts) + ")" if nav_parts else None link_href = call_url(ctx, "blog_url", f"/{slug}/") return sexp_call("menu-row-sx", id="post-row", level=1, link_href=link_href, link_label_content=SexpExpr(label_sexp), nav=SexpExpr(nav_sexp) if nav_sexp else None, child_id="post-header-child", oob=oob, external=True, ) def post_admin_header_sexp(ctx: dict, slug: str, *, oob: bool = False, selected: str = "", admin_href: str = "") -> str: """Post admin header row as sexp call string.""" # Label label_parts = ['(i :class "fa fa-shield-halved" :aria-hidden "true")', '" admin"'] if selected: label_parts.append(f'(span :class "text-white" "{escape(selected)}")') label_sexp = "(<> " + " ".join(label_parts) + ")" # Nav items select_colours = ctx.get("select_colours", "") base_cls = ("justify-center cursor-pointer flex flex-row items-center" " gap-2 rounded bg-stone-200 text-black p-3") selected_cls = ("justify-center cursor-pointer flex flex-row items-center" " gap-2 rounded !bg-stone-500 !text-white p-3") nav_parts: list[str] = [] items = [ ("events_url", f"/{slug}/admin/", "calendars"), ("market_url", f"/{slug}/admin/", "markets"), ("cart_url", f"/{slug}/admin/payments/", "payments"), ("blog_url", f"/{slug}/admin/entries/", "entries"), ("blog_url", f"/{slug}/admin/data/", "data"), ("blog_url", f"/{slug}/admin/edit/", "edit"), ("blog_url", f"/{slug}/admin/settings/", "settings"), ] for url_key, path, label in items: url_fn = ctx.get(url_key) if not callable(url_fn): continue href = url_fn(path) is_sel = label == selected cls = selected_cls if is_sel else base_cls aria = "true" if is_sel else None nav_parts.append( f'(div :class "relative nav-group"' f' (a :href "{escape(href)}"' + (f' :aria-selected "true"' if aria else "") + f' :class "{cls} {escape(select_colours)}"' + f' "{escape(label)}"))' ) nav_sexp = "(<> " + " ".join(nav_parts) + ")" if nav_parts else None if not admin_href: blog_fn = ctx.get("blog_url") admin_href = blog_fn(f"/{slug}/admin/") if callable(blog_fn) else f"/{slug}/admin/" return sexp_call("menu-row-sx", id="post-admin-row", level=2, link_href=admin_href, link_label_content=SexpExpr(label_sexp), nav=SexpExpr(nav_sexp) if nav_sexp else None, child_id="post-admin-header-child", oob=oob, ) def oob_header_sexp(parent_id: str, child_id: str, row_sexp: str) -> str: """Wrap a header row sexp in an OOB swap.""" return sexp_call("oob-header-sx", parent_id=parent_id, child_id=child_id, row=SexpExpr(row_sexp), ) def header_child_sexp(inner_sexp: str, *, id: str = "root-header-child") -> str: """Wrap inner sexp in a header-child div.""" return sexp_call("header-child-sx", id=id, inner=SexpExpr(inner_sexp), ) def oob_page_sexp(*, oobs: str = "", filter: str = "", aside: str = "", content: str = "", menu: str = "") -> str: """Build OOB response as sexp call string.""" return sexp_call("oob-sexp", oobs=SexpExpr(oobs) if oobs else None, filter=SexpExpr(filter) if filter else None, aside=SexpExpr(aside) if aside else None, menu=SexpExpr(menu) if menu else None, content=SexpExpr(content) if content else None, ) def full_page_sexp(ctx: dict, *, header_rows: str, filter: str = "", aside: str = "", content: str = "", menu: str = "", meta_html: str = "", meta: str = "") -> str: """Build a full page using sexp_page() with ~app-body. meta_html: raw HTML injected into the
shell (legacy). meta: sexp source for meta tags — auto-hoisted to by sexp.js. """ body_sexp = sexp_call("app-body", header_rows=SexpExpr(header_rows) if header_rows else None, filter=SexpExpr(filter) if filter else None, aside=SexpExpr(aside) if aside else None, menu=SexpExpr(menu) if menu else None, content=SexpExpr(content) if content else None, ) if meta: # Wrap body + meta in a fragment so sexp.js renders both; # auto-hoist moves meta/title/link elements to . body_sexp = "(<> " + meta + " " + body_sexp + ")" return sexp_page(ctx, body_sexp, meta_html=meta_html) def sexp_call(component_name: str, **kwargs: Any) -> str: """Build an s-expression component call string from Python kwargs. Converts snake_case to kebab-case automatically:: sexp_call("test-row", nodeid="foo", outcome="passed") # => '(~test-row :nodeid "foo" :outcome "passed")' Values are serialized: strings are quoted, None becomes nil, bools become true/false, numbers stay as-is. """ from .parser import serialize name = component_name if component_name.startswith("~") else f"~{component_name}" parts = [name] for key, val in kwargs.items(): parts.append(f":{key.replace('_', '-')}") parts.append(serialize(val)) return "(" + " ".join(parts) + ")" def sexp_response(source_or_component: str, status: int = 200, headers: dict | None = None, **kwargs: Any): """Return an s-expression wire-format response. Can be called with a raw sexp string:: return sexp_response('(~test-row :nodeid "foo")') Or with a component name + kwargs (builds the sexp call):: return sexp_response("test-row", nodeid="foo", outcome="passed") """ from quart import Response if kwargs: source = sexp_call(source_or_component, **kwargs) else: source = source_or_component resp = Response(source, status=status, content_type="text/sexp") if headers: for k, v in headers.items(): resp.headers[k] = v return resp def oob_page(ctx: dict, *, oobs_html: str = "", filter_html: str = "", aside_html: str = "", content_html: str = "", menu_html: str = "") -> str: """Render an OOB response with standard swap targets.""" return render( "oob-response", oobs_html=oobs_html, filter_html=filter_html, aside_html=aside_html, menu_html=menu_html, content_html=content_html, ) # --------------------------------------------------------------------------- # Sexp wire-format full page shell # --------------------------------------------------------------------------- _SEXP_PAGE_TEMPLATE = """\