""" Shared helper functions for s-expression page rendering. These are used by per-service sx_components.py files to build common page elements (headers, search, etc.) from template context. """ from __future__ import annotations import hashlib from pathlib import Path from typing import Any from markupsafe import escape from .page import SEARCH_HEADERS_MOBILE, SEARCH_HEADERS_DESKTOP from .parser import SxExpr # --------------------------------------------------------------------------- # Pre-computed CSS classes for inline sx built by Python helpers # --------------------------------------------------------------------------- # These :class strings appear in post_header_sx / post_admin_header_sx etc. # They're static — scan once at import time so they aren't re-scanned per request. _HELPER_CLASS_SOURCES = [ ':class "flex flex-col sm:flex-row sm:items-center gap-2 border-r border-stone-200 mr-2 sm:max-w-2xl"', ':class "relative nav-group"', ':class "justify-center cursor-pointer flex flex-row items-center gap-2 rounded bg-stone-200 text-black p-3"', ':class "!bg-stone-500 !text-white"', ':class "fa fa-cog"', ':class "fa fa-shield-halved"', ':class "text-white"', ':class "justify-center cursor-pointer flex flex-row items-center gap-2 rounded !bg-stone-500 !text-white p-3"', ] def _scan_helper_classes() -> frozenset[str]: """Scan the static class strings from helper functions once.""" from .css_registry import scan_classes_from_sx combined = " ".join(_HELPER_CLASS_SOURCES) return frozenset(scan_classes_from_sx(combined)) HELPER_CSS_CLASSES: frozenset[str] = _scan_helper_classes() 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 "" # --------------------------------------------------------------------------- # Sx-native helper functions — return sx source (not HTML) # --------------------------------------------------------------------------- def _as_sx(val: Any) -> SxExpr | None: """Coerce a fragment value to SxExpr. If *val* is already a ``SxExpr`` (from a ``text/sx`` fragment), return it as-is. If it's a non-empty string (HTML from a ``text/html`` fragment), wrap it in ``~rich-text``. Otherwise return ``None``. """ if not val: return None if isinstance(val, SxExpr): return val html = str(val) escaped = html.replace("\\", "\\\\").replace('"', '\\"') return SxExpr(f'(~rich-text :html "{escaped}")') def root_header_sx(ctx: dict, *, oob: bool = False) -> str: """Build the root header row as a sx 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 sx_call("header-row-sx", cart_mini=_as_sx(ctx.get("cart_mini")), blog_url=call_url(ctx, "blog_url", ""), site_title=ctx.get("base_title", ""), app_label=ctx.get("app_label", ""), nav_tree=_as_sx(ctx.get("nav_tree")), auth_menu=_as_sx(ctx.get("auth_menu")), nav_panel=_as_sx(ctx.get("nav_panel")), settings_url=settings_url, is_admin=is_admin, oob=oob, ) def mobile_menu_sx(*sections: str) -> str: """Assemble mobile menu from pre-built sections (deepest first).""" parts = [s for s in sections if s] return "(<> " + " ".join(parts) + ")" if parts else "" def mobile_root_nav_sx(ctx: dict) -> str: """Root-level mobile nav via ~mobile-root-nav component.""" nav_tree = ctx.get("nav_tree") or "" auth_menu = ctx.get("auth_menu") or "" if not nav_tree and not auth_menu: return "" return sx_call("mobile-root-nav", nav_tree=_as_sx(nav_tree), auth_menu=_as_sx(auth_menu), ) # --------------------------------------------------------------------------- # Shared nav-item builders — used by BOTH desktop headers and mobile menus # --------------------------------------------------------------------------- def _post_nav_items_sx(ctx: dict) -> str: """Build post-level nav items (container_nav + admin cog). Shared by ``post_header_sx`` (desktop) and ``post_mobile_nav_sx`` (mobile).""" post = ctx.get("post") or {} slug = post.get("slug", "") if not slug: return "" 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}/") parts.append(sx_call("page-cart-badge", href=cart_href, count=str(page_cart_count))) container_nav = str(ctx.get("container_nav") or "").strip() # Skip empty fragment wrappers like "(<> )" if container_nav and container_nav.replace("(<>", "").replace(")", "").strip(): parts.append( f'(div :id "entries-calendars-nav-wrapper"' f' :class "flex flex-col sm:flex-row sm:items-center gap-2' f' 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 = ctx.get("is_admin_section") or "/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: parts.append(admin_nav) return "(<> " + " ".join(parts) + ")" if parts else "" def _post_admin_nav_items_sx(ctx: dict, slug: str, selected: str = "") -> str: """Build post-admin nav items (calendars, markets, etc.). Shared by ``post_admin_header_sx`` (desktop) and mobile menu.""" select_colours = ctx.get("select_colours", "") 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/preview/", "preview"), ("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 parts.append(sx_call("nav-link", href=href, label=label, select_colours=select_colours, is_selected=is_sel or None)) return "(<> " + " ".join(parts) + ")" if parts else "" # --------------------------------------------------------------------------- # Mobile menu section builders — wrap shared nav items for hamburger panel # --------------------------------------------------------------------------- def post_mobile_nav_sx(ctx: dict) -> str: """Post-level mobile menu section.""" nav = _post_nav_items_sx(ctx) if not nav: return "" post = ctx.get("post") or {} slug = post.get("slug", "") title = (post.get("title") or slug)[:40] return sx_call("mobile-menu-section", label=title, href=call_url(ctx, "blog_url", f"/{slug}/"), level=1, items=SxExpr(nav), ) def post_admin_mobile_nav_sx(ctx: dict, slug: str, selected: str = "") -> str: """Post-admin mobile menu section.""" nav = _post_admin_nav_items_sx(ctx, slug, selected) if not nav: return "" admin_href = call_url(ctx, "blog_url", f"/{slug}/admin/") return sx_call("mobile-menu-section", label="admin", href=admin_href, level=2, items=SxExpr(nav), ) def search_mobile_sx(ctx: dict) -> str: """Build mobile search input as sx call string.""" return sx_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_sx(ctx: dict) -> str: """Build desktop search input as sx call string.""" return sx_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_sx(ctx: dict, *, oob: bool = False) -> str: """Build the post-level header row as sx 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_sx = sx_call("post-label", feature_image=feature_image, title=title) nav_sx = _post_nav_items_sx(ctx) or None link_href = call_url(ctx, "blog_url", f"/{slug}/") return sx_call("menu-row-sx", id="post-row", level=1, link_href=link_href, link_label_content=SxExpr(label_sx), nav=SxExpr(nav_sx) if nav_sx else None, child_id="post-header-child", oob=oob, external=True, ) def post_admin_header_sx(ctx: dict, slug: str, *, oob: bool = False, selected: str = "", admin_href: str = "") -> str: """Post admin header row as sx 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_sx = "(<> " + " ".join(label_parts) + ")" nav_sx = _post_admin_nav_items_sx(ctx, slug, selected) or 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 sx_call("menu-row-sx", id="post-admin-row", level=2, link_href=admin_href, link_label_content=SxExpr(label_sx), nav=SxExpr(nav_sx) if nav_sx else None, child_id="post-admin-header-child", oob=oob, ) def oob_header_sx(parent_id: str, child_id: str, row_sx: str) -> str: """Wrap a header row sx in an OOB swap. child_id is accepted for call-site compatibility but no longer used — the child placeholder is created by ~menu-row-sx itself. """ return sx_call("oob-header-sx", parent_id=parent_id, row=SxExpr(row_sx), ) def header_child_sx(inner_sx: str, *, id: str = "root-header-child") -> str: """Wrap inner sx in a header-child div.""" return sx_call("header-child-sx", id=id, inner=SxExpr(f"(<> {inner_sx})"), ) def oob_page_sx(*, oobs: str = "", filter: str = "", aside: str = "", content: str = "", menu: str = "") -> str: """Build OOB response as sx call string.""" return sx_call("oob-sx", oobs=SxExpr(f"(<> {oobs})") if oobs else None, filter=SxExpr(filter) if filter else None, aside=SxExpr(aside) if aside else None, menu=SxExpr(menu) if menu else None, content=SxExpr(content) if content else None, ) def full_page_sx(ctx: dict, *, header_rows: str, filter: str = "", aside: str = "", content: str = "", menu: str = "", meta_html: str = "", meta: str = "") -> str: """Build a full page using sx_page() with ~app-body. meta_html: raw HTML injected into the shell (legacy). meta: sx source for meta tags — auto-hoisted to by sx.js. """ # Auto-generate mobile nav from context when no menu provided if not menu: menu = mobile_root_nav_sx(ctx) body_sx = sx_call("app-body", header_rows=SxExpr(f"(<> {header_rows})") if header_rows else None, filter=SxExpr(filter) if filter else None, aside=SxExpr(aside) if aside else None, menu=SxExpr(menu) if menu else None, content=SxExpr(content) if content else None, ) if meta: # Wrap body + meta in a fragment so sx.js renders both; # auto-hoist moves meta/title/link elements to . body_sx = "(<> " + meta + " " + body_sx + ")" return sx_page(ctx, body_sx, meta_html=meta_html) def sx_call(component_name: str, **kwargs: Any) -> str: """Build an s-expression component call string from Python kwargs. Converts snake_case to kebab-case automatically:: sx_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 components_for_request() -> str: """Return defcomp/defmacro source for definitions the client doesn't have yet. Reads the ``SX-Components`` header (comma-separated component names like ``~card,~nav-item``) and returns only the definitions the client is missing. If the header is absent, returns all defs. """ from quart import request from .jinja_bridge import client_components_tag, _COMPONENT_ENV from .types import Component, Macro from .parser import serialize loaded_raw = request.headers.get("SX-Components", "") if not loaded_raw: # Client has nothing — send all tag = client_components_tag() if not tag: return "" start = tag.find(">") + 1 end = tag.rfind("") return tag[start:end] if start > 0 and end > start else "" loaded = set(loaded_raw.split(",")) parts = [] for key, val in _COMPONENT_ENV.items(): if isinstance(val, Component): # Skip components the client already has if f"~{val.name}" in loaded or val.name in loaded: continue # Reconstruct defcomp source param_strs = ["&key"] + list(val.params) if val.has_children: param_strs.extend(["&rest", "children"]) params_sx = "(" + " ".join(param_strs) + ")" body_sx = serialize(val.body, pretty=True) parts.append(f"(defcomp ~{val.name} {params_sx} {body_sx})") elif isinstance(val, Macro): if val.name in loaded: continue param_strs = list(val.params) if val.rest_param: param_strs.extend(["&rest", val.rest_param]) params_sx = "(" + " ".join(param_strs) + ")" body_sx = serialize(val.body, pretty=True) parts.append(f"(defmacro {val.name} {params_sx} {body_sx})") return "\n".join(parts) def sx_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 sx string:: return sx_response('(~test-row :nodeid "foo")') Or with a component name + kwargs (builds the sx call):: return sx_response("test-row", nodeid="foo", outcome="passed") For SX requests, missing component definitions are prepended as a ``\n{body}') # On-demand CSS: scan source for classes, send only new rules from .css_registry import scan_classes_from_sx, lookup_rules, registry_loaded, lookup_css_hash, store_css_hash new_classes: set[str] = set() cumulative_classes: set[str] = set() if registry_loaded(): new_classes = scan_classes_from_sx(source) # Include pre-computed helper classes (menu bars, admin nav, etc.) new_classes.update(HELPER_CSS_CLASSES) if comp_defs: # Scan only the component definitions actually being sent new_classes.update(scan_classes_from_sx(comp_defs)) # Resolve known classes from SX-Css header (hash or full list) known_classes: set[str] = set() known_raw = request.headers.get("SX-Css", "") if known_raw: if len(known_raw) <= 16: # Treat as hash looked_up = lookup_css_hash(known_raw) if looked_up is not None: known_classes = looked_up else: # Cache miss — send all classes (safe fallback) known_classes = set() else: known_classes = set(known_raw.split(",")) cumulative_classes = known_classes | new_classes new_classes -= known_classes if new_classes: new_rules = lookup_rules(new_classes) if new_rules: body = f'\n{body}' # Dev mode: pretty-print sx source for readable Network tab responses if _is_dev_mode(): body = _pretty_print_sx_body(body) resp = Response(body, status=status, content_type="text/sx") if new_classes: resp.headers["SX-Css-Add"] = ",".join(sorted(new_classes)) if cumulative_classes: resp.headers["SX-Css-Hash"] = store_css_hash(cumulative_classes) if headers: for k, v in headers.items(): resp.headers[k] = v return resp # --------------------------------------------------------------------------- # Sx wire-format full page shell # --------------------------------------------------------------------------- _SX_PAGE_TEMPLATE = """\ {title} {meta_html} """ def sx_page(ctx: dict, page_sx: str, *, meta_html: str = "") -> str: """Return a minimal HTML shell that boots the page from sx source. The browser loads component definitions and page sx, then sx.js renders everything client-side. CSS rules are scanned from the sx source and component defs, then injected as a