From 0a81a2af0143818d672c02c06336dcd85e48f3f6 Mon Sep 17 00:00:00 2001 From: giles Date: Tue, 3 Mar 2026 17:43:47 +0000 Subject: [PATCH 001/105] Convert social and federation profile from Jinja to SX rendering Add primitives (replace, strip-tags, slice, csrf-token), convert all social blueprint routes and federation profile to SX content builders, delete 12 unused Jinja templates and social_lite layout. Co-Authored-By: Claude Opus 4.6 --- federation/templates/federation/profile.html | 32 - .../_types/social_lite/header/_header.html | 42 -- .../templates/_types/social_lite/index.html | 10 - .../templates/social/_actor_list_items.html | 63 -- .../browser/templates/social/_post_card.html | 53 -- .../templates/social/_search_results.html | 61 -- .../templates/social/_timeline_items.html | 13 - .../templates/social/actor_timeline.html | 53 -- .../browser/templates/social/followers.html | 12 - .../browser/templates/social/following.html | 13 - shared/browser/templates/social/index.html | 33 - shared/browser/templates/social/search.html | 32 - shared/infrastructure/activitypub.py | 79 ++- shared/infrastructure/ap_social.py | 141 ++--- shared/infrastructure/ap_social_sx.py | 593 ++++++++++++++++++ shared/infrastructure/factory.py | 2 + shared/sx/primitives.py | 18 + shared/sx/primitives_io.py | 13 + 18 files changed, 760 insertions(+), 503 deletions(-) delete mode 100644 federation/templates/federation/profile.html delete mode 100644 shared/browser/templates/_types/social_lite/header/_header.html delete mode 100644 shared/browser/templates/_types/social_lite/index.html delete mode 100644 shared/browser/templates/social/_actor_list_items.html delete mode 100644 shared/browser/templates/social/_post_card.html delete mode 100644 shared/browser/templates/social/_search_results.html delete mode 100644 shared/browser/templates/social/_timeline_items.html delete mode 100644 shared/browser/templates/social/actor_timeline.html delete mode 100644 shared/browser/templates/social/followers.html delete mode 100644 shared/browser/templates/social/following.html delete mode 100644 shared/browser/templates/social/index.html delete mode 100644 shared/browser/templates/social/search.html create mode 100644 shared/infrastructure/ap_social_sx.py diff --git a/federation/templates/federation/profile.html b/federation/templates/federation/profile.html deleted file mode 100644 index 2e21a08..0000000 --- a/federation/templates/federation/profile.html +++ /dev/null @@ -1,32 +0,0 @@ -{% extends "_types/social/index.html" %} -{% block title %}@{{ actor.preferred_username }} — Rose Ash{% endblock %} -{% block social_content %} -
-
-

{{ actor.display_name or actor.preferred_username }}

-

@{{ actor.preferred_username }}@{{ config.get('ap_domain', 'rose-ash.com') }}

- {% if actor.summary %} -

{{ actor.summary }}

- {% endif %} -
- -

Activities ({{ total }})

- {% if activities %} -
- {% for a in activities %} -
-
- {{ a.activity_type }} - {{ a.published.strftime('%Y-%m-%d %H:%M') if a.published }} -
- {% if a.object_type %} - {{ a.object_type }} - {% endif %} -
- {% endfor %} -
- {% else %} -

No activities yet.

- {% endif %} -
-{% endblock %} diff --git a/shared/browser/templates/_types/social_lite/header/_header.html b/shared/browser/templates/_types/social_lite/header/_header.html deleted file mode 100644 index 6e7b337..0000000 --- a/shared/browser/templates/_types/social_lite/header/_header.html +++ /dev/null @@ -1,42 +0,0 @@ -{% import 'macros/links.html' as links %} -{% macro header_row(oob=False) %} - {% call links.menu_row(id='social-lite-row', oob=oob) %} - - {% endcall %} -{% endmacro %} diff --git a/shared/browser/templates/_types/social_lite/index.html b/shared/browser/templates/_types/social_lite/index.html deleted file mode 100644 index dc3e25d..0000000 --- a/shared/browser/templates/_types/social_lite/index.html +++ /dev/null @@ -1,10 +0,0 @@ -{% extends '_types/root/_index.html' %} -{% block meta %}{% endblock %} -{% block root_header_child %} - {% from '_types/root/_n/macros.html' import index_row with context %} - {% call index_row('social-lite-header-child', '_types/social_lite/header/_header.html') %} - {% endcall %} -{% endblock %} -{% block content %} - {% block social_content %}{% endblock %} -{% endblock %} diff --git a/shared/browser/templates/social/_actor_list_items.html b/shared/browser/templates/social/_actor_list_items.html deleted file mode 100644 index ad47be4..0000000 --- a/shared/browser/templates/social/_actor_list_items.html +++ /dev/null @@ -1,63 +0,0 @@ -{% for a in actors %} -
- {% if a.icon_url %} - - {% else %} -
- {{ (a.display_name or a.preferred_username)[0] | upper }} -
- {% endif %} - -
- {% if list_type == "following" and a.id %} - - {{ a.display_name or a.preferred_username }} - - {% else %} - - {{ a.display_name or a.preferred_username }} - - {% endif %} -
@{{ a.preferred_username }}@{{ a.domain }}
- {% if a.summary %} -
{{ a.summary | striptags }}
- {% endif %} -
- - {% if actor %} -
- {% if list_type == "following" or a.actor_url in (followed_urls or []) %} -
- - - -
- {% else %} -
- - - -
- {% endif %} -
- {% endif %} -
-{% endfor %} - -{% if actors | length >= 20 %} -
-
-{% endif %} diff --git a/shared/browser/templates/social/_post_card.html b/shared/browser/templates/social/_post_card.html deleted file mode 100644 index 5556652..0000000 --- a/shared/browser/templates/social/_post_card.html +++ /dev/null @@ -1,53 +0,0 @@ -
- {% if item.boosted_by %} -
- Boosted by {{ item.boosted_by }} -
- {% endif %} - -
- {% if item.actor_icon %} - - {% else %} -
- {{ item.actor_name[0] | upper if item.actor_name else '?' }} -
- {% endif %} - -
-
- {{ item.actor_name }} - - @{{ item.actor_username }}{% if item.actor_domain %}@{{ item.actor_domain }}{% endif %} - - - {% if item.published %} - {{ item.published.strftime('%b %d, %H:%M') }} - {% endif %} - -
- - {% if item.summary %} -
- CW: {{ item.summary }} -
{{ item.content | safe }}
-
- {% else %} -
{{ item.content | safe }}
- {% endif %} - -
- {% if item.url and item.post_type == "remote" %} - - original - - {% endif %} - {% if item.object_id %} - - View on Hub - - {% endif %} -
-
-
-
diff --git a/shared/browser/templates/social/_search_results.html b/shared/browser/templates/social/_search_results.html deleted file mode 100644 index ad3c88a..0000000 --- a/shared/browser/templates/social/_search_results.html +++ /dev/null @@ -1,61 +0,0 @@ -{% for a in actors %} -
- {% if a.icon_url %} - - {% else %} -
- {{ (a.display_name or a.preferred_username)[0] | upper }} -
- {% endif %} - -
- {% if a.id %} - - {{ a.display_name or a.preferred_username }} - - {% else %} - {{ a.display_name or a.preferred_username }} - {% endif %} -
@{{ a.preferred_username }}@{{ a.domain }}
- {% if a.summary %} -
{{ a.summary | striptags }}
- {% endif %} -
- - {% if actor %} -
- {% if a.actor_url in (followed_urls or []) %} -
- - - -
- {% else %} -
- - - -
- {% endif %} -
- {% endif %} -
-{% endfor %} - -{% if actors | length >= 20 %} -
-
-{% endif %} diff --git a/shared/browser/templates/social/_timeline_items.html b/shared/browser/templates/social/_timeline_items.html deleted file mode 100644 index b30ee30..0000000 --- a/shared/browser/templates/social/_timeline_items.html +++ /dev/null @@ -1,13 +0,0 @@ -{% for item in items %} - {% include "social/_post_card.html" %} -{% endfor %} - -{% if items %} - {% set last = items[-1] %} - {% if timeline_type == "actor" %} -
-
- {% endif %} -{% endif %} diff --git a/shared/browser/templates/social/actor_timeline.html b/shared/browser/templates/social/actor_timeline.html deleted file mode 100644 index ed34232..0000000 --- a/shared/browser/templates/social/actor_timeline.html +++ /dev/null @@ -1,53 +0,0 @@ -{% extends "_types/social_lite/index.html" %} - -{% block title %}{{ remote_actor.display_name or remote_actor.preferred_username }} — Rose Ash{% endblock %} - -{% block social_content %} -
-
- {% if remote_actor.icon_url %} - - {% else %} -
- {{ (remote_actor.display_name or remote_actor.preferred_username)[0] | upper }} -
- {% endif %} - -
-

{{ remote_actor.display_name or remote_actor.preferred_username }}

-
@{{ remote_actor.preferred_username }}@{{ remote_actor.domain }}
- {% if remote_actor.summary %} -
{{ remote_actor.summary | safe }}
- {% endif %} -
- - {% if actor %} -
- {% if is_following %} -
- - - -
- {% else %} -
- - - -
- {% endif %} -
- {% endif %} -
-
- -
- {% set timeline_type = "actor" %} - {% set actor_id = remote_actor.id %} - {% include "social/_timeline_items.html" %} -
-{% endblock %} diff --git a/shared/browser/templates/social/followers.html b/shared/browser/templates/social/followers.html deleted file mode 100644 index 9421537..0000000 --- a/shared/browser/templates/social/followers.html +++ /dev/null @@ -1,12 +0,0 @@ -{% extends "_types/social_lite/index.html" %} - -{% block title %}Followers — Rose Ash{% endblock %} - -{% block social_content %} -

Followers ({{ total }})

- -
- {% set list_type = "followers" %} - {% include "social/_actor_list_items.html" %} -
-{% endblock %} diff --git a/shared/browser/templates/social/following.html b/shared/browser/templates/social/following.html deleted file mode 100644 index 5643b94..0000000 --- a/shared/browser/templates/social/following.html +++ /dev/null @@ -1,13 +0,0 @@ -{% extends "_types/social_lite/index.html" %} - -{% block title %}Following — Rose Ash{% endblock %} - -{% block social_content %} -

Following ({{ total }})

- -
- {% set list_type = "following" %} - {% set followed_urls = [] %} - {% include "social/_actor_list_items.html" %} -
-{% endblock %} diff --git a/shared/browser/templates/social/index.html b/shared/browser/templates/social/index.html deleted file mode 100644 index 4d32685..0000000 --- a/shared/browser/templates/social/index.html +++ /dev/null @@ -1,33 +0,0 @@ -{% extends "_types/social_lite/index.html" %} - -{% block title %}Social — Rose Ash{% endblock %} - -{% block social_content %} -

Social

- -{% if actor %} - -{% else %} -

- Search for accounts on the fediverse, or visit the - Hub to get started. -

-{% endif %} -{% endblock %} diff --git a/shared/browser/templates/social/search.html b/shared/browser/templates/social/search.html deleted file mode 100644 index 682aadc..0000000 --- a/shared/browser/templates/social/search.html +++ /dev/null @@ -1,32 +0,0 @@ -{% extends "_types/social_lite/index.html" %} - -{% block title %}Search — Rose Ash{% endblock %} - -{% block social_content %} -

Search

- -
-
- - -
-
- -{% if query and total %} -

{{ total }} result{{ 's' if total != 1 }} for {{ query }}

-{% elif query %} -

No results found for {{ query }}

-{% endif %} - -
- {% include "social/_search_results.html" %} -
-{% endblock %} diff --git a/shared/infrastructure/activitypub.py b/shared/infrastructure/activitypub.py index 1d43209..b8cead2 100644 --- a/shared/infrastructure/activitypub.py +++ b/shared/infrastructure/activitypub.py @@ -57,6 +57,77 @@ def _is_aggregate(app_name: str) -> bool: return app_name == "federation" +async def _render_profile_sx(actor, activities, total): + """Render the federation actor profile page using SX.""" + from markupsafe import escape + from shared.sx.page import get_template_context + from shared.sx.helpers import full_page_sx, oob_page_sx, sx_response + from shared.browser.app.utils.htmx import is_htmx_request + from shared.config import config + + def _e(v): + s = str(v) if v else "" + return str(escape(s)).replace('"', '\\"') + + username = _e(actor.preferred_username) + display_name = _e(actor.display_name or actor.preferred_username) + ap_domain = config().get("ap_domain", "rose-ash.com") + + summary_el = "" + if actor.summary: + summary_el = f'(p :class "mt-2" "{_e(actor.summary)}")' + + activity_items = [] + for a in activities: + ts = "" + if a.published: + ts = a.published.strftime("%Y-%m-%d %H:%M") + obj_el = "" + if a.object_type: + obj_el = f'(span :class "text-sm text-stone-500" "{_e(a.object_type)}")' + activity_items.append( + f'(div :class "bg-white rounded-lg shadow p-4"' + f' (div :class "flex justify-between items-start"' + f' (span :class "font-medium" "{_e(a.activity_type)}")' + f' (span :class "text-sm text-stone-400" "{_e(ts)}"))' + f' {obj_el})') + + if activities: + activities_el = ('(div :class "space-y-4" ' + + " ".join(activity_items) + ")") + else: + activities_el = '(p :class "text-stone-500" "No activities yet.")' + + content = ( + f'(div :id "main-panel"' + f' (div :class "py-8"' + f' (div :class "bg-white rounded-lg shadow p-6 mb-6"' + f' (h1 :class "text-2xl font-bold" "{display_name}")' + f' (p :class "text-stone-500" "@{username}@{_e(ap_domain)}")' + f' {summary_el})' + f' (h2 :class "text-xl font-bold mb-4" "Activities ({total})")' + f' {activities_el}))') + + tctx = await get_template_context() + + if is_htmx_request(): + # Import federation layout for OOB headers + try: + from federation.sxc.pages import _social_oob + oob_headers = _social_oob(tctx) + except ImportError: + oob_headers = "" + return sx_response(oob_page_sx(oobs=oob_headers, content=content)) + else: + try: + from federation.sxc.pages import _social_full + header_rows = _social_full(tctx) + except ImportError: + from shared.sx.helpers import root_header_sx + header_rows = root_header_sx(tctx) + return full_page_sx(tctx, header_rows=header_rows, content=content) + + def create_activitypub_blueprint(app_name: str) -> Blueprint: """Return a Blueprint with AP endpoints for *app_name*.""" bp = Blueprint("activitypub", __name__) @@ -272,16 +343,10 @@ def create_activitypub_blueprint(app_name: str) -> Blueprint: # HTML: federation renders its own profile; other apps redirect there if aggregate: - from quart import render_template activities, total = await services.federation.get_outbox( g._ap_s, username, page=1, per_page=20, ) - return await render_template( - "federation/profile.html", - actor=actor, - activities=activities, - total=total, - ) + return await _render_profile_sx(actor, activities, total) from quart import redirect return redirect(f"https://{fed_domain}/users/{username}") diff --git a/shared/infrastructure/ap_social.py b/shared/infrastructure/ap_social.py index a6c1cb4..ba9c0c2 100644 --- a/shared/infrastructure/ap_social.py +++ b/shared/infrastructure/ap_social.py @@ -2,13 +2,15 @@ Lightweight social UI for blog/market/events. Federation keeps the full social hub (timeline, compose, notifications, interactions). + +All rendering uses s-expressions (no Jinja templates). """ from __future__ import annotations import logging from datetime import datetime -from quart import Blueprint, request, g, redirect, url_for, abort, render_template, Response +from quart import Blueprint, request, g, redirect, url_for, abort, Response from shared.services.registry import services @@ -77,15 +79,36 @@ def create_ap_social_blueprint(app_name: str) -> Blueprint: abort(403, "You need to choose a federation username first") return actor + async def _render_social_page(content: str, actor=None, title: str = "Social"): + """Render a full social page or OOB response depending on request type.""" + from shared.browser.app.utils.htmx import is_htmx_request + from shared.sx.page import get_template_context + from shared.sx.helpers import full_page_sx, oob_page_sx, sx_response + from shared.infrastructure.ap_social_sx import ( + _social_full_headers, _social_oob_headers, + ) + + tctx = await get_template_context() + kw = {"actor": actor} + + if is_htmx_request(): + oob_headers = _social_oob_headers(tctx, **kw) + return sx_response(oob_page_sx( + oobs=oob_headers, + content=content, + )) + else: + header_rows = _social_full_headers(tctx, **kw) + return full_page_sx(tctx, header_rows=header_rows, content=content) + # -- Index ---------------------------------------------------------------- @bp.get("/") async def index(): actor = getattr(g, "_social_actor", None) - return await render_template( - "social/index.html", - actor=actor, - ) + from shared.infrastructure.ap_social_sx import social_index_content_sx + content = social_index_content_sx(actor) + return await _render_social_page(content, actor, title="Social") # -- Search --------------------------------------------------------------- @@ -103,15 +126,9 @@ def create_ap_social_blueprint(app_name: str) -> Blueprint: g._ap_s, actor.preferred_username, page=1, per_page=1000, ) followed_urls = {a.actor_url for a in following} - return await render_template( - "social/search.html", - query=query, - actors=actors, - total=total, - page=1, - followed_urls=followed_urls, - actor=actor, - ) + from shared.infrastructure.ap_social_sx import social_search_content_sx + content = social_search_content_sx(query, actors, total, 1, followed_urls, actor) + return await _render_social_page(content, actor, title="Search") @bp.get("/search/page") async def search_page(): @@ -130,15 +147,10 @@ def create_ap_social_blueprint(app_name: str) -> Blueprint: g._ap_s, actor.preferred_username, page=1, per_page=1000, ) followed_urls = {a.actor_url for a in following} - return await render_template( - "social/_search_results.html", - actors=actors, - total=total, - page=page, - query=query, - followed_urls=followed_urls, - actor=actor, - ) + from shared.infrastructure.ap_social_sx import search_results_sx + from shared.sx.helpers import sx_response + content = search_results_sx(actors, total, page, query, followed_urls, actor) + return sx_response(content) # -- Follow / Unfollow ---------------------------------------------------- @@ -169,7 +181,7 @@ def create_ap_social_blueprint(app_name: str) -> Blueprint: return redirect(request.referrer or url_for("ap_social.search")) async def _actor_card_response(actor, remote_actor_url, is_followed): - """Re-render a single actor card after follow/unfollow via HTMX.""" + """Re-render a single actor card after follow/unfollow.""" remote_dto = await services.federation.get_or_fetch_remote_actor( g._ap_s, remote_actor_url, ) @@ -181,15 +193,12 @@ def create_ap_social_blueprint(app_name: str) -> Blueprint: list_type = "followers" else: list_type = "following" - return await render_template( - "social/_actor_list_items.html", - actors=[remote_dto], - total=0, - page=1, - list_type=list_type, - followed_urls=followed_urls, - actor=actor, + from shared.infrastructure.ap_social_sx import actor_list_items_sx + from shared.sx.helpers import sx_response + content = actor_list_items_sx( + [remote_dto], 0, 1, list_type, followed_urls, actor, ) + return sx_response(content) # -- Followers ------------------------------------------------------------ @@ -203,14 +212,9 @@ def create_ap_social_blueprint(app_name: str) -> Blueprint: g._ap_s, actor.preferred_username, page=1, per_page=1000, ) followed_urls = {a.actor_url for a in following} - return await render_template( - "social/followers.html", - actors=actors, - total=total, - page=1, - followed_urls=followed_urls, - actor=actor, - ) + from shared.infrastructure.ap_social_sx import social_followers_content_sx + content = social_followers_content_sx(actors, total, 1, followed_urls, actor) + return await _render_social_page(content, actor, title="Followers") @bp.get("/followers/page") async def followers_list_page(): @@ -223,15 +227,10 @@ def create_ap_social_blueprint(app_name: str) -> Blueprint: g._ap_s, actor.preferred_username, page=1, per_page=1000, ) followed_urls = {a.actor_url for a in following} - return await render_template( - "social/_actor_list_items.html", - actors=actors, - total=total, - page=page, - list_type="followers", - followed_urls=followed_urls, - actor=actor, - ) + from shared.infrastructure.ap_social_sx import actor_list_items_sx + from shared.sx.helpers import sx_response + content = actor_list_items_sx(actors, total, page, "followers", followed_urls, actor) + return sx_response(content) # -- Following ------------------------------------------------------------ @@ -241,13 +240,9 @@ def create_ap_social_blueprint(app_name: str) -> Blueprint: actors, total = await services.federation.get_following( g._ap_s, actor.preferred_username, ) - return await render_template( - "social/following.html", - actors=actors, - total=total, - page=1, - actor=actor, - ) + from shared.infrastructure.ap_social_sx import social_following_content_sx + content = social_following_content_sx(actors, total, 1, actor) + return await _render_social_page(content, actor, title="Following") @bp.get("/following/page") async def following_list_page(): @@ -256,15 +251,10 @@ def create_ap_social_blueprint(app_name: str) -> Blueprint: actors, total = await services.federation.get_following( g._ap_s, actor.preferred_username, page=page, ) - return await render_template( - "social/_actor_list_items.html", - actors=actors, - total=total, - page=page, - list_type="following", - followed_urls=set(), - actor=actor, - ) + from shared.infrastructure.ap_social_sx import actor_list_items_sx + from shared.sx.helpers import sx_response + content = actor_list_items_sx(actors, total, page, "following", set(), actor) + return sx_response(content) # -- Actor timeline ------------------------------------------------------- @@ -295,13 +285,9 @@ def create_ap_social_blueprint(app_name: str) -> Blueprint: ) ).scalar_one_or_none() is_following = existing is not None - return await render_template( - "social/actor_timeline.html", - remote_actor=remote_dto, - items=items, - is_following=is_following, - actor=actor, - ) + from shared.infrastructure.ap_social_sx import social_actor_timeline_content_sx + content = social_actor_timeline_content_sx(remote_dto, items, is_following, actor) + return await _render_social_page(content, actor) @bp.get("/actor//timeline") async def actor_timeline_page(id: int): @@ -316,12 +302,9 @@ def create_ap_social_blueprint(app_name: str) -> Blueprint: items = await services.federation.get_actor_timeline( g._ap_s, id, before=before, ) - return await render_template( - "social/_timeline_items.html", - items=items, - timeline_type="actor", - actor_id=id, - actor=actor, - ) + from shared.infrastructure.ap_social_sx import timeline_items_sx + from shared.sx.helpers import sx_response + content = timeline_items_sx(items, "actor", id, actor) + return sx_response(content) return bp diff --git a/shared/infrastructure/ap_social_sx.py b/shared/infrastructure/ap_social_sx.py new file mode 100644 index 0000000..54e76e6 --- /dev/null +++ b/shared/infrastructure/ap_social_sx.py @@ -0,0 +1,593 @@ +"""SX content builders for the per-app AP social blueprint. + +Builds s-expression source strings for all social pages, replacing +the Jinja templates in shared/browser/templates/social/. + +All dynamic values (URLs, CSRF tokens) are resolved server-side in Python +and embedded as string literals — the SX is rendered client-side where +server primitives like url-for and csrf-token are unavailable. +""" +from __future__ import annotations + +from typing import Any + +from markupsafe import escape + +from shared.sx.helpers import ( + sx_call, root_header_sx, oob_header_sx, + mobile_menu_sx, mobile_root_nav_sx, full_page_sx, oob_page_sx, +) +from shared.sx.parser import SxExpr + + +# --------------------------------------------------------------------------- +# Layout — "social-lite": root header + social nav row +# --------------------------------------------------------------------------- + +def setup_social_layout() -> None: + """Register the social-lite layout. Called once during app startup.""" + from shared.sx.layouts import register_custom_layout + register_custom_layout( + "social-lite", + _social_full_headers, + _social_oob_headers, + _social_mobile, + ) + + +def _social_nav_items(actor: Any) -> str: + """Build the social nav items as sx source. + + All URLs resolved server-side via Quart's url_for. + """ + from quart import url_for + from shared.infrastructure.urls import app_url + + search_url = _e(url_for("ap_social.search")) + hub_url = _e(app_url("federation", "/social/")) + + parts: list[str] = [] + if actor: + following_url = _e(url_for("ap_social.following_list")) + followers_url = _e(url_for("ap_social.followers_list")) + username = _e(getattr(actor, "preferred_username", "")) + try: + profile_url = _e(url_for("activitypub.actor_profile", + username=actor.preferred_username)) + except Exception: + profile_url = "" + + parts.append(f'(a :href "{search_url}"' + f' :class "px-2 py-1 rounded hover:bg-stone-200 text-sm" "Search")') + parts.append(f'(a :href "{following_url}"' + f' :class "px-2 py-1 rounded hover:bg-stone-200 text-sm" "Following")') + parts.append(f'(a :href "{followers_url}"' + f' :class "px-2 py-1 rounded hover:bg-stone-200 text-sm" "Followers")') + if profile_url: + parts.append(f'(a :href "{profile_url}"' + f' :class "px-2 py-1 rounded hover:bg-stone-200 text-sm"' + f' "@{username}")') + parts.append(f'(a :href "{hub_url}"' + f' :class "px-2 py-1 rounded hover:bg-stone-200 text-sm text-stone-500"' + f' "Hub")') + else: + parts.append(f'(a :href "{search_url}"' + f' :class "px-2 py-1 rounded hover:bg-stone-200 text-sm" "Search")') + parts.append(f'(a :href "{hub_url}"' + f' :class "px-2 py-1 rounded hover:bg-stone-200 text-sm text-stone-500"' + f' "Hub")') + return " ".join(parts) + + +def _social_header_row(actor: Any) -> str: + """Build the social nav header row as sx source.""" + nav = _social_nav_items(actor) + return ( + f'(div :id "social-lite-header-child"' + f' :class "flex flex-col items-center md:flex-row justify-center' + f' md:justify-between w-full p-1 bg-stone-300"' + f' (div :class "w-full flex flex-row items-center gap-2 flex-wrap"' + f' (nav :class "flex gap-3 text-sm items-center flex-wrap" {nav})))' + ) + + +def _social_full_headers(ctx: dict, **kw: Any) -> str: + root_hdr = root_header_sx(ctx) + actor = kw.get("actor") + social_row = _social_header_row(actor) + return "(<> " + root_hdr + " " + social_row + ")" + + +def _social_oob_headers(ctx: dict, **kw: Any) -> str: + root_hdr = root_header_sx(ctx) + actor = kw.get("actor") + social_row = _social_header_row(actor) + rows = "(<> " + root_hdr + " " + social_row + ")" + return oob_header_sx("root-header-child", "social-lite-header-child", rows) + + +def _social_mobile(ctx: dict, **kw: Any) -> str: + return mobile_menu_sx(mobile_root_nav_sx(ctx)) + + +# --------------------------------------------------------------------------- +# Shared helpers +# --------------------------------------------------------------------------- + +def _e(val: Any) -> str: + """Escape a value for safe embedding in sx source strings.""" + s = str(val) if val else "" + return str(escape(s)).replace('"', '\\"') + + +def _esc_raw(html: str) -> str: + """Escape raw HTML for embedding as a string literal in sx. + + The string will be passed to (raw! ...) so it should NOT be HTML-escaped, + only the sx string delimiters need escaping. + """ + return html.replace("\\", "\\\\").replace('"', '\\"') + + +def _actor_initial(a: Any) -> str: + """Get the uppercase first character of an actor's display name or username.""" + name = _actor_name(a) + return name[0].upper() if name else "?" + + +def _actor_name(a: Any) -> str: + """Get display name or preferred username from an actor (DTO or dict).""" + if isinstance(a, dict): + return a.get("display_name") or a.get("preferred_username") or "" + return getattr(a, "display_name", None) or getattr(a, "preferred_username", "") or "" + + +def _attr(a: Any, key: str, default: str = "") -> Any: + """Get attribute from DTO or dict.""" + if isinstance(a, dict): + return a.get(key, default) + return getattr(a, key, default) + + +def _strip_tags(s: str) -> str: + import re + return re.sub(r"<[^>]+>", "", s) + + +def _csrf() -> str: + """Get the CSRF token as a string.""" + from quart import current_app + fn = current_app.jinja_env.globals.get("csrf_token") + if callable(fn): + return str(fn()) + return "" + + +# --------------------------------------------------------------------------- +# Actor card — used in search results, followers, following +# --------------------------------------------------------------------------- + +def _actor_card_sx(a: Any, followed_urls: set, actor: Any, + list_type: str = "search") -> str: + """Build sx source for a single actor card.""" + from quart import url_for + + actor_url = _attr(a, "actor_url", "") + safe_id = actor_url.replace("/", "_").replace(":", "_") + icon_url = _attr(a, "icon_url", "") + display_name = _actor_name(a) + username = _attr(a, "preferred_username", "") + domain = _attr(a, "domain", "") + summary = _attr(a, "summary", "") + actor_id = _attr(a, "id") + csrf = _e(_csrf()) + + # Avatar + if icon_url: + avatar = f'(img :src "{_e(icon_url)}" :alt "" :class "w-12 h-12 rounded-full")' + else: + initial = _actor_initial(a) + avatar = (f'(div :class "w-12 h-12 rounded-full bg-stone-300 flex items-center' + f' justify-center text-stone-600 font-bold" "{initial}")') + + # Name link + if (list_type in ("following", "search")) and actor_id: + tl_url = _e(url_for("ap_social.actor_timeline", id=actor_id)) + name_el = (f'(a :href "{tl_url}"' + f' :class "font-semibold text-stone-900 hover:underline"' + f' "{_e(display_name)}")') + else: + name_el = (f'(a :href "https://{_e(domain)}/@{_e(username)}"' + f' :target "_blank" :rel "noopener"' + f' :class "font-semibold text-stone-900 hover:underline"' + f' "{_e(display_name)}")') + + handle = f'(div :class "text-sm text-stone-500" "@{_e(username)}@{_e(domain)}")' + + # Summary + summary_el = "" + if summary: + clean = _strip_tags(summary) + summary_el = (f'(div :class "text-sm text-stone-600 mt-1 truncate"' + f' "{_e(clean)}")') + + # Follow/unfollow button + button_el = "" + if actor: + is_followed = (list_type == "following" or actor_url in (followed_urls or set())) + if is_followed: + unfollow_url = _e(url_for("ap_social.unfollow")) + button_el = ( + f'(div :class "flex-shrink-0"' + f' (form :method "post" :action "{unfollow_url}"' + f' :sx-post "{unfollow_url}"' + f' :sx-target "closest article" :sx-swap "outerHTML"' + f' (input :type "hidden" :name "csrf_token" :value "{csrf}")' + f' (input :type "hidden" :name "actor_url" :value "{_e(actor_url)}")' + f' (button :type "submit"' + f' :class "text-sm border border-stone-300 rounded px-3 py-1 hover:bg-stone-100"' + f' "Unfollow")))') + else: + follow_url = _e(url_for("ap_social.follow")) + label = "Follow Back" if list_type == "followers" else "Follow" + button_el = ( + f'(div :class "flex-shrink-0"' + f' (form :method "post" :action "{follow_url}"' + f' :sx-post "{follow_url}"' + f' :sx-target "closest article" :sx-swap "outerHTML"' + f' (input :type "hidden" :name "csrf_token" :value "{csrf}")' + f' (input :type "hidden" :name "actor_url" :value "{_e(actor_url)}")' + f' (button :type "submit"' + f' :class "text-sm bg-stone-800 text-white rounded px-3 py-1 hover:bg-stone-700"' + f' "{label}")))') + + return ( + f'(article :class "bg-white rounded-lg shadow-sm border border-stone-200' + f' p-4 mb-3 flex items-center gap-4" :id "actor-{_e(safe_id)}"' + f' {avatar}' + f' (div :class "flex-1 min-w-0" {name_el} {handle} {summary_el})' + f' {button_el})' + ) + + +# --------------------------------------------------------------------------- +# Actor list items — paginated fragment +# --------------------------------------------------------------------------- + +def actor_list_items_sx(actors: list, total: int, page: int, + list_type: str, followed_urls: set, actor: Any) -> str: + """Build sx source for a list of actor cards with pagination sentinel.""" + from quart import url_for + + parts = [_actor_card_sx(a, followed_urls, actor, list_type) for a in actors] + + # Infinite scroll sentinel + if len(actors) >= 20: + next_page = page + 1 + ep = f"ap_social.{list_type}_list_page" + next_url = _e(url_for(ep, page=next_page)) + parts.append( + f'(div :sx-get "{next_url}"' + f' :sx-trigger "revealed" :sx-swap "outerHTML")') + + return "(<> " + " ".join(parts) + ")" if parts else '""' + + +# --------------------------------------------------------------------------- +# Search results — paginated fragment +# --------------------------------------------------------------------------- + +def search_results_sx(actors: list, total: int, page: int, + query: str, followed_urls: set, actor: Any) -> str: + """Build sx source for search results with pagination sentinel.""" + from quart import url_for + + parts = [_actor_card_sx(a, followed_urls, actor, "search") for a in actors] + + if len(actors) >= 20: + next_page = page + 1 + next_url = _e(url_for("ap_social.search_page", q=query, page=next_page)) + parts.append( + f'(div :sx-get "{next_url}"' + f' :sx-trigger "revealed" :sx-swap "outerHTML")') + + return "(<> " + " ".join(parts) + ")" if parts else '""' + + +# --------------------------------------------------------------------------- +# Post card — timeline item +# --------------------------------------------------------------------------- + +def _post_card_sx(item: Any) -> str: + """Build sx source for a single post/status card.""" + from shared.infrastructure.urls import app_url + + actor_name = _attr(item, "actor_name", "") + actor_username = _attr(item, "actor_username", "") + actor_domain = _attr(item, "actor_domain", "") + actor_icon = _attr(item, "actor_icon", "") + content = _attr(item, "content", "") + summary = _attr(item, "summary", "") + published = _attr(item, "published") + boosted_by = _attr(item, "boosted_by", "") + url = _attr(item, "url", "") + object_id = _attr(item, "object_id", "") + post_type = _attr(item, "post_type", "") + + boost_el = "" + if boosted_by: + boost_el = (f'(div :class "text-sm text-stone-500 mb-2"' + f' "Boosted by {_e(boosted_by)}")') + + # Avatar + if actor_icon: + avatar = f'(img :src "{_e(actor_icon)}" :alt "" :class "w-10 h-10 rounded-full")' + else: + initial = actor_name[0].upper() if actor_name else "?" + avatar = (f'(div :class "w-10 h-10 rounded-full bg-stone-300 flex items-center' + f' justify-center text-stone-600 font-bold text-sm" "{initial}")') + + # Handle + handle_text = f"@{_e(actor_username)}" + if actor_domain: + handle_text += f"@{_e(actor_domain)}" + + # Timestamp + time_el = "" + if published: + if hasattr(published, "strftime"): + ts = published.strftime("%b %d, %H:%M") + else: + ts = str(published) + time_el = f'(span :class "text-sm text-stone-400 ml-auto" "{_e(ts)}")' + + # Content — raw HTML from AP, render with raw! + if summary: + content_el = ( + f'(details :class "mt-2"' + f' (summary :class "text-stone-500 cursor-pointer" "CW: {_e(summary)}")' + f' (div :class "mt-2 prose prose-sm prose-stone max-w-none"' + f' (raw! "{_esc_raw(content)}")))') + else: + content_el = ( + f'(div :class "mt-2 prose prose-sm prose-stone max-w-none"' + f' (raw! "{_esc_raw(content)}"))') + + # Links + links: list[str] = [] + if url and post_type == "remote": + links.append( + f'(a :href "{_e(url)}" :target "_blank" :rel "noopener"' + f' :class "hover:underline" "original")') + if object_id: + hub_url = _e(app_url("federation", "/social/")) + links.append( + f'(a :href "{hub_url}"' + f' :class "hover:underline" "View on Hub")') + links_el = "" + if links: + links_el = ('(div :class "mt-2 flex gap-3 text-sm text-stone-400" ' + + " ".join(links) + ")") + + return ( + f'(article :class "bg-white rounded-lg shadow-sm border border-stone-200 p-4 mb-4"' + f' {boost_el}' + f' (div :class "flex items-start gap-3"' + f' {avatar}' + f' (div :class "flex-1 min-w-0"' + f' (div :class "flex items-baseline gap-2"' + f' (span :class "font-semibold text-stone-900" "{_e(actor_name)}")' + f' (span :class "text-sm text-stone-500" "{handle_text}")' + f' {time_el})' + f' {content_el}' + f' {links_el})))' + ) + + +# --------------------------------------------------------------------------- +# Timeline items — paginated fragment +# --------------------------------------------------------------------------- + +def timeline_items_sx(items: list, timeline_type: str = "", + actor_id: int | None = None, actor: Any = None) -> str: + """Build sx source for timeline items with infinite scroll sentinel.""" + from quart import url_for + + parts = [_post_card_sx(item) for item in items] + + if items and timeline_type == "actor" and actor_id: + last = items[-1] + published = _attr(last, "published") + if published and hasattr(published, "isoformat"): + before = published.isoformat() + else: + before = str(published) if published else "" + if before: + next_url = _e(url_for("ap_social.actor_timeline_page", + id=actor_id, before=before)) + parts.append( + f'(div :sx-get "{next_url}"' + f' :sx-trigger "revealed" :sx-swap "outerHTML")') + + return "(<> " + " ".join(parts) + ")" if parts else '""' + + +# --------------------------------------------------------------------------- +# Full page content builders +# --------------------------------------------------------------------------- + +def social_index_content_sx(actor: Any) -> str: + """Build sx source for the social index page content.""" + from quart import url_for + from shared.infrastructure.urls import app_url + + search_url = _e(url_for("ap_social.search")) + hub_url = _e(app_url("federation", "/social/")) + + if actor: + following_url = _e(url_for("ap_social.following_list")) + followers_url = _e(url_for("ap_social.followers_list")) + return ( + f'(div :id "main-panel"' + f' (h1 :class "text-2xl font-bold mb-6" "Social")' + f' (div :class "space-y-3"' + f' (a :href "{search_url}"' + f' :class "block bg-white rounded-lg shadow-sm border border-stone-200 p-4 hover:bg-stone-50"' + f' (div :class "font-semibold" "Search")' + f' (div :class "text-sm text-stone-500" "Find and follow accounts on the fediverse"))' + f' (a :href "{following_url}"' + f' :class "block bg-white rounded-lg shadow-sm border border-stone-200 p-4 hover:bg-stone-50"' + f' (div :class "font-semibold" "Following")' + f' (div :class "text-sm text-stone-500" "Accounts you follow"))' + f' (a :href "{followers_url}"' + f' :class "block bg-white rounded-lg shadow-sm border border-stone-200 p-4 hover:bg-stone-50"' + f' (div :class "font-semibold" "Followers")' + f' (div :class "text-sm text-stone-500" "Accounts following you here"))' + f' (a :href "{hub_url}"' + f' :class "block bg-white rounded-lg shadow-sm border border-stone-200 p-4 hover:bg-stone-50"' + f' (div :class "font-semibold" "Hub")' + f' (div :class "text-sm text-stone-500"' + f' "Full social experience \\u2014 timeline, compose, notifications"))))') + else: + return ( + f'(div :id "main-panel"' + f' (h1 :class "text-2xl font-bold mb-6" "Social")' + f' (p :class "text-stone-500"' + f' (a :href "{search_url}" :class "underline" "Search")' + f' " for accounts on the fediverse, or visit the "' + f' (a :href "{hub_url}" :class "underline" "Hub")' + f' " to get started."))') + + +def social_search_content_sx(query: str, actors: list, total: int, + page: int, followed_urls: set, actor: Any) -> str: + """Build sx source for the search page content.""" + from quart import url_for + + search_url = _e(url_for("ap_social.search")) + search_page_url = _e(url_for("ap_social.search_page")) + + # Results message + msg = "" + if query and total: + s = "s" if total != 1 else "" + msg = (f'(p :class "text-sm text-stone-500 mb-4"' + f' "{total} result{s} for " (strong "{_e(query)}"))') + elif query: + msg = (f'(p :class "text-stone-500 mb-4"' + f' "No results found for " (strong "{_e(query)}"))') + + results = search_results_sx(actors, total, page, query, followed_urls, actor) + + return ( + f'(div :id "main-panel"' + f' (h1 :class "text-2xl font-bold mb-6" "Search")' + f' (form :method "get" :action "{search_url}"' + f' :sx-get "{search_page_url}"' + f' :sx-target "#search-results"' + f' :sx-push-url "{search_url}"' + f' :class "mb-6"' + f' (div :class "flex gap-2"' + f' (input :type "text" :name "q" :value "{_e(query)}"' + f' :class "flex-1 border border-stone-300 rounded-lg px-4 py-2' + f' focus:outline-none focus:ring-2 focus:ring-stone-500"' + f' :placeholder "Search users or @user@instance.tld")' + f' (button :type "submit"' + f' :class "bg-stone-800 text-white px-6 py-2 rounded hover:bg-stone-700"' + f' "Search")))' + f' {msg}' + f' (div :id "search-results" {results}))' + ) + + +def social_followers_content_sx(actors: list, total: int, page: int, + followed_urls: set, actor: Any) -> str: + """Build sx source for the followers page content.""" + items = actor_list_items_sx(actors, total, page, "followers", followed_urls, actor) + return ( + f'(div :id "main-panel"' + f' (h1 :class "text-2xl font-bold mb-6" "Followers "' + f' (span :class "text-stone-400 font-normal" "({total})"))' + f' (div :id "actor-list" {items}))' + ) + + +def social_following_content_sx(actors: list, total: int, + page: int, actor: Any) -> str: + """Build sx source for the following page content.""" + items = actor_list_items_sx(actors, total, page, "following", set(), actor) + return ( + f'(div :id "main-panel"' + f' (h1 :class "text-2xl font-bold mb-6" "Following "' + f' (span :class "text-stone-400 font-normal" "({total})"))' + f' (div :id "actor-list" {items}))' + ) + + +def social_actor_timeline_content_sx(remote_actor: Any, items: list, + is_following: bool, actor: Any) -> str: + """Build sx source for the actor timeline page content.""" + from quart import url_for + + ra = remote_actor + display_name = _actor_name(ra) + username = _attr(ra, "preferred_username", "") + domain = _attr(ra, "domain", "") + icon_url = _attr(ra, "icon_url", "") + summary = _attr(ra, "summary", "") + actor_url = _attr(ra, "actor_url", "") + ra_id = _attr(ra, "id") + csrf = _e(_csrf()) + + # Avatar + if icon_url: + avatar = f'(img :src "{_e(icon_url)}" :alt "" :class "w-16 h-16 rounded-full")' + else: + initial = display_name[0].upper() if display_name else "?" + avatar = (f'(div :class "w-16 h-16 rounded-full bg-stone-300 flex items-center' + f' justify-center text-stone-600 font-bold text-xl" "{initial}")') + + # Summary — raw HTML from AP + summary_el = "" + if summary: + summary_el = (f'(div :class "text-sm text-stone-600 mt-2"' + f' (raw! "{_esc_raw(summary)}"))') + + # Follow/unfollow button + button_el = "" + if actor: + if is_following: + unfollow_url = _e(url_for("ap_social.unfollow")) + button_el = ( + f'(div :class "flex-shrink-0"' + f' (form :method "post" :action "{unfollow_url}"' + f' (input :type "hidden" :name "csrf_token" :value "{csrf}")' + f' (input :type "hidden" :name "actor_url" :value "{_e(actor_url)}")' + f' (button :type "submit"' + f' :class "border border-stone-300 rounded px-4 py-2 hover:bg-stone-100"' + f' "Unfollow")))') + else: + follow_url = _e(url_for("ap_social.follow")) + button_el = ( + f'(div :class "flex-shrink-0"' + f' (form :method "post" :action "{follow_url}"' + f' (input :type "hidden" :name "csrf_token" :value "{csrf}")' + f' (input :type "hidden" :name "actor_url" :value "{_e(actor_url)}")' + f' (button :type "submit"' + f' :class "bg-stone-800 text-white rounded px-4 py-2 hover:bg-stone-700"' + f' "Follow")))') + + tl = timeline_items_sx(items, "actor", ra_id, actor) + + return ( + f'(div :id "main-panel"' + f' (div :class "bg-white rounded-lg shadow-sm border border-stone-200 p-6 mb-6"' + f' (div :class "flex items-center gap-4"' + f' {avatar}' + f' (div :class "flex-1"' + f' (h1 :class "text-xl font-bold" "{_e(display_name)}")' + f' (div :class "text-stone-500" "@{_e(username)}@{_e(domain)}")' + f' {summary_el})' + f' {button_el}))' + f' (div :id "timeline" {tl}))' + ) diff --git a/shared/infrastructure/factory.py b/shared/infrastructure/factory.py index 4799943..c26d4d4 100644 --- a/shared/infrastructure/factory.py +++ b/shared/infrastructure/factory.py @@ -160,6 +160,8 @@ def create_base_app( # Auto-register per-app social blueprint (not federation — it has its own) if name in AP_APPS and name != "federation": from shared.infrastructure.ap_social import create_ap_social_blueprint + from shared.infrastructure.ap_social_sx import setup_social_layout + setup_social_layout() app.register_blueprint(create_ap_social_blueprint(name)) # --- device id (all apps, including account) --- diff --git a/shared/sx/primitives.py b/shared/sx/primitives.py index eb4d0b9..559db89 100644 --- a/shared/sx/primitives.py +++ b/shared/sx/primitives.py @@ -257,6 +257,24 @@ def prim_split(s: str, sep: str = " ") -> list[str]: def prim_join(sep: str, coll: list) -> str: return sep.join(str(x) for x in coll) +@register_primitive("replace") +def prim_replace(s: str, old: str, new: str) -> str: + return s.replace(old, new) + +@register_primitive("strip-tags") +def prim_strip_tags(s: str) -> str: + """Strip HTML tags from a string.""" + import re + return re.sub(r"<[^>]+>", "", s) + +@register_primitive("slice") +def prim_slice(coll: Any, start: int, end: Any = None) -> Any: + """Slice a string or list: (slice coll start end?).""" + start = int(start) + if end is None or end is NIL: + return coll[start:] + return coll[start:int(end)] + @register_primitive("starts-with?") def prim_starts_with(s, prefix: str) -> bool: if not isinstance(s, str): diff --git a/shared/sx/primitives_io.py b/shared/sx/primitives_io.py index 676af03..ae17a28 100644 --- a/shared/sx/primitives_io.py +++ b/shared/sx/primitives_io.py @@ -41,6 +41,7 @@ IO_PRIMITIVES: frozenset[str] = frozenset({ "nav-tree", "get-children", "g", + "csrf-token", }) @@ -314,6 +315,17 @@ async def _io_g( return getattr(g, key, None) +async def _io_csrf_token( + args: list[Any], kwargs: dict[str, Any], ctx: RequestContext +) -> str: + """``(csrf-token)`` → current CSRF token string.""" + from quart import current_app + csrf = current_app.jinja_env.globals.get("csrf_token") + if callable(csrf): + return csrf() + return "" + + _IO_HANDLERS: dict[str, Any] = { "frag": _io_frag, "query": _io_query, @@ -326,4 +338,5 @@ _IO_HANDLERS: dict[str, Any] = { "nav-tree": _io_nav_tree, "get-children": _io_get_children, "g": _io_g, + "csrf-token": _io_csrf_token, } From 4ba63bda17304a9df84f37a7fb2f5e37961b6bab Mon Sep 17 00:00:00 2001 From: giles Date: Tue, 3 Mar 2026 17:48:35 +0000 Subject: [PATCH 002/105] Add server-driven architecture principle and React feature analysis Documents why sx stays server-driven by default, maps React features to sx equivalents, and defines targeted escape hatches for the few interactions that genuinely need client-side state. Co-Authored-By: Claude Opus 4.6 --- docs/isomorphic-sx-plan.md | 48 ++++++++++++++++++++++++++++++++++++++ 1 file changed, 48 insertions(+) diff --git a/docs/isomorphic-sx-plan.md b/docs/isomorphic-sx-plan.md index 9e48846..9ebdaf8 100644 --- a/docs/isomorphic-sx-plan.md +++ b/docs/isomorphic-sx-plan.md @@ -396,3 +396,51 @@ If performance ever becomes a concern, WASM is the escape hatch at three levels: 3. **Compute-heavy primitives in WASM.** Keep the sx interpreter in JS, bind specific primitives to WASM (image processing, crypto, data transformation). Most pragmatic and least disruptive — additive, no architecture change. The primitive-binding model means the evaluator doesn't care what's behind a primitive. `(blur-image data radius)` could be a JS Canvas call today and a WASM JAX kernel tomorrow. The sx source doesn't change. + +### Server-Driven by Default: The React Question + +The sx system is architecturally aligned with HTMX/LiveView — server-driven UI — even though it does far more on the client (full s-expression evaluation, DOM rendering, morph reconciliation, component caching). The server is the single source of truth. Every UI state is a URL. Auth is enforced at render time. There are no state synchronization bugs because there is no client state to synchronize. + +React's client-state model (`useState`, `useEffect`, Context, Suspense) exists because React was built for SPAs that need to feel like native apps — optimistic updates, offline capability, instant feedback without network latency. But it created an entire category of problems: state management libraries, hydration mismatches, cache invalidation, stale closures, memory leaks from forgotten cleanup, the `useEffect` footgun. + +**The question is not "should sx have useState" — it's which specific interactions actually suffer from the server round-trip.** + +For most of our apps, that's a very short list: +- Toggle a mobile nav panel +- Gallery image switching +- Quantity steppers +- Live search-as-you-type + +These don't need a general-purpose reactive state system. They need **targeted client-side primitives** that handle those specific cases without abandoning the server-driven model. + +**The dangerous path:** Add `useState` → need `useEffect` for cleanup → need Context to avoid prop drilling → need Suspense for async state → rebuild React inside sx → lose the simplicity that makes the server-driven model work. + +**The careful path:** Keep server-driven as the default. Add explicit, targeted escape hatches for interactions that genuinely need client-side state. Make those escape hatches obviously different from the normal flow so they don't creep into everything. + +#### What sx has vs React + +| React feature | SX status | Verdict | +|---|---|---| +| Components + props | `defcomp` + `&key` | Done — cleaner than JSX | +| Fragments, conditionals, lists | `<>`, `if`/`when`/`cond`, `map` | Done — more expressive | +| Macros | `defmacro` | Done — React has nothing like this | +| OOB updates / portals | `sx-swap-oob` | Done — more powerful (server-driven) | +| DOM reconciliation | `_morphDOM` (id-keyed) | Done — works during SxEngine swaps | +| Reactive client state | None | **By design.** Server is source of truth. | +| Component lifecycle | None | Add targeted primitives if body.js behaviors move to sx | +| Context / providers | `_componentEnv` global | Sufficient for auth/theme; revisit if trees get deep | +| Suspense / loading | `sx-request` CSS class | Sufficient for server-driven; revisit for Phase 4 client data | +| Two-way data binding | None | Not needed — HTMX model (form POST → new HTML) works | +| Error boundaries | Global `sx:responseError` | Sufficient; per-component boundaries are a future nice-to-have | +| Keyed list reconciliation | id-based morph | Works; add `:key` prop support if list update bugs arise | + +#### Targeted escape hatches (not a general state system) + +For the few interactions that need client-side responsiveness, add **specific primitives** rather than a general framework: + +- `(toggle! el "class")` — CSS class toggle, no server trip +- `(set-attr! el "attr" value)` — attribute manipulation +- `(on-event el "click" handler)` — declarative event binding within sx +- `(timer interval-ms handler)` — with automatic cleanup on DOM removal + +These are imperative DOM operations exposed as primitives — not reactive state. They let components handle simple client-side interactions without importing React's entire mental model. The server-driven flow remains the default for anything involving data. From 293f7713d67ae0d479a2178711f43ab94d21a799 Mon Sep 17 00:00:00 2001 From: giles Date: Tue, 3 Mar 2026 19:03:15 +0000 Subject: [PATCH 003/105] Auto-mount defpages: eliminate Python route stubs across all 9 services Defpages are now declared with absolute paths in .sx files and auto-mounted directly on the Quart app, removing ~850 lines of blueprint mount_pages calls, before_request hooks, and g.* wrapper boilerplate. A new page = one defpage declaration, nothing else. Infrastructure: - async_eval awaits coroutine results from callable dispatch - auto_mount_pages() mounts all registered defpages on the app - g._defpage_ctx pattern passes helper data to layout context Migrated: sx, account, orders, federation, cart, market, events, blog Co-Authored-By: Claude Opus 4.6 --- account/app.py | 5 +- account/bp/account/routes.py | 50 +-- account/sxc/pages/__init__.py | 53 ++- account/sxc/pages/account.sx | 2 +- blog/app.py | 17 + blog/bp/admin/routes.py | 23 +- blog/bp/blog/admin/routes.py | 61 +--- blog/bp/blog/routes.py | 14 +- blog/bp/menu_items/routes.py | 15 - blog/bp/post/admin/routes.py | 162 +-------- blog/bp/snippets/routes.py | 20 +- blog/sxc/pages/__init__.py | 418 +++++++++++++++++++---- blog/sxc/pages/blog.sx | 42 +-- cart/app.py | 15 +- cart/bp/cart/global_routes.py | 8 +- cart/bp/cart/overview_routes.py | 17 +- cart/bp/cart/page_routes.py | 24 +- cart/bp/orders/routes.py | 2 +- cart/bp/page_admin/routes.py | 17 - cart/sx/sx_components.py | 2 +- cart/sxc/pages/__init__.py | 56 +-- cart/sxc/pages/cart.sx | 6 +- events/app.py | 12 +- events/bp/all_events/routes.py | 2 +- events/bp/calendar/admin/routes.py | 14 +- events/bp/calendar/routes.py | 2 +- events/bp/calendar_entries/routes.py | 2 +- events/bp/calendar_entry/admin/routes.py | 17 +- events/bp/calendar_entry/routes.py | 15 +- events/bp/calendars/routes.py | 2 +- events/bp/day/admin/routes.py | 15 +- events/bp/day/routes.py | 2 +- events/bp/markets/routes.py | 14 +- events/bp/page/routes.py | 2 +- events/bp/slot/routes.py | 21 -- events/bp/slots/routes.py | 12 - events/bp/ticket_admin/routes.py | 44 +-- events/bp/ticket_type/routes.py | 26 -- events/bp/ticket_types/routes.py | 17 - events/bp/tickets/routes.py | 40 --- events/sx/sx_components.py | 22 +- events/sxc/pages/__init__.py | 383 ++++++++++++++++++--- events/sxc/pages/events.sx | 70 ++-- federation/app.py | 5 +- federation/bp/social/routes.py | 108 +----- federation/sxc/pages/__init__.py | 133 ++++++-- federation/sxc/pages/social.sx | 18 +- market/app.py | 9 +- market/bp/all_markets/routes.py | 13 - market/bp/browse/routes.py | 4 - market/bp/market/admin/routes.py | 5 - market/bp/market/routes.py | 2 +- market/bp/page_admin/routes.py | 11 - market/bp/page_markets/routes.py | 14 - market/sxc/pages/__init__.py | 82 +++-- market/sxc/pages/market.sx | 16 +- orders/app.py | 7 +- orders/bp/orders/routes.py | 109 +----- orders/sxc/pages/__init__.py | 159 ++++++++- orders/sxc/pages/orders.sx | 8 +- shared/sx/async_eval.py | 71 +++- shared/sx/pages.py | 12 + sx/app.py | 7 +- 63 files changed, 1340 insertions(+), 1216 deletions(-) diff --git a/account/app.py b/account/app.py index c5c375b..639262e 100644 --- a/account/app.py +++ b/account/app.py @@ -81,10 +81,11 @@ def create_app() -> "Quart": app.register_blueprint(register_auth_bp()) account_bp = register_account_bp() - from shared.sx.pages import mount_pages - mount_pages(account_bp, "account") app.register_blueprint(account_bp) + from shared.sx.pages import auto_mount_pages + auto_mount_pages(app, "account") + app.register_blueprint(register_fragments()) from bp.actions.routes import register as register_actions diff --git a/account/bp/account/routes.py b/account/bp/account/routes.py index 9c18757..835353f 100644 --- a/account/bp/account/routes.py +++ b/account/bp/account/routes.py @@ -8,15 +8,12 @@ from __future__ import annotations from quart import ( Blueprint, request, - redirect, g, ) from sqlalchemy import select from shared.models import UserNewsletter -from shared.models.ghost_membership_entities import GhostNewsletter -from shared.infrastructure.urls import login_url -from shared.infrastructure.fragments import fetch_fragment, fetch_fragments +from shared.infrastructure.fragments import fetch_fragments from shared.sx.helpers import sx_response @@ -25,8 +22,7 @@ def register(url_prefix="/"): @account_bp.before_request async def _prepare_page_data(): - """Fetch account_nav fragments and load data for defpage routes.""" - # Fetch account nav items for layout (was in context_processor) + """Fetch account_nav fragments for layout.""" events_nav, cart_nav, artdag_nav = await fetch_fragments([ ("events", "account-nav-item", {}), ("cart", "account-nav-item", {}), @@ -34,48 +30,6 @@ def register(url_prefix="/"): ], required=False) g.account_nav = events_nav + cart_nav + artdag_nav - if request.method != "GET": - return - - endpoint = request.endpoint or "" - - # Newsletters page — load newsletter data - if endpoint.endswith("defpage_newsletters"): - result = await g.s.execute( - select(GhostNewsletter).order_by(GhostNewsletter.name) - ) - all_newsletters = result.scalars().all() - - sub_result = await g.s.execute( - select(UserNewsletter).where( - UserNewsletter.user_id == g.user.id, - ) - ) - user_subs = {un.newsletter_id: un for un in sub_result.scalars().all()} - - newsletter_list = [] - for nl in all_newsletters: - un = user_subs.get(nl.id) - newsletter_list.append({ - "newsletter": nl, - "un": un, - "subscribed": un.subscribed if un else False, - }) - g.newsletters_data = newsletter_list - - # Fragment page — load fragment from events service - elif endpoint.endswith("defpage_fragment_page"): - slug = request.view_args.get("slug") - if slug and g.get("user"): - fragment_html = await fetch_fragment( - "events", "account-page", - params={"slug": slug, "user_id": str(g.user.id)}, - ) - if not fragment_html: - from quart import abort - abort(404) - g.fragment_page_data = fragment_html - @account_bp.post("/newsletter//toggle/") async def toggle_newsletter(newsletter_id: int): if not g.get("user"): diff --git a/account/sxc/pages/__init__.py b/account/sxc/pages/__init__.py index 6e3e8c6..269ad2d 100644 --- a/account/sxc/pages/__init__.py +++ b/account/sxc/pages/__init__.py @@ -75,31 +75,60 @@ def _register_account_helpers() -> None: }) -def _h_account_content(): +def _h_account_content(**kw): from sx.sx_components import _account_main_panel_sx return _account_main_panel_sx({}) -def _h_newsletters_content(): +async def _h_newsletters_content(**kw): from quart import g - d = getattr(g, "newsletters_data", None) - if not d: + from sqlalchemy import select + from shared.models import UserNewsletter + from shared.models.ghost_membership_entities import GhostNewsletter + + result = await g.s.execute( + select(GhostNewsletter).order_by(GhostNewsletter.name) + ) + all_newsletters = result.scalars().all() + + sub_result = await g.s.execute( + select(UserNewsletter).where( + UserNewsletter.user_id == g.user.id, + ) + ) + user_subs = {un.newsletter_id: un for un in sub_result.scalars().all()} + + newsletter_list = [] + for nl in all_newsletters: + un = user_subs.get(nl.id) + newsletter_list.append({ + "newsletter": nl, + "un": un, + "subscribed": un.subscribed if un else False, + }) + + if not newsletter_list: from shared.sx.helpers import sx_call return sx_call("account-newsletter-empty") - from shared.sx.page import get_template_context_sync from sx.sx_components import _newsletters_panel_sx - # Build a minimal ctx with account_url ctx = {"account_url": getattr(g, "_account_url", None)} if ctx["account_url"] is None: from shared.infrastructure.urls import account_url ctx["account_url"] = account_url - return _newsletters_panel_sx(ctx, d) + return _newsletters_panel_sx(ctx, newsletter_list) -def _h_fragment_content(): - from quart import g - frag = getattr(g, "fragment_page_data", None) - if not frag: +async def _h_fragment_content(slug=None, **kw): + from quart import g, abort + from shared.infrastructure.fragments import fetch_fragment + + if not slug or not g.get("user"): return "" + fragment_html = await fetch_fragment( + "events", "account-page", + params={"slug": slug, "user_id": str(g.user.id)}, + ) + if not fragment_html: + abort(404) from sx.sx_components import _fragment_content - return _fragment_content(frag) + return _fragment_content(fragment_html) diff --git a/account/sxc/pages/account.sx b/account/sxc/pages/account.sx index c175285..1e3b3ca 100644 --- a/account/sxc/pages/account.sx +++ b/account/sxc/pages/account.sx @@ -28,4 +28,4 @@ :path "//" :auth :login :layout :account - :content (fragment-content)) + :content (fragment-content slug)) diff --git a/blog/app.py b/blog/app.py index bba7428..9fe803b 100644 --- a/blog/app.py +++ b/blog/app.py @@ -162,6 +162,23 @@ def create_app() -> "Quart": ) return jsonify(resp) + # Auto-mount all defpages with absolute paths + from shared.sx.pages import auto_mount_pages + auto_mount_pages(app, "blog") + + # --- Pass defpage helper data to template context for layouts --- + @app.context_processor + async def inject_blog_data(): + import os + from shared.config import config as get_config + ctx = { + "blog_title": get_config()["blog_title"], + "base_title": get_config()["title"], + "unsplash_api_key": os.environ.get("UNSPLASH_ACCESS_KEY", ""), + } + ctx.update(getattr(g, '_defpage_ctx', {})) + return ctx + # --- debug: url rules --- @app.get("/__rules") async def dump_rules(): diff --git a/blog/bp/admin/routes.py b/blog/bp/admin/routes.py index c69642a..958bdab 100644 --- a/blog/bp/admin/routes.py +++ b/blog/bp/admin/routes.py @@ -3,13 +3,9 @@ from __future__ import annotations #from quart import Blueprint, g from quart import ( - render_template, - make_response, Blueprint, redirect, url_for, - request, - jsonify ) from shared.browser.app.redis_cacher import clear_all_cache from shared.browser.app.authz import require_admin @@ -27,23 +23,6 @@ def register(url_prefix): "base_title": f"{config()['title']} settings", } - @bp.before_request - async def _prepare_page_data(): - ep = request.endpoint or "" - if "defpage_settings_home" in ep: - from shared.sx.page import get_template_context - from sx.sx_components import _settings_main_panel_sx - tctx = await get_template_context() - g.settings_content = _settings_main_panel_sx(tctx) - elif "defpage_cache_page" in ep: - from shared.sx.page import get_template_context - from sx.sx_components import _cache_main_panel_sx - tctx = await get_template_context() - g.cache_content = _cache_main_panel_sx(tctx) - - from shared.sx.pages import mount_pages - mount_pages(bp, "blog", names=["settings-home", "cache-page"]) - @bp.post("/cache_clear/") @require_admin async def cache_clear(): @@ -54,7 +33,7 @@ def register(url_prefix): html = render_comp("cache-cleared", time_str=now.strftime("%H:%M:%S")) return sx_response(html) - return redirect(url_for("settings.defpage_cache_page")) + return redirect(url_for("defpage_cache_page")) return bp diff --git a/blog/bp/blog/admin/routes.py b/blog/bp/blog/admin/routes.py index 2465b63..64909e2 100644 --- a/blog/bp/blog/admin/routes.py +++ b/blog/bp/blog/admin/routes.py @@ -2,8 +2,6 @@ from __future__ import annotations import re from quart import ( - render_template, - make_response, Blueprint, redirect, url_for, @@ -13,9 +11,7 @@ from quart import ( from sqlalchemy import select, delete from shared.browser.app.authz import require_admin -from shared.browser.app.utils.htmx import is_htmx_request from shared.browser.app.redis_cacher import invalidate_tag_cache -from shared.sx.helpers import sx_response from models.tag_group import TagGroup, TagGroupTag from models.ghost_content import Tag @@ -46,60 +42,13 @@ async def _unassigned_tags(session): def register(): bp = Blueprint("tag_groups_admin", __name__, url_prefix="/settings/tag-groups") - @bp.before_request - async def _prepare_page_data(): - ep = request.endpoint or "" - if "defpage_tag_groups_page" in ep: - groups = list( - (await g.s.execute( - select(TagGroup).order_by(TagGroup.sort_order, TagGroup.name) - )).scalars() - ) - unassigned = await _unassigned_tags(g.s) - from shared.sx.page import get_template_context - from sx.sx_components import _tag_groups_main_panel_sx - tctx = await get_template_context() - tctx.update({"groups": groups, "unassigned_tags": unassigned}) - g.tag_groups_content = _tag_groups_main_panel_sx(tctx) - elif "defpage_tag_group_edit" in ep: - tag_id = (request.view_args or {}).get("id") - tg = await g.s.get(TagGroup, tag_id) - if not tg: - from quart import abort - abort(404) - assigned_rows = list( - (await g.s.execute( - select(TagGroupTag.tag_id).where(TagGroupTag.tag_group_id == tag_id) - )).scalars() - ) - all_tags = list( - (await g.s.execute( - select(Tag).where( - Tag.deleted_at.is_(None), - (Tag.visibility == "public") | (Tag.visibility.is_(None)), - ).order_by(Tag.name) - )).scalars() - ) - from shared.sx.page import get_template_context - from sx.sx_components import _tag_groups_edit_main_panel_sx - tctx = await get_template_context() - tctx.update({ - "group": tg, - "all_tags": all_tags, - "assigned_tag_ids": set(assigned_rows), - }) - g.tag_group_edit_content = _tag_groups_edit_main_panel_sx(tctx) - - from shared.sx.pages import mount_pages - mount_pages(bp, "blog", names=["tag-groups-page", "tag-group-edit"]) - @bp.post("/") @require_admin async def create(): form = await request.form name = (form.get("name") or "").strip() if not name: - return redirect(url_for("blog.tag_groups_admin.defpage_tag_groups_page")) + return redirect(url_for("defpage_tag_groups_page")) slug = _slugify(name) feature_image = (form.get("feature_image") or "").strip() or None @@ -115,14 +64,14 @@ def register(): await g.s.flush() await invalidate_tag_cache("blog") - return redirect(url_for("blog.tag_groups_admin.defpage_tag_groups_page")) + return redirect(url_for("defpage_tag_groups_page")) @bp.post("//") @require_admin async def save(id: int): tg = await g.s.get(TagGroup, id) if not tg: - return redirect(url_for("blog.tag_groups_admin.defpage_tag_groups_page")) + return redirect(url_for("defpage_tag_groups_page")) form = await request.form name = (form.get("name") or "").strip() @@ -153,7 +102,7 @@ def register(): await g.s.flush() await invalidate_tag_cache("blog") - return redirect(url_for("blog.tag_groups_admin.defpage_tag_group_edit", id=id)) + return redirect(url_for("defpage_tag_group_edit", id=id)) @bp.post("//delete/") @require_admin @@ -163,6 +112,6 @@ def register(): await g.s.delete(tg) await g.s.flush() await invalidate_tag_cache("blog") - return redirect(url_for("blog.tag_groups_admin.defpage_tag_groups_page")) + return redirect(url_for("defpage_tag_groups_page")) return bp diff --git a/blog/bp/blog/routes.py b/blog/bp/blog/routes.py index 57fec85..1a3001f 100644 --- a/blog/bp/blog/routes.py +++ b/blog/bp/blog/routes.py @@ -53,16 +53,6 @@ def register(url_prefix, title): @blogs_bp.before_request async def route(): g.makeqs_factory = makeqs_factory - ep = request.endpoint or "" - if "defpage_new_post" in ep: - from sx.sx_components import render_editor_panel - g.editor_content = render_editor_panel() - elif "defpage_new_page" in ep: - from sx.sx_components import render_editor_panel - g.editor_page_content = render_editor_panel(is_page=True) - - from shared.sx.pages import mount_pages - mount_pages(blogs_bp, "blog", names=["new-post", "new-page"]) @blogs_bp.context_processor async def inject_root(): @@ -277,7 +267,7 @@ def register(url_prefix, title): await invalidate_tag_cache("blog") # Redirect to the edit page - return redirect(host_url(url_for("blog.post.admin.defpage_post_edit", slug=post.slug))) + return redirect(host_url(url_for("defpage_post_edit", slug=post.slug))) @blogs_bp.post("/new-page/") @@ -335,7 +325,7 @@ def register(url_prefix, title): await invalidate_tag_cache("blog") # Redirect to the page admin - return redirect(host_url(url_for("blog.post.admin.defpage_post_edit", slug=page.slug))) + return redirect(host_url(url_for("defpage_post_edit", slug=page.slug))) @blogs_bp.get("/drafts/") diff --git a/blog/bp/menu_items/routes.py b/blog/bp/menu_items/routes.py index 56d94d4..e43381d 100644 --- a/blog/bp/menu_items/routes.py +++ b/blog/bp/menu_items/routes.py @@ -12,7 +12,6 @@ from .services.menu_items import ( search_pages, MenuItemError, ) -from shared.browser.app.utils.htmx import is_htmx_request from shared.sx.helpers import sx_response def register(): @@ -23,20 +22,6 @@ def register(): from sx.sx_components import render_menu_items_nav_oob return render_menu_items_nav_oob(menu_items) - @bp.before_request - async def _prepare_page_data(): - if "defpage_" not in (request.endpoint or ""): - return - menu_items = await get_all_menu_items(g.s) - from shared.sx.page import get_template_context - from sx.sx_components import _menu_items_main_panel_sx - tctx = await get_template_context() - tctx["menu_items"] = menu_items - g.menu_items_content = _menu_items_main_panel_sx(tctx) - - from shared.sx.pages import mount_pages - mount_pages(bp, "blog", names=["menu-items-page"]) - @bp.get("/new/") @require_admin async def new_menu_item(): diff --git a/blog/bp/post/admin/routes.py b/blog/bp/post/admin/routes.py index 475f664..227b658 100644 --- a/blog/bp/post/admin/routes.py +++ b/blog/bp/post/admin/routes.py @@ -10,7 +10,6 @@ from quart import ( url_for, ) from shared.browser.app.authz import require_admin, require_post_author -from shared.browser.app.utils.htmx import is_htmx_request from shared.sx.helpers import sx_response from shared.utils import host_url @@ -55,155 +54,6 @@ def _post_to_edit_dict(post) -> dict: def register(): bp = Blueprint("admin", __name__, url_prefix='/admin') - @bp.before_request - async def _prepare_page_data(): - ep = request.endpoint or "" - if "defpage_post_admin" in ep: - from sqlalchemy import select - from shared.models.page_config import PageConfig - post = (g.post_data or {}).get("post", {}) - features = {} - sumup_configured = False - sumup_merchant_code = "" - sumup_checkout_prefix = "" - if post.get("is_page"): - pc = (await g.s.execute( - select(PageConfig).where( - PageConfig.container_type == "page", - PageConfig.container_id == post["id"], - ) - )).scalar_one_or_none() - if pc: - features = pc.features or {} - sumup_configured = bool(pc.sumup_api_key) - sumup_merchant_code = pc.sumup_merchant_code or "" - sumup_checkout_prefix = pc.sumup_checkout_prefix or "" - from shared.sx.page import get_template_context - from sx.sx_components import _post_admin_main_panel_sx - tctx = await get_template_context() - tctx.update({ - "features": features, - "sumup_configured": sumup_configured, - "sumup_merchant_code": sumup_merchant_code, - "sumup_checkout_prefix": sumup_checkout_prefix, - }) - g.post_admin_content = _post_admin_main_panel_sx(tctx) - - elif "defpage_post_data" in ep: - from shared.sx.page import get_template_context - from sx.sx_components import _post_data_content_sx - tctx = await get_template_context() - g.post_data_content = _post_data_content_sx(tctx) - - elif "defpage_post_preview" in ep: - from models.ghost_content import Post - from sqlalchemy import select as sa_select - post_id = g.post_data["post"]["id"] - post = (await g.s.execute( - sa_select(Post).where(Post.id == post_id) - )).scalar_one_or_none() - preview_ctx = {} - sx_content = getattr(post, "sx_content", None) or "" - if sx_content: - from shared.sx.prettify import sx_to_pretty_sx - preview_ctx["sx_pretty"] = sx_to_pretty_sx(sx_content) - lexical_raw = getattr(post, "lexical", None) or "" - if lexical_raw: - from shared.sx.prettify import json_to_pretty_sx - preview_ctx["json_pretty"] = json_to_pretty_sx(lexical_raw) - if sx_content: - from shared.sx.parser import parse as sx_parse - from shared.sx.html import render as sx_html_render - from shared.sx.jinja_bridge import _COMPONENT_ENV - try: - parsed = sx_parse(sx_content) - preview_ctx["sx_rendered"] = sx_html_render(parsed, dict(_COMPONENT_ENV)) - except Exception: - preview_ctx["sx_rendered"] = "Error rendering sx" - if lexical_raw: - from bp.blog.ghost.lexical_renderer import render_lexical - try: - preview_ctx["lex_rendered"] = render_lexical(lexical_raw) - except Exception: - preview_ctx["lex_rendered"] = "Error rendering lexical" - from shared.sx.page import get_template_context - from sx.sx_components import _preview_main_panel_sx - tctx = await get_template_context() - tctx.update(preview_ctx) - g.post_preview_content = _preview_main_panel_sx(tctx) - - elif "defpage_post_entries" in ep: - from sqlalchemy import select - from shared.models.calendars import Calendar - from ..services.entry_associations import get_post_entry_ids - post_id = g.post_data["post"]["id"] - associated_entry_ids = await get_post_entry_ids(post_id) - result = await g.s.execute( - select(Calendar) - .where(Calendar.deleted_at.is_(None)) - .order_by(Calendar.name.asc()) - ) - all_calendars = result.scalars().all() - for calendar in all_calendars: - await g.s.refresh(calendar, ["entries", "post"]) - from shared.sx.page import get_template_context - from sx.sx_components import _post_entries_content_sx - tctx = await get_template_context() - tctx["all_calendars"] = all_calendars - tctx["associated_entry_ids"] = associated_entry_ids - g.post_entries_content = _post_entries_content_sx(tctx) - - elif "defpage_post_settings" in ep: - from models.ghost_content import Post - from sqlalchemy import select as sa_select - from sqlalchemy.orm import selectinload - post_id = g.post_data["post"]["id"] - post = (await g.s.execute( - sa_select(Post) - .where(Post.id == post_id) - .options(selectinload(Post.tags)) - )).scalar_one_or_none() - ghost_post = _post_to_edit_dict(post) if post else {} - save_success = request.args.get("saved") == "1" - from shared.sx.page import get_template_context - from sx.sx_components import _post_settings_content_sx - tctx = await get_template_context() - tctx["ghost_post"] = ghost_post - tctx["save_success"] = save_success - g.post_settings_content = _post_settings_content_sx(tctx) - - elif "defpage_post_edit" in ep: - from models.ghost_content import Post - from sqlalchemy import select as sa_select - from sqlalchemy.orm import selectinload - from shared.infrastructure.data_client import fetch_data - post_id = g.post_data["post"]["id"] - post = (await g.s.execute( - sa_select(Post) - .where(Post.id == post_id) - .options(selectinload(Post.tags)) - )).scalar_one_or_none() - ghost_post = _post_to_edit_dict(post) if post else {} - save_success = request.args.get("saved") == "1" - save_error = request.args.get("error", "") - raw_newsletters = await fetch_data("account", "newsletters", required=False) or [] - from types import SimpleNamespace - newsletters = [SimpleNamespace(**nl) for nl in raw_newsletters] - from shared.sx.page import get_template_context - from sx.sx_components import _post_edit_content_sx - tctx = await get_template_context() - tctx["ghost_post"] = ghost_post - tctx["save_success"] = save_success - tctx["save_error"] = save_error - tctx["newsletters"] = newsletters - g.post_edit_content = _post_edit_content_sx(tctx) - - from shared.sx.pages import mount_pages - mount_pages(bp, "blog", names=[ - "post-admin", "post-data", "post-preview", - "post-entries", "post-settings", "post-edit", - ]) - @bp.put("/features/") @require_admin async def update_features(slug: str): @@ -468,7 +318,7 @@ def register(): except OptimisticLockError: from urllib.parse import quote return redirect( - host_url(url_for("blog.post.admin.defpage_post_settings", slug=slug)) + host_url(url_for("defpage_post_settings", slug=slug)) + "?error=" + quote("Someone else edited this post. Please reload and try again.") ) @@ -479,7 +329,7 @@ def register(): await invalidate_tag_cache("post.post_detail") # Redirect using the (possibly new) slug - return redirect(host_url(url_for("blog.post.admin.defpage_post_settings", slug=post.slug)) + "?saved=1") + return redirect(host_url(url_for("defpage_post_settings", slug=post.slug)) + "?saved=1") @bp.post("/edit/") @require_post_author @@ -504,11 +354,11 @@ def register(): try: lexical_doc = json.loads(lexical_raw) except (json.JSONDecodeError, TypeError): - return redirect(host_url(url_for("blog.post.admin.defpage_post_edit", slug=slug)) + "?error=" + quote("Invalid JSON in editor content.")) + return redirect(host_url(url_for("defpage_post_edit", slug=slug)) + "?error=" + quote("Invalid JSON in editor content.")) ok, reason = validate_lexical(lexical_doc) if not ok: - return redirect(host_url(url_for("blog.post.admin.defpage_post_edit", slug=slug)) + "?error=" + quote(reason)) + return redirect(host_url(url_for("defpage_post_edit", slug=slug)) + "?error=" + quote(reason)) # Publish workflow is_admin = bool((g.get("rights") or {}).get("admin")) @@ -544,7 +394,7 @@ def register(): ) except OptimisticLockError: return redirect( - host_url(url_for("blog.post.admin.defpage_post_edit", slug=slug)) + host_url(url_for("defpage_post_edit", slug=slug)) + "?error=" + quote("Someone else edited this post. Please reload and try again.") ) @@ -560,7 +410,7 @@ def register(): await invalidate_tag_cache("post.post_detail") # Redirect to GET (PRG pattern) — use post.slug in case it changed - redirect_url = host_url(url_for("blog.post.admin.defpage_post_edit", slug=post.slug)) + "?saved=1" + redirect_url = host_url(url_for("defpage_post_edit", slug=post.slug)) + "?saved=1" if publish_requested_msg: redirect_url += "&publish_requested=1" return redirect(redirect_url) diff --git a/blog/bp/snippets/routes.py b/blog/bp/snippets/routes.py index a5c7f22..f64d00b 100644 --- a/blog/bp/snippets/routes.py +++ b/blog/bp/snippets/routes.py @@ -1,11 +1,9 @@ from __future__ import annotations -from quart import Blueprint, make_response, request, g, abort +from quart import Blueprint, request, g, abort from sqlalchemy import select, or_ -from sqlalchemy.orm import selectinload from shared.browser.app.authz import require_login -from shared.browser.app.utils.htmx import is_htmx_request from shared.sx.helpers import sx_response from models import Snippet @@ -32,22 +30,6 @@ async def _visible_snippets(session): def register(): bp = Blueprint("snippets", __name__, url_prefix="/settings/snippets") - @bp.before_request - async def _prepare_page_data(): - if "defpage_" not in (request.endpoint or ""): - return - snippets = await _visible_snippets(g.s) - is_admin = g.rights.get("admin") - from shared.sx.page import get_template_context - from sx.sx_components import _snippets_main_panel_sx - tctx = await get_template_context() - tctx["snippets"] = snippets - tctx["is_admin"] = is_admin - g.snippets_content = _snippets_main_panel_sx(tctx) - - from shared.sx.pages import mount_pages - mount_pages(bp, "blog", names=["snippets-page"]) - @bp.delete("//") @require_login async def delete_snippet(snippet_id: int): diff --git a/blog/sxc/pages/__init__.py b/blog/sxc/pages/__init__.py index 19f1395..0536ba1 100644 --- a/blog/sxc/pages/__init__.py +++ b/blog/sxc/pages/__init__.py @@ -17,6 +17,96 @@ def _load_blog_page_files() -> None: load_page_dir(os.path.dirname(__file__), "blog") +# --------------------------------------------------------------------------- +# Shared hydration helpers +# --------------------------------------------------------------------------- + +def _add_to_defpage_ctx(**kwargs: Any) -> None: + from quart import g + if not hasattr(g, '_defpage_ctx'): + g._defpage_ctx = {} + g._defpage_ctx.update(kwargs) + + +async def _ensure_post_data(slug: str | None) -> None: + """Load post data and set g.post_data + defpage context. + + Replicates post bp's hydrate_post_data + context_processor. + """ + from quart import g, abort + + if hasattr(g, 'post_data') and g.post_data: + await _inject_post_context(g.post_data) + return + + if not slug: + abort(404) + + from bp.post.services.post_data import post_data + + is_admin = bool((g.get("rights") or {}).get("admin")) + p_data = await post_data(slug, g.s, include_drafts=True) + if not p_data: + abort(404) + + # Draft access control + if p_data["post"].get("status") != "published": + if is_admin: + pass + elif g.user and p_data["post"].get("user_id") == g.user.id: + pass + else: + abort(404) + + g.post_data = p_data + g.post_slug = slug + await _inject_post_context(p_data) + + +async def _inject_post_context(p_data: dict) -> None: + """Add post context_processor data to defpage context.""" + from shared.config import config + from shared.infrastructure.fragments import fetch_fragment + from shared.infrastructure.data_client import fetch_data + from shared.contracts.dtos import CartSummaryDTO, dto_from_dict + from shared.infrastructure.cart_identity import current_cart_identity + + db_post_id = p_data["post"]["id"] + post_slug = p_data["post"]["slug"] + + container_nav = await fetch_fragment("relations", "container-nav", params={ + "container_type": "page", + "container_id": str(db_post_id), + "post_slug": post_slug, + }) + + ctx: dict = { + **p_data, + "base_title": config()["title"], + "container_nav": container_nav, + } + + if p_data["post"].get("is_page"): + ident = current_cart_identity() + summary_params: dict = {"page_slug": post_slug} + if ident["user_id"] is not None: + summary_params["user_id"] = ident["user_id"] + if ident["session_id"] is not None: + summary_params["session_id"] = ident["session_id"] + raw_summary = await fetch_data( + "cart", "cart-summary", params=summary_params, required=False, + ) + page_summary = dto_from_dict(CartSummaryDTO, raw_summary) if raw_summary else CartSummaryDTO() + ctx["page_cart_count"] = ( + page_summary.count + page_summary.calendar_count + page_summary.ticket_count + ) + ctx["page_cart_total"] = float( + page_summary.total + page_summary.calendar_total + page_summary.ticket_total + ) + + _add_to_defpage_ctx(**ctx) + + # --------------------------------------------------------------------------- # Layouts # --------------------------------------------------------------------------- @@ -110,48 +200,48 @@ def _sub_settings_oob(ctx: dict, row_id: str, child_id: str, def _cache_full(ctx: dict, **kw: Any) -> str: return _sub_settings_full(ctx, "cache-row", "cache-header-child", - "settings.defpage_cache_page", "refresh", "Cache") + "defpage_cache_page", "refresh", "Cache") def _cache_oob(ctx: dict, **kw: Any) -> str: return _sub_settings_oob(ctx, "cache-row", "cache-header-child", - "settings.defpage_cache_page", "refresh", "Cache") + "defpage_cache_page", "refresh", "Cache") # --- Snippets --- def _snippets_full(ctx: dict, **kw: Any) -> str: return _sub_settings_full(ctx, "snippets-row", "snippets-header-child", - "snippets.defpage_snippets_page", "puzzle-piece", "Snippets") + "defpage_snippets_page", "puzzle-piece", "Snippets") def _snippets_oob(ctx: dict, **kw: Any) -> str: return _sub_settings_oob(ctx, "snippets-row", "snippets-header-child", - "snippets.defpage_snippets_page", "puzzle-piece", "Snippets") + "defpage_snippets_page", "puzzle-piece", "Snippets") # --- Menu Items --- def _menu_items_full(ctx: dict, **kw: Any) -> str: return _sub_settings_full(ctx, "menu_items-row", "menu_items-header-child", - "menu_items.defpage_menu_items_page", "bars", "Menu Items") + "defpage_menu_items_page", "bars", "Menu Items") def _menu_items_oob(ctx: dict, **kw: Any) -> str: return _sub_settings_oob(ctx, "menu_items-row", "menu_items-header-child", - "menu_items.defpage_menu_items_page", "bars", "Menu Items") + "defpage_menu_items_page", "bars", "Menu Items") # --- Tag Groups --- def _tag_groups_full(ctx: dict, **kw: Any) -> str: return _sub_settings_full(ctx, "tag-groups-row", "tag-groups-header-child", - "blog.tag_groups_admin.defpage_tag_groups_page", "tags", "Tag Groups") + "defpage_tag_groups_page", "tags", "Tag Groups") def _tag_groups_oob(ctx: dict, **kw: Any) -> str: return _sub_settings_oob(ctx, "tag-groups-row", "tag-groups-header-child", - "blog.tag_groups_admin.defpage_tag_groups_page", "tags", "Tag Groups") + "defpage_tag_groups_page", "tags", "Tag Groups") # --- Tag Group Edit --- @@ -165,7 +255,7 @@ def _tag_group_edit_full(ctx: dict, **kw: Any) -> str: root_hdr = root_header_sx(ctx) settings_hdr = _settings_header_sx(ctx) sub_hdr = _sub_settings_header_sx("tag-groups-row", "tag-groups-header-child", - qurl("blog.tag_groups_admin.defpage_tag_group_edit", id=g_id), + qurl("defpage_tag_group_edit", id=g_id), "tags", "Tag Groups", ctx) return "(<> " + root_hdr + " " + settings_hdr + " " + sub_hdr + ")" @@ -178,14 +268,14 @@ def _tag_group_edit_oob(ctx: dict, **kw: Any) -> str: from sx.sx_components import _settings_header_sx, _sub_settings_header_sx settings_hdr_oob = _settings_header_sx(ctx, oob=True) sub_hdr = _sub_settings_header_sx("tag-groups-row", "tag-groups-header-child", - qurl("blog.tag_groups_admin.defpage_tag_group_edit", id=g_id), + qurl("defpage_tag_group_edit", id=g_id), "tags", "Tag Groups", ctx) sub_oob = oob_header_sx("root-settings-header-child", "tag-groups-header-child", sub_hdr) return "(<> " + settings_hdr_oob + " " + sub_oob + ")" # --------------------------------------------------------------------------- -# Page helpers (sync functions available in .sx defpage expressions) +# Page helpers (async functions available in .sx defpage expressions) # --------------------------------------------------------------------------- def _register_blog_helpers() -> None: @@ -208,71 +298,277 @@ def _register_blog_helpers() -> None: }) -def _h_editor_content(): +# --- Editor helpers --- + +async def _h_editor_content(**kw): + from sx.sx_components import render_editor_panel + return render_editor_panel() + + +async def _h_editor_page_content(**kw): + from sx.sx_components import render_editor_panel + return render_editor_panel(is_page=True) + + +# --- Post admin helpers --- + +async def _h_post_admin_content(slug=None, **kw): + await _ensure_post_data(slug) from quart import g - return getattr(g, "editor_content", "") + from sqlalchemy import select + from shared.models.page_config import PageConfig + post = (g.post_data or {}).get("post", {}) + features = {} + sumup_configured = False + sumup_merchant_code = "" + sumup_checkout_prefix = "" + if post.get("is_page"): + pc = (await g.s.execute( + select(PageConfig).where( + PageConfig.container_type == "page", + PageConfig.container_id == post["id"], + ) + )).scalar_one_or_none() + if pc: + features = pc.features or {} + sumup_configured = bool(pc.sumup_api_key) + sumup_merchant_code = pc.sumup_merchant_code or "" + sumup_checkout_prefix = pc.sumup_checkout_prefix or "" + from shared.sx.page import get_template_context + from sx.sx_components import _post_admin_main_panel_sx + tctx = await get_template_context() + tctx.update({ + "features": features, + "sumup_configured": sumup_configured, + "sumup_merchant_code": sumup_merchant_code, + "sumup_checkout_prefix": sumup_checkout_prefix, + }) + return _post_admin_main_panel_sx(tctx) -def _h_editor_page_content(): +async def _h_post_data_content(slug=None, **kw): + await _ensure_post_data(slug) + from shared.sx.page import get_template_context + from sx.sx_components import _post_data_content_sx + tctx = await get_template_context() + return _post_data_content_sx(tctx) + + +async def _h_post_preview_content(slug=None, **kw): + await _ensure_post_data(slug) from quart import g - return getattr(g, "editor_page_content", "") + from models.ghost_content import Post + from sqlalchemy import select as sa_select + post_id = g.post_data["post"]["id"] + post = (await g.s.execute( + sa_select(Post).where(Post.id == post_id) + )).scalar_one_or_none() + preview_ctx: dict = {} + sx_content = getattr(post, "sx_content", None) or "" + if sx_content: + from shared.sx.prettify import sx_to_pretty_sx + preview_ctx["sx_pretty"] = sx_to_pretty_sx(sx_content) + lexical_raw = getattr(post, "lexical", None) or "" + if lexical_raw: + from shared.sx.prettify import json_to_pretty_sx + preview_ctx["json_pretty"] = json_to_pretty_sx(lexical_raw) + if sx_content: + from shared.sx.parser import parse as sx_parse + from shared.sx.html import render as sx_html_render + from shared.sx.jinja_bridge import _COMPONENT_ENV + try: + parsed = sx_parse(sx_content) + preview_ctx["sx_rendered"] = sx_html_render(parsed, dict(_COMPONENT_ENV)) + except Exception: + preview_ctx["sx_rendered"] = "Error rendering sx" + if lexical_raw: + from bp.blog.ghost.lexical_renderer import render_lexical + try: + preview_ctx["lex_rendered"] = render_lexical(lexical_raw) + except Exception: + preview_ctx["lex_rendered"] = "Error rendering lexical" + from shared.sx.page import get_template_context + from sx.sx_components import _preview_main_panel_sx + tctx = await get_template_context() + tctx.update(preview_ctx) + return _preview_main_panel_sx(tctx) -def _h_post_admin_content(): +async def _h_post_entries_content(slug=None, **kw): + await _ensure_post_data(slug) from quart import g - return getattr(g, "post_admin_content", "") + from sqlalchemy import select + from shared.models.calendars import Calendar + from bp.post.services.entry_associations import get_post_entry_ids + post_id = g.post_data["post"]["id"] + associated_entry_ids = await get_post_entry_ids(post_id) + result = await g.s.execute( + select(Calendar) + .where(Calendar.deleted_at.is_(None)) + .order_by(Calendar.name.asc()) + ) + all_calendars = result.scalars().all() + for calendar in all_calendars: + await g.s.refresh(calendar, ["entries", "post"]) + from shared.sx.page import get_template_context + from sx.sx_components import _post_entries_content_sx + tctx = await get_template_context() + tctx["all_calendars"] = all_calendars + tctx["associated_entry_ids"] = associated_entry_ids + return _post_entries_content_sx(tctx) -def _h_post_data_content(): +async def _h_post_settings_content(slug=None, **kw): + await _ensure_post_data(slug) + from quart import g, request + from models.ghost_content import Post + from sqlalchemy import select as sa_select + from sqlalchemy.orm import selectinload + from bp.post.admin.routes import _post_to_edit_dict + post_id = g.post_data["post"]["id"] + post = (await g.s.execute( + sa_select(Post) + .where(Post.id == post_id) + .options(selectinload(Post.tags)) + )).scalar_one_or_none() + ghost_post = _post_to_edit_dict(post) if post else {} + save_success = request.args.get("saved") == "1" + from shared.sx.page import get_template_context + from sx.sx_components import _post_settings_content_sx + tctx = await get_template_context() + tctx["ghost_post"] = ghost_post + tctx["save_success"] = save_success + return _post_settings_content_sx(tctx) + + +async def _h_post_edit_content(slug=None, **kw): + await _ensure_post_data(slug) + from quart import g, request + from models.ghost_content import Post + from sqlalchemy import select as sa_select + from sqlalchemy.orm import selectinload + from shared.infrastructure.data_client import fetch_data + from bp.post.admin.routes import _post_to_edit_dict + post_id = g.post_data["post"]["id"] + post = (await g.s.execute( + sa_select(Post) + .where(Post.id == post_id) + .options(selectinload(Post.tags)) + )).scalar_one_or_none() + ghost_post = _post_to_edit_dict(post) if post else {} + save_success = request.args.get("saved") == "1" + save_error = request.args.get("error", "") + raw_newsletters = await fetch_data("account", "newsletters", required=False) or [] + from types import SimpleNamespace + newsletters = [SimpleNamespace(**nl) for nl in raw_newsletters] + from shared.sx.page import get_template_context + from sx.sx_components import _post_edit_content_sx + tctx = await get_template_context() + tctx["ghost_post"] = ghost_post + tctx["save_success"] = save_success + tctx["save_error"] = save_error + tctx["newsletters"] = newsletters + return _post_edit_content_sx(tctx) + + +# --- Settings helpers --- + +async def _h_settings_content(**kw): + from shared.sx.page import get_template_context + from sx.sx_components import _settings_main_panel_sx + tctx = await get_template_context() + return _settings_main_panel_sx(tctx) + + +async def _h_cache_content(**kw): + from shared.sx.page import get_template_context + from sx.sx_components import _cache_main_panel_sx + tctx = await get_template_context() + return _cache_main_panel_sx(tctx) + + +# --- Snippets helper --- + +async def _h_snippets_content(**kw): from quart import g - return getattr(g, "post_data_content", "") + from sqlalchemy import select, or_ + from models import Snippet + uid = g.user.id + is_admin = g.rights.get("admin") + filters = [Snippet.user_id == uid, Snippet.visibility == "shared"] + if is_admin: + filters.append(Snippet.visibility == "admin") + rows = (await g.s.execute( + select(Snippet).where(or_(*filters)).order_by(Snippet.name) + )).scalars().all() + from shared.sx.page import get_template_context + from sx.sx_components import _snippets_main_panel_sx + tctx = await get_template_context() + tctx["snippets"] = rows + tctx["is_admin"] = is_admin + return _snippets_main_panel_sx(tctx) -def _h_post_preview_content(): +# --- Menu Items helper --- + +async def _h_menu_items_content(**kw): from quart import g - return getattr(g, "post_preview_content", "") + from bp.menu_items.services.menu_items import get_all_menu_items + menu_items = await get_all_menu_items(g.s) + from shared.sx.page import get_template_context + from sx.sx_components import _menu_items_main_panel_sx + tctx = await get_template_context() + tctx["menu_items"] = menu_items + return _menu_items_main_panel_sx(tctx) -def _h_post_entries_content(): +# --- Tag Groups helpers --- + +async def _h_tag_groups_content(**kw): from quart import g - return getattr(g, "post_entries_content", "") + from sqlalchemy import select + from models.tag_group import TagGroup + from bp.blog.admin.routes import _unassigned_tags + groups = list( + (await g.s.execute( + select(TagGroup).order_by(TagGroup.sort_order, TagGroup.name) + )).scalars() + ) + unassigned = await _unassigned_tags(g.s) + from shared.sx.page import get_template_context + from sx.sx_components import _tag_groups_main_panel_sx + tctx = await get_template_context() + tctx.update({"groups": groups, "unassigned_tags": unassigned}) + return _tag_groups_main_panel_sx(tctx) -def _h_post_settings_content(): - from quart import g - return getattr(g, "post_settings_content", "") - - -def _h_post_edit_content(): - from quart import g - return getattr(g, "post_edit_content", "") - - -def _h_settings_content(): - from quart import g - return getattr(g, "settings_content", "") - - -def _h_cache_content(): - from quart import g - return getattr(g, "cache_content", "") - - -def _h_snippets_content(): - from quart import g - return getattr(g, "snippets_content", "") - - -def _h_menu_items_content(): - from quart import g - return getattr(g, "menu_items_content", "") - - -def _h_tag_groups_content(): - from quart import g - return getattr(g, "tag_groups_content", "") - - -def _h_tag_group_edit_content(): - from quart import g - return getattr(g, "tag_group_edit_content", "") +async def _h_tag_group_edit_content(id=None, **kw): + from quart import g, abort + from sqlalchemy import select + from models.tag_group import TagGroup, TagGroupTag + from models.ghost_content import Tag + tg = await g.s.get(TagGroup, id) + if not tg: + abort(404) + assigned_rows = list( + (await g.s.execute( + select(TagGroupTag.tag_id).where(TagGroupTag.tag_group_id == id) + )).scalars() + ) + all_tags = list( + (await g.s.execute( + select(Tag).where( + Tag.deleted_at.is_(None), + (Tag.visibility == "public") | (Tag.visibility.is_(None)), + ).order_by(Tag.name) + )).scalars() + ) + from shared.sx.page import get_template_context + from sx.sx_components import _tag_groups_edit_main_panel_sx + tctx = await get_template_context() + tctx.update({ + "group": tg, + "all_tags": all_tags, + "assigned_tag_ids": set(assigned_rows), + }) + return _tag_groups_edit_main_panel_sx(tctx) diff --git a/blog/sxc/pages/blog.sx b/blog/sxc/pages/blog.sx index 9f87393..21a816e 100644 --- a/blog/sxc/pages/blog.sx +++ b/blog/sxc/pages/blog.sx @@ -15,54 +15,54 @@ :layout :blog :content (editor-page-content)) -; --- Post admin pages (nested under //admin/) --- +; --- Post admin pages (absolute paths under //admin/) --- (defpage post-admin - :path "/" + :path "//admin/" :auth :admin :layout (:post-admin :selected "admin") - :content (post-admin-content)) + :content (post-admin-content slug)) (defpage post-data - :path "/data/" + :path "//admin/data/" :auth :admin :layout (:post-admin :selected "data") - :content (post-data-content)) + :content (post-data-content slug)) (defpage post-preview - :path "/preview/" + :path "//admin/preview/" :auth :admin :layout (:post-admin :selected "preview") - :content (post-preview-content)) + :content (post-preview-content slug)) (defpage post-entries - :path "/entries/" + :path "//admin/entries/" :auth :admin :layout (:post-admin :selected "entries") - :content (post-entries-content)) + :content (post-entries-content slug)) (defpage post-settings - :path "/settings/" + :path "//admin/settings/" :auth :post_author :layout (:post-admin :selected "settings") - :content (post-settings-content)) + :content (post-settings-content slug)) (defpage post-edit - :path "/edit/" + :path "//admin/edit/" :auth :post_author :layout (:post-admin :selected "edit") - :content (post-edit-content)) + :content (post-edit-content slug)) -; --- Settings pages --- +; --- Settings pages (absolute paths) --- (defpage settings-home - :path "/" + :path "/settings/" :auth :admin :layout :blog-settings :content (settings-content)) (defpage cache-page - :path "/cache/" + :path "/settings/cache/" :auth :admin :layout :blog-cache :content (cache-content)) @@ -70,7 +70,7 @@ ; --- Snippets --- (defpage snippets-page - :path "/" + :path "/settings/snippets/" :auth :login :layout :blog-snippets :content (snippets-content)) @@ -78,7 +78,7 @@ ; --- Menu Items --- (defpage menu-items-page - :path "/" + :path "/settings/menu_items/" :auth :admin :layout :blog-menu-items :content (menu-items-content)) @@ -86,13 +86,13 @@ ; --- Tag Groups --- (defpage tag-groups-page - :path "/" + :path "/settings/tag-groups/" :auth :admin :layout :blog-tag-groups :content (tag-groups-content)) (defpage tag-group-edit - :path "//" + :path "/settings/tag-groups//" :auth :admin :layout :blog-tag-group-edit - :content (tag-group-edit-content)) + :content (tag-group-edit-content id)) diff --git a/cart/app.py b/cart/app.py index 2b50759..5ae5140 100644 --- a/cart/app.py +++ b/cart/app.py @@ -185,8 +185,6 @@ def create_app() -> "Quart": from sxc.pages import setup_cart_pages setup_cart_pages() - from shared.sx.pages import mount_pages - # --- Blueprint registration --- # Static prefixes first, dynamic (page_slug) last @@ -196,21 +194,22 @@ def create_app() -> "Quart": url_prefix="/", ) - # Cart overview at GET / + # Cart overview blueprint (no defpage routes, just action endpoints) overview_bp = register_cart_overview(url_prefix="/") - mount_pages(overview_bp, "cart", names=["cart-overview"]) app.register_blueprint(overview_bp, url_prefix="/") - # Page admin at //admin/ (before page_cart catch-all) + # Page admin (PUT /payments/ etc.) admin_bp = register_page_admin() - mount_pages(admin_bp, "cart", names=["cart-admin", "cart-payments"]) app.register_blueprint(admin_bp, url_prefix="//admin") - # Page cart at // (dynamic, matched last) + # Page cart (POST /checkout/ etc.) page_cart_bp = register_page_cart(url_prefix="/") - mount_pages(page_cart_bp, "cart", names=["page-cart-view"]) app.register_blueprint(page_cart_bp, url_prefix="/") + # Auto-mount all defpages with absolute paths + from shared.sx.pages import auto_mount_pages + auto_mount_pages(app, "cart") + return app diff --git a/cart/bp/cart/global_routes.py b/cart/bp/cart/global_routes.py index c0819d0..ef41637 100644 --- a/cart/bp/cart/global_routes.py +++ b/cart/bp/cart/global_routes.py @@ -56,9 +56,9 @@ def register(url_prefix: str) -> Blueprint: if request.headers.get("SX-Request") == "true" or request.headers.get("HX-Request") == "true": # Redirect to overview for HTMX - return redirect(url_for("cart_overview.defpage_cart_overview")) + return redirect(url_for("defpage_cart_overview")) - return redirect(url_for("cart_overview.defpage_cart_overview")) + return redirect(url_for("defpage_cart_overview")) @bp.post("/quantity//") async def update_quantity(product_id: int): @@ -137,7 +137,7 @@ def register(url_prefix: str) -> Blueprint: tickets = await get_ticket_cart_entries(g.s) if not cart and not calendar_entries and not tickets: - return redirect(url_for("cart_overview.defpage_cart_overview")) + return redirect(url_for("defpage_cart_overview")) product_total = total(cart) or 0 calendar_amount = calendar_total(calendar_entries) or 0 @@ -145,7 +145,7 @@ def register(url_prefix: str) -> Blueprint: cart_total = product_total + calendar_amount + ticket_amount if cart_total <= 0: - return redirect(url_for("cart_overview.defpage_cart_overview")) + return redirect(url_for("defpage_cart_overview")) try: page_config = await resolve_page_config(g.s, cart, calendar_entries, tickets) diff --git a/cart/bp/cart/overview_routes.py b/cart/bp/cart/overview_routes.py index a6b0d52..392902d 100644 --- a/cart/bp/cart/overview_routes.py +++ b/cart/bp/cart/overview_routes.py @@ -3,24 +3,9 @@ from __future__ import annotations -from quart import Blueprint, g, request - -from .services import get_cart_grouped_by_page +from quart import Blueprint def register(url_prefix: str) -> Blueprint: bp = Blueprint("cart_overview", __name__, url_prefix=url_prefix) - - @bp.before_request - async def _prepare_page_data(): - """Load overview data for defpage route.""" - endpoint = request.endpoint or "" - if not endpoint.endswith("defpage_cart_overview"): - return - from shared.sx.page import get_template_context - from sx.sx_components import _overview_main_panel_sx - page_groups = await get_cart_grouped_by_page(g.s) - ctx = await get_template_context() - g.overview_content = _overview_main_panel_sx(page_groups, ctx) - return bp diff --git a/cart/bp/cart/page_routes.py b/cart/bp/cart/page_routes.py index 0cbb067..d1d2633 100644 --- a/cart/bp/cart/page_routes.py +++ b/cart/bp/cart/page_routes.py @@ -19,26 +19,6 @@ from .services import current_cart_identity def register(url_prefix: str) -> Blueprint: bp = Blueprint("page_cart", __name__, url_prefix=url_prefix) - @bp.before_request - async def _prepare_page_data(): - """Load page cart data for defpage route.""" - endpoint = request.endpoint or "" - if not endpoint.endswith("defpage_page_cart_view"): - return - post = g.page_post - cart = await get_cart_for_page(g.s, post.id) - cal_entries = await get_calendar_entries_for_page(g.s, post.id) - page_tickets = await get_tickets_for_page(g.s, post.id) - ticket_groups = group_tickets(page_tickets) - - from shared.sx.page import get_template_context - from sx.sx_components import _page_cart_main_panel_sx - ctx = await get_template_context() - g.page_cart_content = _page_cart_main_panel_sx( - ctx, cart, cal_entries, page_tickets, ticket_groups, - total, calendar_total, ticket_total, - ) - @bp.post("/checkout/") async def page_checkout(): post = g.page_post @@ -48,7 +28,7 @@ def register(url_prefix: str) -> Blueprint: page_tickets = await get_tickets_for_page(g.s, post.id) if not cart and not cal_entries and not page_tickets: - return redirect(url_for("page_cart.defpage_page_cart_view")) + return redirect(url_for("defpage_page_cart_view")) product_total_val = total(cart) or 0 calendar_amount = calendar_total(cal_entries) or 0 @@ -56,7 +36,7 @@ def register(url_prefix: str) -> Blueprint: cart_total = product_total_val + calendar_amount + ticket_amount if cart_total <= 0: - return redirect(url_for("page_cart.defpage_page_cart_view")) + return redirect(url_for("defpage_page_cart_view")) ident = current_cart_identity() diff --git a/cart/bp/orders/routes.py b/cart/bp/orders/routes.py index 41d989b..abb3e6a 100644 --- a/cart/bp/orders/routes.py +++ b/cart/bp/orders/routes.py @@ -1,6 +1,6 @@ from __future__ import annotations -from quart import Blueprint, g, render_template, redirect, url_for, make_response +from quart import Blueprint, g, redirect, url_for, make_response from sqlalchemy import select, func, or_, cast, String, exists from sqlalchemy.orm import selectinload diff --git a/cart/bp/page_admin/routes.py b/cart/bp/page_admin/routes.py index d8455fc..f77492c 100644 --- a/cart/bp/page_admin/routes.py +++ b/cart/bp/page_admin/routes.py @@ -13,23 +13,6 @@ from shared.sx.helpers import sx_response def register(): bp = Blueprint("page_admin", __name__) - @bp.before_request - async def _prepare_page_data(): - """Pre-render admin content for defpage routes.""" - endpoint = request.endpoint or "" - if request.method != "GET": - return - if endpoint.endswith("defpage_cart_admin"): - from shared.sx.page import get_template_context - from sx.sx_components import _cart_admin_main_panel_sx - ctx = await get_template_context() - g.cart_admin_content = _cart_admin_main_panel_sx(ctx) - elif endpoint.endswith("defpage_cart_payments"): - from shared.sx.page import get_template_context - from sx.sx_components import _cart_payments_main_panel_sx - ctx = await get_template_context() - g.cart_payments_content = _cart_payments_main_panel_sx(ctx) - @bp.put("/payments/") @require_admin async def update_sumup(**kwargs): diff --git a/cart/sx/sx_components.py b/cart/sx/sx_components.py index ec10db3..293ffcf 100644 --- a/cart/sx/sx_components.py +++ b/cart/sx/sx_components.py @@ -771,7 +771,7 @@ def _cart_page_admin_header_sx(ctx: dict, page_post: Any, *, oob: bool = False, def _cart_admin_main_panel_sx(ctx: dict) -> str: """Admin overview panel -- links to sub-admin pages.""" from quart import url_for - payments_href = url_for("page_admin.defpage_cart_payments") + payments_href = url_for("defpage_cart_payments") return ( '(div :id "main-panel"' ' (div :class "flex items-center justify-between p-3 border-b"' diff --git a/cart/sxc/pages/__init__.py b/cart/sxc/pages/__init__.py index fdac60d..710e6ed 100644 --- a/cart/sxc/pages/__init__.py +++ b/cart/sxc/pages/__init__.py @@ -90,32 +90,48 @@ def _register_cart_helpers() -> None: }) -def _h_overview_content(): +async def _h_overview_content(**kw): from quart import g - page_groups = getattr(g, "overview_page_groups", []) + from shared.sx.page import get_template_context from sx.sx_components import _overview_main_panel_sx - # _overview_main_panel_sx needs ctx for url helpers — use g-based approach - # The function reads cart_url from ctx, which we can get from template context - from shared.sx.page import get_template_context - import asyncio - # Page helpers are sync — we pre-compute in before_request - return getattr(g, "overview_content", "") + from bp.cart.services import get_cart_grouped_by_page + page_groups = await get_cart_grouped_by_page(g.s) + ctx = await get_template_context() + return _overview_main_panel_sx(page_groups, ctx) -def _h_page_cart_content(): +async def _h_page_cart_content(page_slug=None, **kw): from quart import g - return getattr(g, "page_cart_content", "") + from shared.sx.page import get_template_context + from sx.sx_components import _page_cart_main_panel_sx + from bp.cart.services import total, calendar_total, ticket_total + from bp.cart.services.page_cart import ( + get_cart_for_page, get_calendar_entries_for_page, get_tickets_for_page, + ) + from bp.cart.services.ticket_groups import group_tickets + + post = g.page_post + cart = await get_cart_for_page(g.s, post.id) + cal_entries = await get_calendar_entries_for_page(g.s, post.id) + page_tickets = await get_tickets_for_page(g.s, post.id) + ticket_groups = group_tickets(page_tickets) + + ctx = await get_template_context() + return _page_cart_main_panel_sx( + ctx, cart, cal_entries, page_tickets, ticket_groups, + total, calendar_total, ticket_total, + ) -def _h_cart_admin_content(): +async def _h_cart_admin_content(page_slug=None, **kw): + from shared.sx.page import get_template_context from sx.sx_components import _cart_admin_main_panel_sx + ctx = await get_template_context() + return _cart_admin_main_panel_sx(ctx) + + +async def _h_cart_payments_content(page_slug=None, **kw): from shared.sx.page import get_template_context - # Sync helper — _cart_admin_main_panel_sx is sync, but needs ctx - # We can pre-compute in before_request, or use get_template_context_sync-like pattern - from quart import g - return getattr(g, "cart_admin_content", "") - - -def _h_cart_payments_content(): - from quart import g - return getattr(g, "cart_payments_content", "") + from sx.sx_components import _cart_payments_main_panel_sx + ctx = await get_template_context() + return _cart_payments_main_panel_sx(ctx) diff --git a/cart/sxc/pages/cart.sx b/cart/sxc/pages/cart.sx index 23ec20b..05ada99 100644 --- a/cart/sxc/pages/cart.sx +++ b/cart/sxc/pages/cart.sx @@ -7,19 +7,19 @@ :content (overview-content)) (defpage page-cart-view - :path "/" + :path "//" :auth :public :layout :cart-page :content (page-cart-content)) (defpage cart-admin - :path "/" + :path "//admin/" :auth :admin :layout :cart-admin :content (cart-admin-content)) (defpage cart-payments - :path "/payments/" + :path "//admin/payments/" :auth :admin :layout (:cart-admin :selected "payments") :content (cart-payments-content)) diff --git a/events/app.py b/events/app.py index df8190d..58a5ce0 100644 --- a/events/app.py +++ b/events/app.py @@ -171,19 +171,25 @@ def create_app() -> "Quart": "markets": markets, } + # Auto-mount all defpages with absolute paths + from shared.sx.pages import auto_mount_pages + auto_mount_pages(app, "events") + # Tickets blueprint — user-facing ticket views and QR codes from bp.tickets.routes import register as register_tickets tickets_bp = register_tickets() - from shared.sx.pages import mount_pages - mount_pages(tickets_bp, "events", names=["my-tickets", "ticket-detail"]) app.register_blueprint(tickets_bp) # Ticket admin — check-in interface (admin only) from bp.ticket_admin.routes import register as register_ticket_admin ticket_admin_bp = register_ticket_admin() - mount_pages(ticket_admin_bp, "events", names=["ticket-admin"]) app.register_blueprint(ticket_admin_bp) + # --- Pass defpage helper data to template context for layouts --- + @app.context_processor + async def inject_events_data(): + return getattr(g, '_defpage_ctx', {}) + # --- oEmbed endpoint --- @app.get("/oembed") async def oembed(): diff --git a/events/bp/all_events/routes.py b/events/bp/all_events/routes.py index 1f091ac..b657b32 100644 --- a/events/bp/all_events/routes.py +++ b/events/bp/all_events/routes.py @@ -11,7 +11,7 @@ Routes: """ from __future__ import annotations -from quart import Blueprint, g, request, render_template, make_response +from quart import Blueprint, g, request, make_response from shared.browser.app.utils.htmx import is_htmx_request from shared.sx.helpers import sx_response diff --git a/events/bp/calendar/admin/routes.py b/events/bp/calendar/admin/routes.py index f1c30ac..e7fcc01 100644 --- a/events/bp/calendar/admin/routes.py +++ b/events/bp/calendar/admin/routes.py @@ -1,7 +1,7 @@ from __future__ import annotations from quart import ( - request, Blueprint, g + Blueprint, g, request, ) @@ -15,18 +15,6 @@ from shared.sx.helpers import sx_response def register(): bp = Blueprint("admin", __name__, url_prefix='/admin') - @bp.before_request - async def _prepare_page_data(): - if "defpage_" not in (request.endpoint or ""): - return - from shared.sx.page import get_template_context - from sx.sx_components import _calendar_admin_main_panel_html - ctx = await get_template_context() - g.calendar_admin_content = _calendar_admin_main_panel_html(ctx) - - from shared.sx.pages import mount_pages - mount_pages(bp, "events", names=["calendar-admin"]) - @bp.get("/description/") @require_admin async def calendar_description_edit(calendar_slug: str, **kwargs): diff --git a/events/bp/calendar/routes.py b/events/bp/calendar/routes.py index 4cffc4d..aa07ad9 100644 --- a/events/bp/calendar/routes.py +++ b/events/bp/calendar/routes.py @@ -2,7 +2,7 @@ from __future__ import annotations from datetime import datetime, timezone from quart import ( - request, render_template, make_response, Blueprint, g, abort, session as qsession + request, make_response, Blueprint, g, abort, session as qsession ) diff --git a/events/bp/calendar_entries/routes.py b/events/bp/calendar_entries/routes.py index 84faff0..2ba164f 100644 --- a/events/bp/calendar_entries/routes.py +++ b/events/bp/calendar_entries/routes.py @@ -3,7 +3,7 @@ from datetime import datetime, timezone from decimal import Decimal from quart import ( - request, render_template, make_response, + request, make_response, Blueprint, g, redirect, url_for, jsonify, ) diff --git a/events/bp/calendar_entry/admin/routes.py b/events/bp/calendar_entry/admin/routes.py index ecb2fa9..6963b91 100644 --- a/events/bp/calendar_entry/admin/routes.py +++ b/events/bp/calendar_entry/admin/routes.py @@ -1,23 +1,8 @@ from __future__ import annotations -from quart import ( - request, Blueprint, g -) +from quart import Blueprint def register(): bp = Blueprint("admin", __name__, url_prefix='/admin') - - @bp.before_request - async def _prepare_page_data(): - if "defpage_" not in (request.endpoint or ""): - return - from shared.sx.page import get_template_context - from sx.sx_components import _entry_admin_main_panel_html - ctx = await get_template_context() - g.entry_admin_content = _entry_admin_main_panel_html(ctx) - - from shared.sx.pages import mount_pages - mount_pages(bp, "events", names=["entry-admin"]) - return bp diff --git a/events/bp/calendar_entry/routes.py b/events/bp/calendar_entry/routes.py index 5de1a59..4139966 100644 --- a/events/bp/calendar_entry/routes.py +++ b/events/bp/calendar_entry/routes.py @@ -11,7 +11,7 @@ from shared.browser.app.redis_cacher import clear_cache from sqlalchemy import select from quart import ( - request, render_template, make_response, Blueprint, g, jsonify + request, make_response, Blueprint, g, jsonify ) from ..calendar_entries.services.entries import ( svc_update_entry, @@ -238,19 +238,6 @@ def register(): "user_ticket_counts_by_type": user_ticket_counts_by_type, "container_nav": container_nav, } - @bp.before_request - async def _prepare_page_data(): - if "defpage_" not in (request.endpoint or ""): - return - from shared.sx.page import get_template_context - from sx.sx_components import _entry_main_panel_html, _entry_nav_html - ctx = await get_template_context() - g.entry_content = _entry_main_panel_html(ctx) - g.entry_menu = _entry_nav_html(ctx) - - from shared.sx.pages import mount_pages - mount_pages(bp, "events", names=["entry-detail"]) - @bp.get("/edit/") @require_admin async def get_edit(entry_id: int, **rest): diff --git a/events/bp/calendars/routes.py b/events/bp/calendars/routes.py index cec8afc..350a939 100644 --- a/events/bp/calendars/routes.py +++ b/events/bp/calendars/routes.py @@ -1,7 +1,7 @@ from __future__ import annotations from quart import ( - request, render_template, make_response, Blueprint, g + request, make_response, Blueprint, g ) from sqlalchemy import select diff --git a/events/bp/day/admin/routes.py b/events/bp/day/admin/routes.py index 4347787..6963b91 100644 --- a/events/bp/day/admin/routes.py +++ b/events/bp/day/admin/routes.py @@ -1,21 +1,8 @@ from __future__ import annotations -from quart import ( - request, Blueprint, g -) +from quart import Blueprint def register(): bp = Blueprint("admin", __name__, url_prefix='/admin') - - @bp.before_request - async def _prepare_page_data(): - if "defpage_" not in (request.endpoint or ""): - return - from sx.sx_components import _day_admin_main_panel_html - g.day_admin_content = _day_admin_main_panel_html({}) - - from shared.sx.pages import mount_pages - mount_pages(bp, "events", names=["day-admin"]) - return bp diff --git a/events/bp/day/routes.py b/events/bp/day/routes.py index ad78eb4..f8412d8 100644 --- a/events/bp/day/routes.py +++ b/events/bp/day/routes.py @@ -2,7 +2,7 @@ from __future__ import annotations from datetime import datetime, timezone, date, timedelta from quart import ( - request, render_template, make_response, Blueprint, g, abort, session as qsession + request, make_response, Blueprint, g, abort, session as qsession ) from bp.calendar.services import get_visible_entries_for_period diff --git a/events/bp/markets/routes.py b/events/bp/markets/routes.py index 59e95b9..2335a86 100644 --- a/events/bp/markets/routes.py +++ b/events/bp/markets/routes.py @@ -1,7 +1,7 @@ from __future__ import annotations from quart import ( - request, render_template, make_response, Blueprint, g + request, make_response, Blueprint, g ) from .services.markets import ( @@ -21,18 +21,6 @@ def register(): async def inject_root(): return {} - @bp.before_request - async def _prepare_page_data(): - if "defpage_" not in (request.endpoint or ""): - return - from shared.sx.page import get_template_context - from sx.sx_components import _markets_main_panel_html - ctx = await get_template_context() - g.markets_content = _markets_main_panel_html(ctx) - - from shared.sx.pages import mount_pages - mount_pages(bp, "events", names=["events-markets"]) - @bp.post("/new/") @require_admin async def create_market(**kwargs): diff --git a/events/bp/page/routes.py b/events/bp/page/routes.py index 8ee7571..bb1a700 100644 --- a/events/bp/page/routes.py +++ b/events/bp/page/routes.py @@ -8,7 +8,7 @@ Routes: """ from __future__ import annotations -from quart import Blueprint, g, request, render_template, make_response +from quart import Blueprint, g, request, make_response from shared.browser.app.utils.htmx import is_htmx_request from shared.sx.helpers import sx_response diff --git a/events/bp/slot/routes.py b/events/bp/slot/routes.py index 7052623..496e892 100644 --- a/events/bp/slot/routes.py +++ b/events/bp/slot/routes.py @@ -29,27 +29,6 @@ from shared.sx.helpers import sx_response def register(): bp = Blueprint("slot", __name__, url_prefix='/') - @bp.before_request - async def _prepare_page_data(): - if "defpage_" not in (request.endpoint or ""): - return - slot_id = (request.view_args or {}).get("slot_id") - slot = await svc_get_slot(g.s, slot_id) if slot_id else None - if not slot: - from quart import abort - abort(404) - g.slot = slot - calendar = getattr(g, "calendar", None) - from sx.sx_components import render_slot_main_panel - g.slot_content = render_slot_main_panel(slot, calendar) - - @bp.context_processor - async def _inject_slot(): - return {"slot": getattr(g, "slot", None)} - - from shared.sx.pages import mount_pages - mount_pages(bp, "events", names=["slot-detail"]) - @bp.get("/edit/") @require_admin async def get_edit(slot_id: int, **kwargs): diff --git a/events/bp/slots/routes.py b/events/bp/slots/routes.py index 88b7cba..e8443d0 100644 --- a/events/bp/slots/routes.py +++ b/events/bp/slots/routes.py @@ -38,18 +38,6 @@ def register(): } return {"slots": []} - @bp.before_request - async def _prepare_page_data(): - if "defpage_" not in (request.endpoint or ""): - return - calendar = getattr(g, "calendar", None) - slots = await svc_list_slots(g.s, calendar.id) if calendar else [] - from sx.sx_components import render_slots_table - g.slots_content = render_slots_table(slots, calendar) - - from shared.sx.pages import mount_pages - mount_pages(bp, "events", names=["slots-listing"]) - @bp.post("/") @require_admin @clear_cache(tag="calendars", tag_scope="all") diff --git a/events/bp/ticket_admin/routes.py b/events/bp/ticket_admin/routes.py index 7188bdb..993c969 100644 --- a/events/bp/ticket_admin/routes.py +++ b/events/bp/ticket_admin/routes.py @@ -14,10 +14,10 @@ import logging from quart import ( Blueprint, g, request, make_response, ) -from sqlalchemy import select, func +from sqlalchemy import select from sqlalchemy.orm import selectinload -from models.calendars import CalendarEntry, Ticket, TicketType +from models.calendars import CalendarEntry from shared.browser.app.authz import require_admin from shared.browser.app.redis_cacher import clear_cache from shared.sx.helpers import sx_response @@ -34,46 +34,6 @@ logger = logging.getLogger(__name__) def register() -> Blueprint: bp = Blueprint("ticket_admin", __name__, url_prefix="/admin/tickets") - @bp.before_request - async def _prepare_page_data(): - if "defpage_" not in (request.endpoint or ""): - return - # Get recent tickets - result = await g.s.execute( - select(Ticket) - .options( - selectinload(Ticket.entry).selectinload(CalendarEntry.calendar), - selectinload(Ticket.ticket_type), - ) - .order_by(Ticket.created_at.desc()) - .limit(50) - ) - tickets = result.scalars().all() - - # Stats - total = await g.s.scalar(select(func.count(Ticket.id))) - confirmed = await g.s.scalar( - select(func.count(Ticket.id)).where(Ticket.state == "confirmed") - ) - checked_in = await g.s.scalar( - select(func.count(Ticket.id)).where(Ticket.state == "checked_in") - ) - reserved = await g.s.scalar( - select(func.count(Ticket.id)).where(Ticket.state == "reserved") - ) - - stats = { - "total": total or 0, - "confirmed": confirmed or 0, - "checked_in": checked_in or 0, - "reserved": reserved or 0, - } - - from shared.sx.page import get_template_context - from sx.sx_components import _ticket_admin_main_panel_html - ctx = await get_template_context() - g.ticket_admin_content = _ticket_admin_main_panel_html(ctx, tickets, stats) - @bp.get("/entry//") @require_admin async def entry_tickets(entry_id: int): diff --git a/events/bp/ticket_type/routes.py b/events/bp/ticket_type/routes.py index 9909162..09e1191 100644 --- a/events/bp/ticket_type/routes.py +++ b/events/bp/ticket_type/routes.py @@ -22,32 +22,6 @@ from shared.sx.helpers import sx_response def register(): bp = Blueprint("ticket_type", __name__, url_prefix='/') - @bp.before_request - async def _prepare_page_data(): - if "defpage_" not in (request.endpoint or ""): - return - ticket_type_id = (request.view_args or {}).get("ticket_type_id") - ticket_type = await svc_get_ticket_type(g.s, ticket_type_id) if ticket_type_id else None - if not ticket_type: - from quart import abort - abort(404) - g.ticket_type = ticket_type - entry = getattr(g, "entry", None) - calendar = getattr(g, "calendar", None) - va = request.view_args or {} - from sx.sx_components import render_ticket_type_main_panel - g.ticket_type_content = render_ticket_type_main_panel( - ticket_type, entry, calendar, - va.get("day"), va.get("month"), va.get("year"), - ) - - @bp.context_processor - async def _inject_ticket_type(): - return {"ticket_type": getattr(g, "ticket_type", None)} - - from shared.sx.pages import mount_pages - mount_pages(bp, "events", names=["ticket-type-detail"]) - @bp.get("/edit/") @require_admin async def get_edit(ticket_type_id: int, **kwargs): diff --git a/events/bp/ticket_types/routes.py b/events/bp/ticket_types/routes.py index 3cb856d..2163c34 100644 --- a/events/bp/ticket_types/routes.py +++ b/events/bp/ticket_types/routes.py @@ -35,23 +35,6 @@ def register(): } return {"ticket_types": []} - @bp.before_request - async def _prepare_page_data(): - if "defpage_" not in (request.endpoint or ""): - return - entry = getattr(g, "entry", None) - calendar = getattr(g, "calendar", None) - ticket_types = await svc_list_ticket_types(g.s, entry.id) if entry else [] - va = request.view_args or {} - from sx.sx_components import render_ticket_types_table - g.ticket_types_content = render_ticket_types_table( - ticket_types, entry, calendar, - va.get("day"), va.get("month"), va.get("year"), - ) - - from shared.sx.pages import mount_pages - mount_pages(bp, "events", names=["ticket-types-listing"]) - @bp.post("/") @require_admin @clear_cache(tag="calendars", tag_scope="all") diff --git a/events/bp/tickets/routes.py b/events/bp/tickets/routes.py index 8a8f48a..54e8586 100644 --- a/events/bp/tickets/routes.py +++ b/events/bp/tickets/routes.py @@ -24,8 +24,6 @@ from shared.sx.helpers import sx_response from .services.tickets import ( create_ticket, - get_ticket_by_code, - get_user_tickets, get_available_ticket_count, get_tickets_for_entry, get_sold_ticket_count, @@ -39,44 +37,6 @@ logger = logging.getLogger(__name__) def register() -> Blueprint: bp = Blueprint("tickets", __name__, url_prefix="/tickets") - @bp.before_request - async def _prepare_page_data(): - ep = request.endpoint or "" - if "defpage_my_tickets" in ep: - ident = current_cart_identity() - tickets = await get_user_tickets( - g.s, - user_id=ident["user_id"], - session_id=ident["session_id"], - ) - from shared.sx.page import get_template_context - from sx.sx_components import _tickets_main_panel_html - ctx = await get_template_context() - g.tickets_content = _tickets_main_panel_html(ctx, tickets) - elif "defpage_ticket_detail" in ep: - code = (request.view_args or {}).get("code") - ticket = await get_ticket_by_code(g.s, code) if code else None - if not ticket: - from quart import abort - abort(404) - # Verify ownership - ident = current_cart_identity() - if ident["user_id"] is not None: - if ticket.user_id != ident["user_id"]: - from quart import abort - abort(404) - elif ident["session_id"] is not None: - if ticket.session_id != ident["session_id"]: - from quart import abort - abort(404) - else: - from quart import abort - abort(404) - from shared.sx.page import get_template_context - from sx.sx_components import _ticket_detail_panel_html - ctx = await get_template_context() - g.ticket_detail_content = _ticket_detail_panel_html(ctx, ticket) - @bp.post("/buy/") @clear_cache(tag="calendars", tag_scope="all") async def buy_tickets(): diff --git a/events/sx/sx_components.py b/events/sx/sx_components.py index ecfe2c2..8326e25 100644 --- a/events/sx/sx_components.py +++ b/events/sx/sx_components.py @@ -191,11 +191,11 @@ def _calendar_nav_sx(ctx: dict) -> str: select_colours = ctx.get("select_colours", "") parts = [] - slots_href = url_for("calendar.slots.defpage_slots_listing", calendar_slug=cal_slug) + slots_href = url_for("defpage_slots_listing", calendar_slug=cal_slug) parts.append(sx_call("nav-link", href=slots_href, icon="fa fa-clock", label="Slots", select_colours=select_colours)) if is_admin: - admin_href = url_for("calendar.admin.defpage_calendar_admin", calendar_slug=cal_slug) + admin_href = url_for("defpage_calendar_admin", calendar_slug=cal_slug) parts.append(sx_call("nav-link", href=admin_href, icon="fa fa-cog", select_colours=select_colours)) return "".join(parts) @@ -319,7 +319,7 @@ def _calendar_admin_header_sx(ctx: dict, *, oob: bool = False) -> str: nav_parts = [] if cal_slug: for endpoint, label in [ - ("calendar.slots.defpage_slots_listing", "slots"), + ("defpage_slots_listing", "slots"), ("calendar.admin.calendar_description_edit", "description"), ]: href = url_for(endpoint, calendar_slug=cal_slug) @@ -339,7 +339,7 @@ def _calendar_admin_header_sx(ctx: dict, *, oob: bool = False) -> str: def _markets_header_sx(ctx: dict, *, oob: bool = False) -> str: """Build the markets section header row.""" from quart import url_for - link_href = url_for("markets.defpage_events_markets") + link_href = url_for("defpage_events_markets") return sx_call("menu-row-sx", id="markets-row", level=3, link_href=link_href, link_label_content=SxExpr(sx_call("events-markets-label")), @@ -594,7 +594,7 @@ def _day_row_html(ctx: dict, entry) -> str: # Slot/Time slot = getattr(entry, "slot", None) if slot: - slot_href = url_for("calendar.slots.slot.defpage_slot_detail", calendar_slug=cal_slug, slot_id=slot.id) + slot_href = url_for("defpage_slot_detail", calendar_slug=cal_slug, slot_id=slot.id) time_start = slot.time_start.strftime("%H:%M") if slot.time_start else "" time_end = f" \u2192 {slot.time_end.strftime('%H:%M')}" if slot.time_end else "" slot_html = sx_call("events-day-row-slot", @@ -774,7 +774,7 @@ def _tickets_main_panel_html(ctx: dict, tickets: list) -> str: ticket_cards = [] if tickets: for ticket in tickets: - href = url_for("tickets.defpage_ticket_detail", code=ticket.code) + href = url_for("defpage_ticket_detail", code=ticket.code) entry = getattr(ticket, "entry", None) entry_name = entry.name if entry else "Unknown event" tt = getattr(ticket, "ticket_type", None) @@ -819,7 +819,7 @@ def _ticket_detail_panel_html(ctx: dict, ticket) -> str: bg_map = {"confirmed": "bg-emerald-50", "checked_in": "bg-blue-50", "reserved": "bg-amber-50"} header_bg = bg_map.get(state, "bg-stone-50") entry_name = entry.name if entry else "Ticket" - back_href = url_for("tickets.defpage_my_tickets") + back_href = url_for("defpage_my_tickets") # Badge with larger sizing badge = _ticket_state_badge_html(state).replace('px-2 py-0.5 text-xs', 'px-3 py-1 text-sm') @@ -2165,7 +2165,7 @@ def render_slots_table(slots, calendar) -> str: rows_html = "" if slots: for s in slots: - slot_href = url_for("calendar.slots.slot.defpage_slot_detail", calendar_slug=cal_slug, slot_id=s.id) + slot_href = url_for("defpage_slot_detail", calendar_slug=cal_slug, slot_id=s.id) del_url = url_for("calendar.slots.slot.slot_delete", calendar_slug=cal_slug, slot_id=s.id) desc = getattr(s, "description", "") or "" @@ -2309,7 +2309,7 @@ def render_buy_result(entry, created_tickets, remaining, cart_count) -> str: tickets_html = "" for ticket in created_tickets: - href = url_for("tickets.defpage_ticket_detail", code=ticket.code) + href = url_for("defpage_ticket_detail", code=ticket.code) tickets_html += sx_call("events-buy-result-ticket", href=href, code_short=ticket.code[:12] + "...") @@ -2319,7 +2319,7 @@ def render_buy_result(entry, created_tickets, remaining, cart_count) -> str: remaining_html = sx_call("events-buy-result-remaining", text=f"{remaining} ticket{r_suffix} remaining") - my_href = url_for("tickets.defpage_my_tickets") + my_href = url_for("defpage_my_tickets") return cart_html + sx_call("events-buy-result", entry_id=str(entry.id), @@ -2411,7 +2411,7 @@ def _ticket_adjust_controls(csrf, adjust_url, target, entry_id, count, *, ticket return _adj_form(1, sx_call("events-adjust-cart-plus"), extra_cls="flex items-center") - my_tickets_href = url_for("tickets.defpage_my_tickets") + my_tickets_href = url_for("defpage_my_tickets") minus = _adj_form(count - 1, sx_call("events-adjust-minus")) cart_icon = sx_call("events-adjust-cart-icon", href=my_tickets_href, count=str(count)) diff --git a/events/sxc/pages/__init__.py b/events/sxc/pages/__init__.py index b36d87e..87ecc43 100644 --- a/events/sxc/pages/__init__.py +++ b/events/sxc/pages/__init__.py @@ -311,6 +311,183 @@ def _markets_oob(ctx: dict, **kw: Any) -> str: return oobs +# --------------------------------------------------------------------------- +# Shared hydration helpers +# --------------------------------------------------------------------------- + +def _add_to_defpage_ctx(**kwargs: Any) -> None: + """Add data to g._defpage_ctx for the app-level context_processor.""" + from quart import g + if not hasattr(g, '_defpage_ctx'): + g._defpage_ctx = {} + g._defpage_ctx.update(kwargs) + + +async def _ensure_calendar(calendar_slug: str | None) -> None: + """Load calendar into g.calendar if not already present.""" + from quart import g, abort + if hasattr(g, 'calendar'): + _add_to_defpage_ctx(calendar=g.calendar) + return + from bp.calendar.services.calendar_view import ( + get_calendar_by_post_and_slug, get_calendar_by_slug, + ) + post_data = getattr(g, "post_data", None) + if post_data: + post_id = (post_data.get("post") or {}).get("id") + cal = await get_calendar_by_post_and_slug(g.s, post_id, calendar_slug) + else: + cal = await get_calendar_by_slug(g.s, calendar_slug) + if not cal: + abort(404) + g.calendar = cal + g.calendar_slug = calendar_slug + _add_to_defpage_ctx(calendar=cal) + + +async def _ensure_entry(entry_id: int | None) -> None: + """Load calendar entry into g.entry if not already present.""" + from quart import g, abort + if hasattr(g, 'entry'): + _add_to_defpage_ctx(entry=g.entry) + return + from sqlalchemy import select + from models.calendars import CalendarEntry + result = await g.s.execute( + select(CalendarEntry).where( + CalendarEntry.id == entry_id, + CalendarEntry.deleted_at.is_(None), + ) + ) + entry = result.scalar_one_or_none() + if entry is None: + abort(404) + g.entry = entry + _add_to_defpage_ctx(entry=entry) + + +async def _ensure_entry_context(entry_id: int | None) -> None: + """Load full entry context (ticket data, posts) into g.* and _defpage_ctx.""" + from quart import g + from sqlalchemy import select + from sqlalchemy.orm import selectinload + from models.calendars import CalendarEntry + from bp.tickets.services.tickets import ( + get_available_ticket_count, + get_sold_ticket_count, + get_user_reserved_count, + ) + from shared.infrastructure.cart_identity import current_cart_identity + from bp.calendar_entry.services.post_associations import get_entry_posts + + await _ensure_entry(entry_id) + + # Reload with ticket_types eagerly loaded + stmt = ( + select(CalendarEntry) + .where(CalendarEntry.id == entry_id, CalendarEntry.deleted_at.is_(None)) + .options(selectinload(CalendarEntry.ticket_types)) + ) + result = await g.s.execute(stmt) + calendar_entry = result.scalar_one_or_none() + + if calendar_entry and getattr(g, "calendar", None): + if calendar_entry.calendar_id != g.calendar.id: + calendar_entry = None + + if calendar_entry: + await g.s.refresh(calendar_entry, ['slot']) + g.entry = calendar_entry + entry_posts = await get_entry_posts(g.s, calendar_entry.id) + ticket_remaining = await get_available_ticket_count(g.s, calendar_entry.id) + ticket_sold_count = await get_sold_ticket_count(g.s, calendar_entry.id) + ident = current_cart_identity() + user_ticket_count = await get_user_reserved_count( + g.s, calendar_entry.id, + user_id=ident["user_id"], + session_id=ident["session_id"], + ) + user_ticket_counts_by_type = {} + if calendar_entry.ticket_types: + for tt in calendar_entry.ticket_types: + if tt.deleted_at is None: + user_ticket_counts_by_type[tt.id] = await get_user_reserved_count( + g.s, calendar_entry.id, + user_id=ident["user_id"], + session_id=ident["session_id"], + ticket_type_id=tt.id, + ) + _add_to_defpage_ctx( + entry=calendar_entry, + entry_posts=entry_posts, + ticket_remaining=ticket_remaining, + ticket_sold_count=ticket_sold_count, + user_ticket_count=user_ticket_count, + user_ticket_counts_by_type=user_ticket_counts_by_type, + ) + + +async def _ensure_day_data(year: int, month: int, day: int) -> None: + """Load day-specific data for layout header functions.""" + from quart import g, session as qsession + if hasattr(g, 'day_date'): + return + from datetime import date as date_cls, datetime, timezone, timedelta + from sqlalchemy import select + from bp.calendar.services import get_visible_entries_for_period + from models.calendars import CalendarSlot + + calendar = getattr(g, "calendar", None) + if not calendar: + return + + try: + day_date = date_cls(year, month, day) + except (ValueError, TypeError): + return + + period_start = datetime(year, month, day, tzinfo=timezone.utc) + period_end = period_start + timedelta(days=1) + + user = getattr(g, "user", None) + session_id = qsession.get("calendar_sid") + + visible = await get_visible_entries_for_period( + sess=g.s, + calendar_id=calendar.id, + period_start=period_start, + period_end=period_end, + user=user, + session_id=session_id, + ) + + weekday_attr = ["mon", "tue", "wed", "thu", "fri", "sat", "sun"][day_date.weekday()] + stmt = ( + select(CalendarSlot) + .where( + CalendarSlot.calendar_id == calendar.id, + getattr(CalendarSlot, weekday_attr) == True, # noqa: E712 + CalendarSlot.deleted_at.is_(None), + ) + .order_by(CalendarSlot.time_start.asc(), CalendarSlot.id.asc()) + ) + result = await g.s.execute(stmt) + day_slots = list(result.scalars()) + + g.day_date = day_date + _add_to_defpage_ctx( + qsession=qsession, + day_date=day_date, + day=day, + year=year, + month=month, + day_entries=visible.merged_entries, + user_entries=visible.user_entries, + confirmed_entries=visible.confirmed_entries, + day_slots=day_slots, + ) + + # --------------------------------------------------------------------------- # Page helpers # --------------------------------------------------------------------------- @@ -336,39 +513,72 @@ def _register_events_helpers() -> None: }) -def _h_calendar_admin_content(): +async def _h_calendar_admin_content(calendar_slug=None, **kw): + await _ensure_calendar(calendar_slug) + from shared.sx.page import get_template_context + from sx.sx_components import _calendar_admin_main_panel_html + ctx = await get_template_context() + return _calendar_admin_main_panel_html(ctx) + + +async def _h_day_admin_content(calendar_slug=None, year=None, month=None, day=None, **kw): + await _ensure_calendar(calendar_slug) + if year is not None: + await _ensure_day_data(int(year), int(month), int(day)) + from sx.sx_components import _day_admin_main_panel_html + return _day_admin_main_panel_html({}) + + +async def _h_slots_content(calendar_slug=None, **kw): from quart import g - return getattr(g, "calendar_admin_content", "") + await _ensure_calendar(calendar_slug) + calendar = getattr(g, "calendar", None) + from bp.slots.services.slots import list_slots as svc_list_slots + slots = await svc_list_slots(g.s, calendar.id) if calendar else [] + _add_to_defpage_ctx(slots=slots) + from sx.sx_components import render_slots_table + return render_slots_table(slots, calendar) -def _h_day_admin_content(): - from quart import g - return getattr(g, "day_admin_content", "") +async def _h_slot_content(calendar_slug=None, slot_id=None, **kw): + from quart import g, abort + await _ensure_calendar(calendar_slug) + from bp.slot.services.slot import get_slot as svc_get_slot + slot = await svc_get_slot(g.s, slot_id) if slot_id else None + if not slot: + abort(404) + g.slot = slot + _add_to_defpage_ctx(slot=slot) + calendar = getattr(g, "calendar", None) + from sx.sx_components import render_slot_main_panel + return render_slot_main_panel(slot, calendar) -def _h_slots_content(): - from quart import g - return getattr(g, "slots_content", "") +async def _h_entry_content(calendar_slug=None, entry_id=None, **kw): + await _ensure_calendar(calendar_slug) + await _ensure_entry_context(entry_id) + from shared.sx.page import get_template_context + from sx.sx_components import _entry_main_panel_html + ctx = await get_template_context() + return _entry_main_panel_html(ctx) -def _h_slot_content(): - from quart import g - return getattr(g, "slot_content", "") +async def _h_entry_menu(calendar_slug=None, entry_id=None, **kw): + await _ensure_calendar(calendar_slug) + await _ensure_entry_context(entry_id) + from shared.sx.page import get_template_context + from sx.sx_components import _entry_nav_html + ctx = await get_template_context() + return _entry_nav_html(ctx) -def _h_entry_content(): - from quart import g - return getattr(g, "entry_content", "") - - -def _h_entry_menu(): - from quart import g - return getattr(g, "entry_menu", "") - - -def _h_entry_admin_content(): - from quart import g - return getattr(g, "entry_admin_content", "") +async def _h_entry_admin_content(calendar_slug=None, entry_id=None, **kw): + await _ensure_calendar(calendar_slug) + await _ensure_entry_context(entry_id) + from shared.sx.page import get_template_context + from sx.sx_components import _entry_admin_main_panel_html + ctx = await get_template_context() + return _entry_admin_main_panel_html(ctx) def _h_admin_menu(): @@ -376,31 +586,118 @@ def _h_admin_menu(): return sx_call("events-admin-placeholder-nav") -def _h_ticket_types_content(): +async def _h_ticket_types_content(calendar_slug=None, entry_id=None, + year=None, month=None, day=None, **kw): from quart import g - return getattr(g, "ticket_types_content", "") + await _ensure_calendar(calendar_slug) + await _ensure_entry(entry_id) + entry = getattr(g, "entry", None) + calendar = getattr(g, "calendar", None) + from bp.ticket_types.services.tickets import list_ticket_types as svc_list_ticket_types + ticket_types = await svc_list_ticket_types(g.s, entry.id) if entry else [] + _add_to_defpage_ctx(ticket_types=ticket_types) + from sx.sx_components import render_ticket_types_table + return render_ticket_types_table(ticket_types, entry, calendar, day, month, year) -def _h_ticket_type_content(): +async def _h_ticket_type_content(calendar_slug=None, entry_id=None, + ticket_type_id=None, year=None, month=None, day=None, **kw): + from quart import g, abort + await _ensure_calendar(calendar_slug) + await _ensure_entry(entry_id) + from bp.ticket_type.services.ticket import get_ticket_type as svc_get_ticket_type + ticket_type = await svc_get_ticket_type(g.s, ticket_type_id) if ticket_type_id else None + if not ticket_type: + abort(404) + g.ticket_type = ticket_type + _add_to_defpage_ctx(ticket_type=ticket_type) + entry = getattr(g, "entry", None) + calendar = getattr(g, "calendar", None) + from sx.sx_components import render_ticket_type_main_panel + return render_ticket_type_main_panel(ticket_type, entry, calendar, day, month, year) + + +async def _h_tickets_content(**kw): from quart import g - return getattr(g, "ticket_type_content", "") + from shared.infrastructure.cart_identity import current_cart_identity + from bp.tickets.services.tickets import get_user_tickets + ident = current_cart_identity() + tickets = await get_user_tickets( + g.s, + user_id=ident["user_id"], + session_id=ident["session_id"], + ) + from shared.sx.page import get_template_context + from sx.sx_components import _tickets_main_panel_html + ctx = await get_template_context() + return _tickets_main_panel_html(ctx, tickets) -def _h_tickets_content(): +async def _h_ticket_detail_content(code=None, **kw): + from quart import g, abort + from shared.infrastructure.cart_identity import current_cart_identity + from bp.tickets.services.tickets import get_ticket_by_code + ticket = await get_ticket_by_code(g.s, code) if code else None + if not ticket: + abort(404) + # Verify ownership + ident = current_cart_identity() + if ident["user_id"] is not None: + if ticket.user_id != ident["user_id"]: + abort(404) + elif ident["session_id"] is not None: + if ticket.session_id != ident["session_id"]: + abort(404) + else: + abort(404) + from shared.sx.page import get_template_context + from sx.sx_components import _ticket_detail_panel_html + ctx = await get_template_context() + return _ticket_detail_panel_html(ctx, ticket) + + +async def _h_ticket_admin_content(**kw): from quart import g - return getattr(g, "tickets_content", "") + from sqlalchemy import select, func + from sqlalchemy.orm import selectinload + from models.calendars import CalendarEntry, Ticket + + result = await g.s.execute( + select(Ticket) + .options( + selectinload(Ticket.entry).selectinload(CalendarEntry.calendar), + selectinload(Ticket.ticket_type), + ) + .order_by(Ticket.created_at.desc()) + .limit(50) + ) + tickets = result.scalars().all() + + total = await g.s.scalar(select(func.count(Ticket.id))) + confirmed = await g.s.scalar( + select(func.count(Ticket.id)).where(Ticket.state == "confirmed") + ) + checked_in = await g.s.scalar( + select(func.count(Ticket.id)).where(Ticket.state == "checked_in") + ) + reserved = await g.s.scalar( + select(func.count(Ticket.id)).where(Ticket.state == "reserved") + ) + stats = { + "total": total or 0, + "confirmed": confirmed or 0, + "checked_in": checked_in or 0, + "reserved": reserved or 0, + } + + from shared.sx.page import get_template_context + from sx.sx_components import _ticket_admin_main_panel_html + ctx = await get_template_context() + return _ticket_admin_main_panel_html(ctx, tickets, stats) -def _h_ticket_detail_content(): - from quart import g - return getattr(g, "ticket_detail_content", "") - - -def _h_ticket_admin_content(): - from quart import g - return getattr(g, "ticket_admin_content", "") - - -def _h_markets_content(): - from quart import g - return getattr(g, "markets_content", "") +async def _h_markets_content(**kw): + from shared.sx.page import get_template_context + from sx.sx_components import _markets_main_panel_html + ctx = await get_template_context() + return _markets_main_panel_html(ctx) diff --git a/events/sxc/pages/events.sx b/events/sxc/pages/events.sx index d47ecb0..6ad48b0 100644 --- a/events/sxc/pages/events.sx +++ b/events/sxc/pages/events.sx @@ -1,89 +1,89 @@ -;; Events pages — mounted on various nested blueprints +;; Events pages — auto-mounted with absolute paths -;; Calendar admin (mounted on calendar.admin bp) +;; Calendar admin (defpage calendar-admin - :path "/" + :path "///admin/" :auth :admin :layout :events-calendar-admin - :content (calendar-admin-content)) + :content (calendar-admin-content calendar-slug)) -;; Day admin (mounted on day.admin bp) +;; Day admin (defpage day-admin - :path "/" + :path "///day////admin/" :auth :admin :layout :events-day-admin - :content (day-admin-content)) + :content (day-admin-content calendar-slug year month day)) -;; Slots listing (mounted on slots bp) +;; Slots listing (defpage slots-listing - :path "/" + :path "///slots/" :auth :public :layout :events-slots - :content (slots-content)) + :content (slots-content calendar-slug)) -;; Slot detail (mounted on slot bp) +;; Slot detail (defpage slot-detail - :path "/" + :path "///slots//" :auth :admin :layout :events-slot - :content (slot-content)) + :content (slot-content calendar-slug slot-id)) -;; Entry detail (mounted on calendar_entry bp) +;; Entry detail (defpage entry-detail - :path "/" + :path "///day////entries//" :auth :admin :layout :events-entry - :content (entry-content) - :menu (entry-menu)) + :content (entry-content calendar-slug entry-id) + :menu (entry-menu calendar-slug entry-id)) -;; Entry admin (mounted on calendar_entry.admin bp) +;; Entry admin (defpage entry-admin - :path "/" + :path "///day////entries//admin/" :auth :admin :layout :events-entry-admin - :content (entry-admin-content) + :content (entry-admin-content calendar-slug entry-id) :menu (admin-menu)) -;; Ticket types listing (mounted on ticket_types bp) +;; Ticket types listing (defpage ticket-types-listing - :path "/" + :path "///day////entries//ticket-types/" :auth :public :layout :events-ticket-types - :content (ticket-types-content) + :content (ticket-types-content calendar-slug entry-id year month day) :menu (admin-menu)) -;; Ticket type detail (mounted on ticket_type bp) +;; Ticket type detail (defpage ticket-type-detail - :path "/" + :path "///day////entries//ticket-types//" :auth :admin :layout :events-ticket-type - :content (ticket-type-content) + :content (ticket-type-content calendar-slug entry-id ticket-type-id year month day) :menu (admin-menu)) -;; My tickets (mounted on tickets bp) +;; My tickets (defpage my-tickets - :path "/" + :path "/tickets/" :auth :public :layout :root :content (tickets-content)) -;; Ticket detail (mounted on tickets bp) +;; Ticket detail (defpage ticket-detail - :path "//" + :path "/tickets//" :auth :public :layout :root - :content (ticket-detail-content)) + :content (ticket-detail-content code)) -;; Ticket admin dashboard (mounted on ticket_admin bp) +;; Ticket admin dashboard (defpage ticket-admin - :path "/" + :path "/admin/tickets/" :auth :admin :layout :root :content (ticket-admin-content)) -;; Markets (mounted on markets bp) +;; Markets (defpage events-markets - :path "/" + :path "//markets/" :auth :public :layout :events-markets :content (markets-content)) diff --git a/federation/app.py b/federation/app.py index 5a40971..60cd5a2 100644 --- a/federation/app.py +++ b/federation/app.py @@ -94,10 +94,11 @@ def create_app() -> "Quart": app.register_blueprint(register_identity_bp()) social_bp = register_social_bp() - from shared.sx.pages import mount_pages - mount_pages(social_bp, "federation") app.register_blueprint(social_bp) + from shared.sx.pages import auto_mount_pages + auto_mount_pages(app, "federation") + app.register_blueprint(register_fragments()) # --- home page --- diff --git a/federation/bp/social/routes.py b/federation/bp/social/routes.py index 3a7fe4b..2450eb8 100644 --- a/federation/bp/social/routes.py +++ b/federation/bp/social/routes.py @@ -32,102 +32,6 @@ def register(url_prefix="/social"): actor = await services.federation.get_actor_by_user_id(g.s, g.user.id) g._social_actor = actor - @bp.before_request - async def _prepare_page_data(): - """Pre-render content for defpage routes.""" - endpoint = request.endpoint or "" - - if endpoint.endswith("defpage_home_timeline"): - actor = _require_actor() - items = await services.federation.get_home_timeline(g.s, actor.id) - from sx.sx_components import _timeline_content_sx - g.home_timeline_content = _timeline_content_sx(items, "home", actor) - - elif endpoint.endswith("defpage_public_timeline"): - actor = getattr(g, "_social_actor", None) - items = await services.federation.get_public_timeline(g.s) - from sx.sx_components import _timeline_content_sx - g.public_timeline_content = _timeline_content_sx(items, "public", actor) - - elif endpoint.endswith("defpage_compose_form"): - actor = _require_actor() - from sx.sx_components import _compose_content_sx - reply_to = request.args.get("reply_to") - g.compose_content = _compose_content_sx(actor, reply_to) - - elif endpoint.endswith("defpage_search"): - actor = getattr(g, "_social_actor", None) - query = request.args.get("q", "").strip() - actors_list = [] - total = 0 - followed_urls: set[str] = set() - if query: - actors_list, total = await services.federation.search_actors(g.s, query) - if actor: - following, _ = await services.federation.get_following( - g.s, actor.preferred_username, page=1, per_page=1000, - ) - followed_urls = {a.actor_url for a in following} - from sx.sx_components import _search_content_sx - g.search_content = _search_content_sx(query, actors_list, total, 1, followed_urls, actor) - - elif endpoint.endswith("defpage_following_list"): - actor = _require_actor() - actors_list, total = await services.federation.get_following( - g.s, actor.preferred_username, - ) - from sx.sx_components import _following_content_sx - g.following_content = _following_content_sx(actors_list, total, actor) - - elif endpoint.endswith("defpage_followers_list"): - actor = _require_actor() - actors_list, total = await services.federation.get_followers_paginated( - g.s, actor.preferred_username, - ) - following, _ = await services.federation.get_following( - g.s, actor.preferred_username, page=1, per_page=1000, - ) - followed_urls = {a.actor_url for a in following} - from sx.sx_components import _followers_content_sx - g.followers_content = _followers_content_sx(actors_list, total, followed_urls, actor) - - elif endpoint.endswith("defpage_actor_timeline"): - actor = getattr(g, "_social_actor", None) - actor_id = request.view_args.get("id") - from shared.models.federation import RemoteActor - from sqlalchemy import select as sa_select - remote = ( - await g.s.execute( - sa_select(RemoteActor).where(RemoteActor.id == actor_id) - ) - ).scalar_one_or_none() - if not remote: - abort(404) - from shared.services.federation_impl import _remote_actor_to_dto - remote_dto = _remote_actor_to_dto(remote) - items = await services.federation.get_actor_timeline(g.s, actor_id) - is_following = False - if actor: - from shared.models.federation import APFollowing - existing = ( - await g.s.execute( - sa_select(APFollowing).where( - APFollowing.actor_profile_id == actor.id, - APFollowing.remote_actor_id == actor_id, - ) - ) - ).scalar_one_or_none() - is_following = existing is not None - from sx.sx_components import _actor_timeline_content_sx - g.actor_timeline_content = _actor_timeline_content_sx(remote_dto, items, is_following, actor) - - elif endpoint.endswith("defpage_notifications"): - actor = _require_actor() - items = await services.federation.get_notifications(g.s, actor.id) - await services.federation.mark_notifications_read(g.s, actor.id) - from sx.sx_components import _notifications_content_sx - g.notifications_content = _notifications_content_sx(items) - # -- Timeline pagination --------------------------------------------------- @bp.get("/timeline") @@ -170,7 +74,7 @@ def register(url_prefix="/social"): form = await request.form content = form.get("content", "").strip() if not content: - return redirect(url_for("social.defpage_compose_form")) + return redirect(url_for("defpage_compose_form")) visibility = form.get("visibility", "public") in_reply_to = form.get("in_reply_to") or None @@ -181,13 +85,13 @@ def register(url_prefix="/social"): visibility=visibility, in_reply_to=in_reply_to, ) - return redirect(url_for("social.defpage_home_timeline")) + return redirect(url_for("defpage_home_timeline")) @bp.post("/delete/") async def delete_post(post_id: int): actor = _require_actor() await services.federation.delete_local_post(g.s, actor.id, post_id) - return redirect(url_for("social.defpage_home_timeline")) + return redirect(url_for("defpage_home_timeline")) # -- Search + Follow ------------------------------------------------------- @@ -223,7 +127,7 @@ def register(url_prefix="/social"): ) if request.headers.get("SX-Request") or request.headers.get("HX-Request"): return await _actor_card_response(actor, remote_actor_url, is_followed=True) - return redirect(request.referrer or url_for("social.defpage_search")) + return redirect(request.referrer or url_for("defpage_search")) @bp.post("/unfollow") async def unfollow(): @@ -236,7 +140,7 @@ def register(url_prefix="/social"): ) if request.headers.get("SX-Request") or request.headers.get("HX-Request"): return await _actor_card_response(actor, remote_actor_url, is_followed=False) - return redirect(request.referrer or url_for("social.defpage_search")) + return redirect(request.referrer or url_for("defpage_search")) async def _actor_card_response(actor, remote_actor_url, is_followed): """Re-render a single actor card after follow/unfollow via HTMX.""" @@ -414,6 +318,6 @@ def register(url_prefix="/social"): async def mark_read(): actor = _require_actor() await services.federation.mark_notifications_read(g.s, actor.id) - return redirect(url_for("social.defpage_notifications")) + return redirect(url_for("defpage_notifications")) return bp diff --git a/federation/sxc/pages/__init__.py b/federation/sxc/pages/__init__.py index be22680..4d30f0c 100644 --- a/federation/sxc/pages/__init__.py +++ b/federation/sxc/pages/__init__.py @@ -69,41 +69,130 @@ def _register_federation_helpers() -> None: }) -def _h_home_timeline_content(): +def _get_actor(): + """Return current user's actor or None.""" from quart import g - return getattr(g, "home_timeline_content", "") + return getattr(g, "_social_actor", None) -def _h_public_timeline_content(): +def _require_actor(): + """Return current user's actor or abort 403.""" + from quart import abort + actor = _get_actor() + if not actor: + abort(403, "You need to choose a federation username first") + return actor + + +async def _h_home_timeline_content(**kw): from quart import g - return getattr(g, "public_timeline_content", "") + from shared.services.registry import services + actor = _require_actor() + items = await services.federation.get_home_timeline(g.s, actor.id) + from sx.sx_components import _timeline_content_sx + return _timeline_content_sx(items, "home", actor) -def _h_compose_content(): +async def _h_public_timeline_content(**kw): from quart import g - return getattr(g, "compose_content", "") + from shared.services.registry import services + actor = _get_actor() + items = await services.federation.get_public_timeline(g.s) + from sx.sx_components import _timeline_content_sx + return _timeline_content_sx(items, "public", actor) -def _h_search_content(): +async def _h_compose_content(**kw): + from quart import request + actor = _require_actor() + from sx.sx_components import _compose_content_sx + reply_to = request.args.get("reply_to") + return _compose_content_sx(actor, reply_to) + + +async def _h_search_content(**kw): + from quart import g, request + from shared.services.registry import services + actor = _get_actor() + query = request.args.get("q", "").strip() + actors_list = [] + total = 0 + followed_urls: set[str] = set() + if query: + actors_list, total = await services.federation.search_actors(g.s, query) + if actor: + following, _ = await services.federation.get_following( + g.s, actor.preferred_username, page=1, per_page=1000, + ) + followed_urls = {a.actor_url for a in following} + from sx.sx_components import _search_content_sx + return _search_content_sx(query, actors_list, total, 1, followed_urls, actor) + + +async def _h_following_content(**kw): from quart import g - return getattr(g, "search_content", "") + from shared.services.registry import services + actor = _require_actor() + actors_list, total = await services.federation.get_following( + g.s, actor.preferred_username, + ) + from sx.sx_components import _following_content_sx + return _following_content_sx(actors_list, total, actor) -def _h_following_content(): +async def _h_followers_content(**kw): from quart import g - return getattr(g, "following_content", "") + from shared.services.registry import services + actor = _require_actor() + actors_list, total = await services.federation.get_followers_paginated( + g.s, actor.preferred_username, + ) + following, _ = await services.federation.get_following( + g.s, actor.preferred_username, page=1, per_page=1000, + ) + followed_urls = {a.actor_url for a in following} + from sx.sx_components import _followers_content_sx + return _followers_content_sx(actors_list, total, followed_urls, actor) -def _h_followers_content(): +async def _h_actor_timeline_content(id=None, **kw): + from quart import g, abort + from shared.services.registry import services + actor = _get_actor() + actor_id = id + from shared.models.federation import RemoteActor + from sqlalchemy import select as sa_select + remote = ( + await g.s.execute( + sa_select(RemoteActor).where(RemoteActor.id == actor_id) + ) + ).scalar_one_or_none() + if not remote: + abort(404) + from shared.services.federation_impl import _remote_actor_to_dto + remote_dto = _remote_actor_to_dto(remote) + items = await services.federation.get_actor_timeline(g.s, actor_id) + is_following = False + if actor: + from shared.models.federation import APFollowing + existing = ( + await g.s.execute( + sa_select(APFollowing).where( + APFollowing.actor_profile_id == actor.id, + APFollowing.remote_actor_id == actor_id, + ) + ) + ).scalar_one_or_none() + is_following = existing is not None + from sx.sx_components import _actor_timeline_content_sx + return _actor_timeline_content_sx(remote_dto, items, is_following, actor) + + +async def _h_notifications_content(**kw): from quart import g - return getattr(g, "followers_content", "") - - -def _h_actor_timeline_content(): - from quart import g - return getattr(g, "actor_timeline_content", "") - - -def _h_notifications_content(): - from quart import g - return getattr(g, "notifications_content", "") + from shared.services.registry import services + actor = _require_actor() + items = await services.federation.get_notifications(g.s, actor.id) + await services.federation.mark_notifications_read(g.s, actor.id) + from sx.sx_components import _notifications_content_sx + return _notifications_content_sx(items) diff --git a/federation/sxc/pages/social.sx b/federation/sxc/pages/social.sx index cb1a557..fafed16 100644 --- a/federation/sxc/pages/social.sx +++ b/federation/sxc/pages/social.sx @@ -1,49 +1,49 @@ ;; Federation social pages (defpage home-timeline - :path "/" + :path "/social/" :auth :login :layout :social :content (home-timeline-content)) (defpage public-timeline - :path "/public" + :path "/social/public" :auth :public :layout :social :content (public-timeline-content)) (defpage compose-form - :path "/compose" + :path "/social/compose" :auth :login :layout :social :content (compose-content)) (defpage search - :path "/search" + :path "/social/search" :auth :public :layout :social :content (search-content)) (defpage following-list - :path "/following" + :path "/social/following" :auth :login :layout :social :content (following-content)) (defpage followers-list - :path "/followers" + :path "/social/followers" :auth :login :layout :social :content (followers-content)) (defpage actor-timeline - :path "/actor/" + :path "/social/actor/" :auth :public :layout :social - :content (actor-timeline-content)) + :content (actor-timeline-content id)) (defpage notifications - :path "/notifications" + :path "/social/notifications" :auth :login :layout :social :content (notifications-content)) diff --git a/market/app.py b/market/app.py index 23f5d5f..a65ccac 100644 --- a/market/app.py +++ b/market/app.py @@ -103,21 +103,16 @@ def create_app() -> "Quart": from sxc.pages import setup_market_pages setup_market_pages() - from shared.sx.pages import mount_pages - # All markets: / — global view across all pages all_markets_bp = register_all_markets() - mount_pages(all_markets_bp, "market", names=["all-markets-index"]) app.register_blueprint(all_markets_bp, url_prefix="/") # Page markets: // — markets for a single page page_markets_bp = register_page_markets() - mount_pages(page_markets_bp, "market", names=["page-markets-index"]) app.register_blueprint(page_markets_bp, url_prefix="/") # Page admin: //admin/ — post-level admin for markets page_admin_bp = register_page_admin() - mount_pages(page_admin_bp, "market", names=["page-admin"]) app.register_blueprint(page_admin_bp, url_prefix="//admin") # Market blueprint nested under post slug: /// @@ -135,6 +130,10 @@ def create_app() -> "Quart": app.register_blueprint(register_actions()) app.register_blueprint(register_data()) + # Auto-mount all defpages with absolute paths + from shared.sx.pages import auto_mount_pages + auto_mount_pages(app, "market") + # --- Auto-inject slugs into url_for() calls --- @app.url_value_preprocessor def pull_slugs(endpoint, values): diff --git a/market/bp/all_markets/routes.py b/market/bp/all_markets/routes.py index e7c2716..f6bc8c3 100644 --- a/market/bp/all_markets/routes.py +++ b/market/bp/all_markets/routes.py @@ -41,19 +41,6 @@ async def _load_markets(page, per_page=20): def register() -> Blueprint: bp = Blueprint("all_markets", __name__) - @bp.before_request - async def _prepare_page_data(): - """Load all-markets data for defpage routes.""" - endpoint = request.endpoint or "" - if not endpoint.endswith("defpage_all_markets_index"): - return - page = int(request.args.get("page", 1)) - markets, has_more, page_info = await _load_markets(page) - g.all_markets_data = { - "markets": markets, "has_more": has_more, - "page_info": page_info, "page": page, - } - @bp.get("/all-markets") async def markets_fragment(): page = int(request.args.get("page", 1)) diff --git a/market/bp/browse/routes.py b/market/bp/browse/routes.py index 0629b5f..f6fe2bf 100644 --- a/market/bp/browse/routes.py +++ b/market/bp/browse/routes.py @@ -29,10 +29,6 @@ def register(): register_product(), ) - # Mount defpage for market home (GET /) - from shared.sx.pages import mount_pages - mount_pages(browse_bp, "market", names=["market-home"]) - @browse_bp.get("/all/") @cache_page(tag="browse") async def browse_all(): diff --git a/market/bp/market/admin/routes.py b/market/bp/market/admin/routes.py index 6172ca2..6963b91 100644 --- a/market/bp/market/admin/routes.py +++ b/market/bp/market/admin/routes.py @@ -5,9 +5,4 @@ from quart import Blueprint def register(): bp = Blueprint("admin", __name__, url_prefix='/admin') - - # Mount defpage for market admin (GET /) - from shared.sx.pages import mount_pages - mount_pages(bp, "market", names=["market-admin"]) - return bp diff --git a/market/bp/market/routes.py b/market/bp/market/routes.py index 2eefecc..0cc5981 100644 --- a/market/bp/market/routes.py +++ b/market/bp/market/routes.py @@ -1,6 +1,6 @@ from __future__ import annotations -from quart import Blueprint, g, render_template, make_response, url_for +from quart import Blueprint, g, make_response, url_for from ..browse.routes import register as register_browse_bp diff --git a/market/bp/page_admin/routes.py b/market/bp/page_admin/routes.py index 59f8507..93873ad 100644 --- a/market/bp/page_admin/routes.py +++ b/market/bp/page_admin/routes.py @@ -26,17 +26,6 @@ def _slugify(value: str, max_len: int = 255) -> str: def register(): bp = Blueprint("page_admin", __name__) - @bp.before_request - async def _prepare_page_data(): - """Pre-render page admin content for defpage (async helper).""" - endpoint = request.endpoint or "" - if request.method != "GET" or not endpoint.endswith("defpage_page_admin"): - return - from shared.sx.page import get_template_context - from sx.sx_components import _markets_admin_panel_sx - ctx = await get_template_context() - g.page_admin_content = await _markets_admin_panel_sx(ctx) - @bp.post("/new/") @require_admin async def create_market(**kwargs): diff --git a/market/bp/page_markets/routes.py b/market/bp/page_markets/routes.py index aada33f..10d1f2f 100644 --- a/market/bp/page_markets/routes.py +++ b/market/bp/page_markets/routes.py @@ -23,20 +23,6 @@ async def _load_markets(post_id, page, per_page=20): def register() -> Blueprint: bp = Blueprint("page_markets", __name__) - @bp.before_request - async def _prepare_page_data(): - """Load page-markets data for defpage routes.""" - endpoint = request.endpoint or "" - if not endpoint.endswith("defpage_page_markets_index"): - return - post = g.post_data["post"] - page = int(request.args.get("page", 1)) - markets, has_more = await _load_markets(post["id"], page) - g.page_markets_data = { - "markets": markets, "has_more": has_more, - "page": page, "post_slug": post.get("slug", ""), - } - @bp.get("/page-markets") async def markets_fragment(): post = g.post_data["post"] diff --git a/market/sxc/pages/__init__.py b/market/sxc/pages/__init__.py index 03c1c70..5f96bdf 100644 --- a/market/sxc/pages/__init__.py +++ b/market/sxc/pages/__init__.py @@ -98,67 +98,77 @@ def _register_market_helpers() -> None: }) -def _h_all_markets_content(): +async def _h_all_markets_content(**kw): from quart import g, url_for, request from shared.utils import route_prefix + from shared.services.registry import services + from shared.infrastructure.data_client import fetch_data + from shared.contracts.dtos import PostDTO, dto_from_dict - data = getattr(g, "all_markets_data", None) - if not data: + page = int(request.args.get("page", 1)) + markets, has_more = await services.market.list_marketplaces( + g.s, page=page, per_page=20, + ) + + page_info = {} + if markets: + post_ids = list({m.container_id for m in markets if m.container_type == "page"}) + if post_ids: + raw_posts = await fetch_data("blog", "posts-by-ids", + params={"ids": ",".join(str(i) for i in post_ids)}, + required=False) or [] + for raw_p in raw_posts: + p = dto_from_dict(PostDTO, raw_p) + page_info[p.id] = {"title": p.title, "slug": p.slug} + + if not markets: from sx.sx_components import _no_markets_sx return _no_markets_sx() - markets = data["markets"] - has_more = data["has_more"] - page_info = data["page_info"] - page = data["page"] - prefix = route_prefix() next_url = prefix + url_for("all_markets.markets_fragment", page=page + 1) - from sx.sx_components import _market_cards_sx, _markets_grid, _no_markets_sx - if markets: - cards = _market_cards_sx(markets, page_info, page, has_more, next_url) - content = _markets_grid(cards) - else: - content = _no_markets_sx() + from sx.sx_components import _market_cards_sx, _markets_grid + cards = _market_cards_sx(markets, page_info, page, has_more, next_url) + content = _markets_grid(cards) return "(<> " + content + " " + '(div :class "pb-8")' + ")" -def _h_page_markets_content(): - from quart import g, url_for +async def _h_page_markets_content(slug=None, **kw): + from quart import g, url_for, request from shared.utils import route_prefix + from shared.services.registry import services - data = getattr(g, "page_markets_data", None) - if not data: + post = g.post_data["post"] + page = int(request.args.get("page", 1)) + markets, has_more = await services.market.list_marketplaces( + g.s, "page", post["id"], page=page, per_page=20, + ) + post_slug = post.get("slug", "") + + if not markets: from sx.sx_components import _no_markets_sx return _no_markets_sx("No markets for this page") - markets = data["markets"] - has_more = data["has_more"] - page = data["page"] - post_slug = data.get("post_slug", "") - prefix = route_prefix() next_url = prefix + url_for("page_markets.markets_fragment", page=page + 1) - from sx.sx_components import _market_cards_sx, _markets_grid, _no_markets_sx - if markets: - cards = _market_cards_sx(markets, {}, page, has_more, next_url, - show_page_badge=False, post_slug=post_slug) - content = _markets_grid(cards) - else: - content = _no_markets_sx("No markets for this page") + from sx.sx_components import _market_cards_sx, _markets_grid + cards = _market_cards_sx(markets, {}, page, has_more, next_url, + show_page_badge=False, post_slug=post_slug) + content = _markets_grid(cards) return "(<> " + content + " " + '(div :class "pb-8")' + ")" -def _h_page_admin_content(): - # Content pre-rendered by before_request (async _markets_admin_panel_sx) - from quart import g - content = getattr(g, "page_admin_content", "") +async def _h_page_admin_content(slug=None, **kw): + from shared.sx.page import get_template_context + from sx.sx_components import _markets_admin_panel_sx + ctx = await get_template_context() + content = await _markets_admin_panel_sx(ctx) return '(div :id "main-panel" ' + content + ')' -def _h_market_home_content(): +def _h_market_home_content(page_slug=None, market_slug=None, **kw): from quart import g post_data = getattr(g, "post_data", {}) post = post_data.get("post", {}) @@ -166,5 +176,5 @@ def _h_market_home_content(): return _market_landing_content_sx(post) -def _h_market_admin_content(): +def _h_market_admin_content(page_slug=None, market_slug=None, **kw): return '"market admin"' diff --git a/market/sxc/pages/market.sx b/market/sxc/pages/market.sx index 22c5727..65070fd 100644 --- a/market/sxc/pages/market.sx +++ b/market/sxc/pages/market.sx @@ -1,10 +1,10 @@ ;; Market app defpage declarations. ;; ;; all-markets-index: / — global view across all pages -;; page-markets-index: / (on page_markets bp, mounted at /) -;; page-admin: / (on page_admin bp, mounted at //admin) -;; market-home: / (on browse bp, mounted at //) -;; market-admin: / (on admin bp, mounted at ///admin) +;; page-markets-index: // — markets for a single page +;; page-admin: //admin/ — post-level admin for markets +;; market-home: /// — market landing page +;; market-admin: ///admin/ — market admin (defpage all-markets-index :path "/" @@ -13,25 +13,25 @@ :content (all-markets-content)) (defpage page-markets-index - :path "/" + :path "//" :auth :public :layout :post :content (page-markets-content)) (defpage page-admin - :path "/" + :path "//admin/" :auth :admin :layout (:post-admin :selected "markets") :content (page-admin-content)) (defpage market-home - :path "/" + :path "///" :auth :public :layout :market :content (market-home-content)) (defpage market-admin - :path "/" + :path "///admin/" :auth :admin :layout (:market-admin :selected "markets") :content (market-admin-content)) diff --git a/orders/app.py b/orders/app.py index d293f6d..a782056 100644 --- a/orders/app.py +++ b/orders/app.py @@ -81,12 +81,13 @@ def create_app() -> "Quart": app.register_blueprint(register_actions()) app.register_blueprint(register_data()) - # Orders list at / (defpage routes mounted below) + # Orders list at / bp = register_orders(url_prefix="/") - from shared.sx.pages import mount_pages - mount_pages(bp, "orders") app.register_blueprint(bp) + from shared.sx.pages import auto_mount_pages + auto_mount_pages(app, "orders") + # Checkout webhook + return app.register_blueprint(register_checkout()) diff --git a/orders/bp/orders/routes.py b/orders/bp/orders/routes.py index 529ecfb..2967d56 100644 --- a/orders/bp/orders/routes.py +++ b/orders/bp/orders/routes.py @@ -1,6 +1,6 @@ from __future__ import annotations -from quart import Blueprint, g, redirect, url_for, make_response, request +from quart import Blueprint, g, redirect, url_for, request from sqlalchemy import select, func, or_, cast, String, exists from sqlalchemy.orm import selectinload @@ -8,7 +8,6 @@ from shared.models.order import Order, OrderItem from shared.infrastructure.http_utils import vary as _vary, current_url_without_page as _current_url_without_page from shared.infrastructure.cart_identity import current_cart_identity -from shared.browser.app.utils.htmx import is_htmx_request from bp.order.routes import register as register_order from .filters.qs import makeqs_factory, decode @@ -31,112 +30,6 @@ def register(url_prefix: str) -> Blueprint: if not ident["user_id"] and not ident["session_id"]: return redirect(url_for("auth.login_form")) - @bp.before_request - async def _prepare_page_data(): - """Load data for defpage routes into g.*.""" - if request.method != "GET": - return - - endpoint = request.endpoint or "" - - # Orders list page - if endpoint.endswith("defpage_orders_list"): - ident = current_cart_identity() - if ident["user_id"]: - owner_clause = Order.user_id == ident["user_id"] - elif ident["session_id"]: - owner_clause = Order.session_id == ident["session_id"] - else: - return - - q = decode() - page, search = q.page, q.search - if page < 1: - page = 1 - - where_clause = _search_clause(search) if search else None - - count_stmt = select(func.count()).select_from(Order).where(owner_clause) - if where_clause is not None: - count_stmt = count_stmt.where(where_clause) - - total_count_result = await g.s.execute(count_stmt) - total_count = total_count_result.scalar_one() or 0 - total_pages = max(1, (total_count + ORDERS_PER_PAGE - 1) // ORDERS_PER_PAGE) - - if page > total_pages: - page = total_pages - - offset = (page - 1) * ORDERS_PER_PAGE - stmt = ( - select(Order) - .where(owner_clause) - .order_by(Order.created_at.desc()) - .offset(offset) - .limit(ORDERS_PER_PAGE) - ) - if where_clause is not None: - stmt = stmt.where(where_clause) - - result = await g.s.execute(stmt) - orders = result.scalars().all() - - from shared.utils import route_prefix - pfx = route_prefix() - qs_fn = makeqs_factory() - - g.orders_page_data = { - "orders": orders, - "page": page, - "total_pages": total_pages, - "search": search, - "search_count": total_count, - "url_for_fn": url_for, - "qs_fn": qs_fn, - "list_url": pfx + url_for("orders.defpage_orders_list"), - } - - # Order detail page - elif endpoint.endswith("defpage_order_detail"): - order_id = request.view_args.get("order_id") - if order_id is None: - return - - ident = current_cart_identity() - if ident["user_id"]: - owner = Order.user_id == ident["user_id"] - elif ident["session_id"]: - owner = Order.session_id == ident["session_id"] - else: - from quart import abort - abort(404) - return - - result = await g.s.execute( - select(Order) - .options(selectinload(Order.items)) - .where(Order.id == order_id, owner) - ) - order = result.scalar_one_or_none() - if not order: - from quart import abort - abort(404) - return - - from shared.utils import route_prefix - from shared.browser.app.csrf import generate_csrf_token - pfx = route_prefix() - - g.order_detail_data = { - "order": order, - "calendar_entries": None, - "detail_url": pfx + url_for("orders.defpage_order_detail", order_id=order.id), - "list_url": pfx + url_for("orders.defpage_orders_list"), - "recheck_url": pfx + url_for("orders.order.order_recheck", order_id=order.id), - "pay_url": pfx + url_for("orders.order.order_pay", order_id=order.id), - "csrf_token": generate_csrf_token(), - } - @bp.get("/rows") async def orders_rows(): """Pagination endpoint — returns order rows for page > 1.""" diff --git a/orders/sxc/pages/__init__.py b/orders/sxc/pages/__init__.py index 802317d..b1ea93b 100644 --- a/orders/sxc/pages/__init__.py +++ b/orders/sxc/pages/__init__.py @@ -119,7 +119,143 @@ def _register_orders_helpers() -> None: }) -def _h_orders_list_content(): +async def _ensure_orders_list(): + """Fetch orders list data and store in g.orders_page_data.""" + from quart import g, url_for + if hasattr(g, "orders_page_data"): + return + from sqlalchemy import select, func, or_, cast, String, exists + from shared.models.order import Order, OrderItem + from shared.infrastructure.cart_identity import current_cart_identity + from shared.utils import route_prefix + + ORDERS_PER_PAGE = 10 + ident = current_cart_identity() + if ident["user_id"]: + owner_clause = Order.user_id == ident["user_id"] + elif ident["session_id"]: + owner_clause = Order.session_id == ident["session_id"] + else: + g.orders_page_data = None + return + + from bp.orders.filters.qs import makeqs_factory, decode + q = decode() + page, search = q.page, q.search + if page < 1: + page = 1 + + where_clause = None + if search: + term = f"%{search.strip()}%" + conditions = [ + Order.status.ilike(term), + Order.currency.ilike(term), + Order.sumup_checkout_id.ilike(term), + Order.sumup_status.ilike(term), + Order.description.ilike(term), + ] + conditions.append( + exists( + select(1).select_from(OrderItem) + .where(OrderItem.order_id == Order.id, + or_(OrderItem.product_title.ilike(term), + OrderItem.product_slug.ilike(term))) + ) + ) + try: + search_id = int(search) + except (TypeError, ValueError): + search_id = None + if search_id is not None: + conditions.append(Order.id == search_id) + else: + conditions.append(cast(Order.id, String).ilike(term)) + where_clause = or_(*conditions) + + count_stmt = select(func.count()).select_from(Order).where(owner_clause) + if where_clause is not None: + count_stmt = count_stmt.where(where_clause) + + total_count_result = await g.s.execute(count_stmt) + total_count = total_count_result.scalar_one() or 0 + total_pages = max(1, (total_count + ORDERS_PER_PAGE - 1) // ORDERS_PER_PAGE) + if page > total_pages: + page = total_pages + + offset = (page - 1) * ORDERS_PER_PAGE + stmt = ( + select(Order).where(owner_clause) + .order_by(Order.created_at.desc()) + .offset(offset).limit(ORDERS_PER_PAGE) + ) + if where_clause is not None: + stmt = stmt.where(where_clause) + + result = await g.s.execute(stmt) + orders = result.scalars().all() + pfx = route_prefix() + qs_fn = makeqs_factory() + + g.orders_page_data = { + "orders": orders, + "page": page, + "total_pages": total_pages, + "search": search, + "search_count": total_count, + "url_for_fn": url_for, + "qs_fn": qs_fn, + "list_url": pfx + url_for("defpage_orders_list"), + } + + +async def _ensure_order_detail(order_id): + """Fetch order detail data and store in g.order_detail_data.""" + from quart import g, url_for, abort + if hasattr(g, "order_detail_data"): + return + from sqlalchemy import select + from sqlalchemy.orm import selectinload + from shared.models.order import Order + from shared.infrastructure.cart_identity import current_cart_identity + from shared.utils import route_prefix + from shared.browser.app.csrf import generate_csrf_token + + if order_id is None: + abort(404) + + ident = current_cart_identity() + if ident["user_id"]: + owner = Order.user_id == ident["user_id"] + elif ident["session_id"]: + owner = Order.session_id == ident["session_id"] + else: + abort(404) + return + + result = await g.s.execute( + select(Order).options(selectinload(Order.items)) + .where(Order.id == order_id, owner) + ) + order = result.scalar_one_or_none() + if not order: + abort(404) + return + + pfx = route_prefix() + g.order_detail_data = { + "order": order, + "calendar_entries": None, + "detail_url": pfx + url_for("defpage_order_detail", order_id=order.id), + "list_url": pfx + url_for("defpage_orders_list"), + "recheck_url": pfx + url_for("orders.order.order_recheck", order_id=order.id), + "pay_url": pfx + url_for("orders.order.order_pay", order_id=order.id), + "csrf_token": generate_csrf_token(), + } + + +async def _h_orders_list_content(**kw): + await _ensure_orders_list() from quart import g d = getattr(g, "orders_page_data", None) if not d: @@ -131,7 +267,8 @@ def _h_orders_list_content(): return _orders_main_panel_sx(d["orders"], rows) -def _h_orders_list_filter(): +async def _h_orders_list_filter(**kw): + await _ensure_orders_list() from quart import g from shared.sx.helpers import sx_call, SxExpr from shared.sx.page import SEARCH_HEADERS_MOBILE @@ -148,7 +285,8 @@ def _h_orders_list_filter(): return sx_call("order-list-header", search_mobile=SxExpr(search_mobile)) -def _h_orders_list_aside(): +async def _h_orders_list_aside(**kw): + await _ensure_orders_list() from quart import g from shared.sx.helpers import sx_call from shared.sx.page import SEARCH_HEADERS_DESKTOP @@ -164,13 +302,15 @@ def _h_orders_list_aside(): ) -def _h_orders_list_url(): +async def _h_orders_list_url(**kw): + await _ensure_orders_list() from quart import g d = getattr(g, "orders_page_data", None) return d["list_url"] if d else "/" -def _h_order_detail_content(): +async def _h_order_detail_content(order_id=None, **kw): + await _ensure_order_detail(order_id) from quart import g d = getattr(g, "order_detail_data", None) if not d: @@ -179,7 +319,8 @@ def _h_order_detail_content(): return _order_main_sx(d["order"], d["calendar_entries"]) -def _h_order_detail_filter(): +async def _h_order_detail_filter(order_id=None, **kw): + await _ensure_order_detail(order_id) from quart import g d = getattr(g, "order_detail_data", None) if not d: @@ -189,13 +330,15 @@ def _h_order_detail_filter(): d["pay_url"], d["csrf_token"]) -def _h_order_detail_url(): +async def _h_order_detail_url(order_id=None, **kw): + await _ensure_order_detail(order_id) from quart import g d = getattr(g, "order_detail_data", None) return d["detail_url"] if d else "/" -def _h_order_list_url_from_detail(): +async def _h_order_list_url_from_detail(order_id=None, **kw): + await _ensure_order_detail(order_id) from quart import g d = getattr(g, "order_detail_data", None) return d["list_url"] if d else "/" diff --git a/orders/sxc/pages/orders.sx b/orders/sxc/pages/orders.sx index a28e25b..4e32cff 100644 --- a/orders/sxc/pages/orders.sx +++ b/orders/sxc/pages/orders.sx @@ -21,7 +21,7 @@ :path "//" :auth :public :layout (:order-detail - :list-url (order-list-url-from-detail) - :detail-url (order-detail-url)) - :filter (order-detail-filter) - :content (order-detail-content)) + :list-url (order-list-url-from-detail order-id) + :detail-url (order-detail-url order-id)) + :filter (order-detail-filter order-id) + :content (order-detail-content order-id)) diff --git a/shared/sx/async_eval.py b/shared/sx/async_eval.py index 4388ecd..ef8a5d8 100644 --- a/shared/sx/async_eval.py +++ b/shared/sx/async_eval.py @@ -19,6 +19,7 @@ Usage:: from __future__ import annotations +import inspect from typing import Any from .types import Component, Keyword, Lambda, Macro, NIL, Symbol @@ -114,7 +115,10 @@ async def async_eval(expr: Any, env: dict[str, Any], ctx: RequestContext) -> Any args = [await async_eval(a, env, ctx) for a in expr[1:]] if callable(fn) and not isinstance(fn, (Lambda, Component)): - return fn(*args) + result = fn(*args) + if inspect.iscoroutine(result): + return await result + return result if isinstance(fn, Lambda): return await _async_call_lambda(fn, args, env, ctx) if isinstance(fn, Component): @@ -369,6 +373,8 @@ async def _asf_thread_first(expr, env, ctx): args = [result] if callable(fn) and not isinstance(fn, (Lambda, Component)): result = fn(*args) + if inspect.iscoroutine(result): + result = await result elif isinstance(fn, Lambda): result = await _async_call_lambda(fn, args, env, ctx) else: @@ -418,7 +424,8 @@ async def _aho_map(expr, env, ctx): if isinstance(fn, Lambda): results.append(await _async_call_lambda(fn, [item], env, ctx)) elif callable(fn): - results.append(fn(item)) + r = fn(item) + results.append(await r if inspect.iscoroutine(r) else r) else: raise EvalError(f"map requires callable, got {type(fn).__name__}") return results @@ -432,7 +439,8 @@ async def _aho_map_indexed(expr, env, ctx): if isinstance(fn, Lambda): results.append(await _async_call_lambda(fn, [i, item], env, ctx)) elif callable(fn): - results.append(fn(i, item)) + r = fn(i, item) + results.append(await r if inspect.iscoroutine(r) else r) else: raise EvalError(f"map-indexed requires callable, got {type(fn).__name__}") return results @@ -447,6 +455,8 @@ async def _aho_filter(expr, env, ctx): val = await _async_call_lambda(fn, [item], env, ctx) elif callable(fn): val = fn(item) + if inspect.iscoroutine(val): + val = await val else: raise EvalError(f"filter requires callable, got {type(fn).__name__}") if val: @@ -459,7 +469,12 @@ async def _aho_reduce(expr, env, ctx): acc = await async_eval(expr[2], env, ctx) coll = await async_eval(expr[3], env, ctx) for item in coll: - acc = await _async_call_lambda(fn, [acc, item], env, ctx) if isinstance(fn, Lambda) else fn(acc, item) + if isinstance(fn, Lambda): + acc = await _async_call_lambda(fn, [acc, item], env, ctx) + else: + acc = fn(acc, item) + if inspect.iscoroutine(acc): + acc = await acc return acc @@ -467,7 +482,12 @@ async def _aho_some(expr, env, ctx): fn = await async_eval(expr[1], env, ctx) coll = await async_eval(expr[2], env, ctx) for item in coll: - result = await _async_call_lambda(fn, [item], env, ctx) if isinstance(fn, Lambda) else fn(item) + if isinstance(fn, Lambda): + result = await _async_call_lambda(fn, [item], env, ctx) + else: + result = fn(item) + if inspect.iscoroutine(result): + result = await result if result: return result return NIL @@ -477,7 +497,13 @@ async def _aho_every(expr, env, ctx): fn = await async_eval(expr[1], env, ctx) coll = await async_eval(expr[2], env, ctx) for item in coll: - if not (await _async_call_lambda(fn, [item], env, ctx) if isinstance(fn, Lambda) else fn(item)): + if isinstance(fn, Lambda): + val = await _async_call_lambda(fn, [item], env, ctx) + else: + val = fn(item) + if inspect.iscoroutine(val): + val = await val + if not val: return False return True @@ -489,7 +515,9 @@ async def _aho_for_each(expr, env, ctx): if isinstance(fn, Lambda): await _async_call_lambda(fn, [item], env, ctx) elif callable(fn): - fn(item) + r = fn(item) + if inspect.iscoroutine(r): + await r return NIL @@ -782,7 +810,10 @@ async def _arsf_map(expr, env, ctx): if isinstance(fn, Lambda): parts.append(await _arender_lambda(fn, (item,), env, ctx)) elif callable(fn): - parts.append(await _arender(fn(item), env, ctx)) + r = fn(item) + if inspect.iscoroutine(r): + r = await r + parts.append(await _arender(r, env, ctx)) else: parts.append(await _arender(item, env, ctx)) return "".join(parts) @@ -796,7 +827,10 @@ async def _arsf_map_indexed(expr, env, ctx): if isinstance(fn, Lambda): parts.append(await _arender_lambda(fn, (i, item), env, ctx)) elif callable(fn): - parts.append(await _arender(fn(i, item), env, ctx)) + r = fn(i, item) + if inspect.iscoroutine(r): + r = await r + parts.append(await _arender(r, env, ctx)) else: parts.append(await _arender(item, env, ctx)) return "".join(parts) @@ -815,7 +849,10 @@ async def _arsf_for_each(expr, env, ctx): if isinstance(fn, Lambda): parts.append(await _arender_lambda(fn, (item,), env, ctx)) elif callable(fn): - parts.append(await _arender(fn(item), env, ctx)) + r = fn(item) + if inspect.iscoroutine(r): + r = await r + parts.append(await _arender(r, env, ctx)) else: parts.append(await _arender(item, env, ctx)) return "".join(parts) @@ -956,7 +993,10 @@ async def _aser(expr: Any, env: dict[str, Any], ctx: RequestContext) -> Any: args = [await async_eval(a, env, ctx) for a in expr[1:]] if callable(fn) and not isinstance(fn, (Lambda, Component)): - return fn(*args) + result = fn(*args) + if inspect.iscoroutine(result): + return await result + return result if isinstance(fn, Lambda): return await _async_call_lambda(fn, args, env, ctx) if isinstance(fn, Component): @@ -1151,7 +1191,8 @@ async def _asho_ser_map(expr, env, ctx): local[p] = v results.append(await _aser(fn.body, local, ctx)) elif callable(fn): - results.append(fn(item)) + r = fn(item) + results.append(await r if inspect.iscoroutine(r) else r) else: raise EvalError(f"map requires callable, got {type(fn).__name__}") return results @@ -1169,7 +1210,8 @@ async def _asho_ser_map_indexed(expr, env, ctx): local[fn.params[1]] = item results.append(await _aser(fn.body, local, ctx)) elif callable(fn): - results.append(fn(i, item)) + r = fn(i, item) + results.append(await r if inspect.iscoroutine(r) else r) else: raise EvalError(f"map-indexed requires callable, got {type(fn).__name__}") return results @@ -1191,7 +1233,8 @@ async def _asho_ser_for_each(expr, env, ctx): local[fn.params[0]] = item results.append(await _aser(fn.body, local, ctx)) elif callable(fn): - results.append(fn(item)) + r = fn(item) + results.append(await r if inspect.iscoroutine(r) else r) return results diff --git a/shared/sx/pages.py b/shared/sx/pages.py index 2149816..41b8600 100644 --- a/shared/sx/pages.py +++ b/shared/sx/pages.py @@ -290,6 +290,18 @@ async def execute_page( # Blueprint mounting # --------------------------------------------------------------------------- +def auto_mount_pages(app: Any, service_name: str) -> None: + """Auto-mount all registered defpages for a service directly on the app. + + Pages must have absolute paths (from the service URL root). + Called once per service in app.py after setup_*_pages(). + """ + pages = get_all_pages(service_name) + for page_def in pages.values(): + _mount_one_page(app, service_name, page_def) + logger.info("Auto-mounted %d defpages for %s", len(pages), service_name) + + def mount_pages(bp: Any, service_name: str, names: set[str] | list[str] | None = None) -> None: """Mount registered PageDef routes onto a Quart Blueprint. diff --git a/sx/app.py b/sx/app.py index c688def..390e490 100644 --- a/sx/app.py +++ b/sx/app.py @@ -54,12 +54,11 @@ def create_app() -> "Quart": setup_sx_pages() bp = register_pages(url_prefix="/") - - from shared.sx.pages import mount_pages - mount_pages(bp, "sx") - app.register_blueprint(bp) + from shared.sx.pages import auto_mount_pages + auto_mount_pages(app, "sx") + return app From e30cb0a992158eb6a1f3b555edd95ef68f4929c1 Mon Sep 17 00:00:00 2001 From: giles Date: Tue, 3 Mar 2026 19:13:15 +0000 Subject: [PATCH 004/105] Auto-mount fragment handlers: eliminate fragment blueprint boilerplate across all 8 services MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Fragment read API is now fully declarative — every handler is a defhandler s-expression dispatched through one shared auto_mount_fragment_handlers() function. Replaces 8 near-identical blueprint files (~35 lines each) with a single function call per service. Events Python handlers (container-cards, account-page) extracted to a standalone module. Co-Authored-By: Claude Opus 4.6 --- account/app.py | 5 +- account/bp/__init__.py | 1 - account/bp/fragments/__init__.py | 1 - account/bp/fragments/routes.py | 36 --------- blog/app.py | 5 +- blog/bp/__init__.py | 1 - blog/bp/fragments/__init__.py | 1 - blog/bp/fragments/routes.py | 36 --------- cart/app.py | 5 +- cart/bp/__init__.py | 1 - cart/bp/fragments/__init__.py | 1 - cart/bp/fragments/routes.py | 36 --------- events/app.py | 9 ++- events/bp/__init__.py | 1 - events/bp/fragments/__init__.py | 1 - events/bp/fragments/python_handlers.py | 58 ++++++++++++++ events/bp/fragments/routes.py | 104 ------------------------- federation/app.py | 4 +- federation/bp/__init__.py | 1 - federation/bp/fragments/__init__.py | 1 - federation/bp/fragments/routes.py | 36 --------- market/app.py | 6 +- market/bp/__init__.py | 1 - market/bp/fragments/__init__.py | 1 - market/bp/fragments/routes.py | 36 --------- orders/app.py | 5 +- orders/bp/__init__.py | 1 - orders/bp/fragments/__init__.py | 0 orders/bp/fragments/routes.py | 36 --------- relations/app.py | 6 +- relations/bp/__init__.py | 1 - relations/bp/fragments/__init__.py | 0 relations/bp/fragments/routes.py | 36 --------- shared/sx/handlers.py | 39 ++++++++++ 34 files changed, 126 insertions(+), 386 deletions(-) delete mode 100644 account/bp/fragments/__init__.py delete mode 100644 account/bp/fragments/routes.py delete mode 100644 blog/bp/fragments/__init__.py delete mode 100644 blog/bp/fragments/routes.py delete mode 100644 cart/bp/fragments/__init__.py delete mode 100644 cart/bp/fragments/routes.py create mode 100644 events/bp/fragments/python_handlers.py delete mode 100644 events/bp/fragments/routes.py delete mode 100644 federation/bp/fragments/__init__.py delete mode 100644 federation/bp/fragments/routes.py delete mode 100644 market/bp/fragments/__init__.py delete mode 100644 market/bp/fragments/routes.py delete mode 100644 orders/bp/fragments/__init__.py delete mode 100644 orders/bp/fragments/routes.py delete mode 100644 relations/bp/fragments/__init__.py delete mode 100644 relations/bp/fragments/routes.py diff --git a/account/app.py b/account/app.py index 639262e..5b92a64 100644 --- a/account/app.py +++ b/account/app.py @@ -8,7 +8,7 @@ from jinja2 import FileSystemLoader, ChoiceLoader from shared.infrastructure.factory import create_base_app -from bp import register_account_bp, register_auth_bp, register_fragments +from bp import register_account_bp, register_auth_bp async def account_context() -> dict: @@ -86,7 +86,8 @@ def create_app() -> "Quart": from shared.sx.pages import auto_mount_pages auto_mount_pages(app, "account") - app.register_blueprint(register_fragments()) + from shared.sx.handlers import auto_mount_fragment_handlers + auto_mount_fragment_handlers(app, "account") from bp.actions.routes import register as register_actions app.register_blueprint(register_actions()) diff --git a/account/bp/__init__.py b/account/bp/__init__.py index fe22f4e..2113b69 100644 --- a/account/bp/__init__.py +++ b/account/bp/__init__.py @@ -1,3 +1,2 @@ from .account.routes import register as register_account_bp from .auth.routes import register as register_auth_bp -from .fragments import register_fragments diff --git a/account/bp/fragments/__init__.py b/account/bp/fragments/__init__.py deleted file mode 100644 index a4af44b..0000000 --- a/account/bp/fragments/__init__.py +++ /dev/null @@ -1 +0,0 @@ -from .routes import register as register_fragments diff --git a/account/bp/fragments/routes.py b/account/bp/fragments/routes.py deleted file mode 100644 index 28a4362..0000000 --- a/account/bp/fragments/routes.py +++ /dev/null @@ -1,36 +0,0 @@ -"""Account app fragment endpoints. - -Exposes sx fragments at ``/internal/fragments/`` for consumption -by other coop apps via the fragment client. - -All handlers are defined declaratively in .sx files under -``account/sx/handlers/`` and dispatched via the sx handler registry. -""" - -from __future__ import annotations - -from quart import Blueprint, Response, request - -from shared.infrastructure.fragments import FRAGMENT_HEADER -from shared.sx.handlers import get_handler, execute_handler - - -def register(): - bp = Blueprint("fragments", __name__, url_prefix="/internal/fragments") - - @bp.before_request - async def _require_fragment_header(): - if not request.headers.get(FRAGMENT_HEADER): - return Response("", status=403) - - @bp.get("/") - async def get_fragment(fragment_type: str): - handler_def = get_handler("account", fragment_type) - if handler_def is not None: - result = await execute_handler( - handler_def, "account", args=dict(request.args), - ) - return Response(result, status=200, content_type="text/sx") - return Response("", status=200, content_type="text/sx") - - return bp diff --git a/blog/app.py b/blog/app.py index 9fe803b..664f9c0 100644 --- a/blog/app.py +++ b/blog/app.py @@ -16,7 +16,6 @@ from bp import ( register_admin, register_menu_items, register_snippets, - register_fragments, register_data, register_actions, ) @@ -108,7 +107,9 @@ def create_app() -> "Quart": app.register_blueprint(register_admin("/settings")) app.register_blueprint(register_menu_items()) app.register_blueprint(register_snippets()) - app.register_blueprint(register_fragments()) + from shared.sx.handlers import auto_mount_fragment_handlers + auto_mount_fragment_handlers(app, "blog") + app.register_blueprint(register_data()) app.register_blueprint(register_actions()) diff --git a/blog/bp/__init__.py b/blog/bp/__init__.py index eb7938b..9e21b5a 100644 --- a/blog/bp/__init__.py +++ b/blog/bp/__init__.py @@ -2,6 +2,5 @@ from .blog.routes import register as register_blog_bp from .admin.routes import register as register_admin from .menu_items.routes import register as register_menu_items from .snippets.routes import register as register_snippets -from .fragments import register_fragments from .data import register_data from .actions.routes import register as register_actions diff --git a/blog/bp/fragments/__init__.py b/blog/bp/fragments/__init__.py deleted file mode 100644 index a4af44b..0000000 --- a/blog/bp/fragments/__init__.py +++ /dev/null @@ -1 +0,0 @@ -from .routes import register as register_fragments diff --git a/blog/bp/fragments/routes.py b/blog/bp/fragments/routes.py deleted file mode 100644 index 9b0818f..0000000 --- a/blog/bp/fragments/routes.py +++ /dev/null @@ -1,36 +0,0 @@ -"""Blog app fragment endpoints. - -Exposes sx fragments at ``/internal/fragments/`` for consumption -by other coop apps via the fragment client. - -All handlers are defined declaratively in .sx files under -``blog/sx/handlers/`` and dispatched via the sx handler registry. -""" - -from __future__ import annotations - -from quart import Blueprint, Response, request - -from shared.infrastructure.fragments import FRAGMENT_HEADER -from shared.sx.handlers import get_handler, execute_handler - - -def register(): - bp = Blueprint("fragments", __name__, url_prefix="/internal/fragments") - - @bp.before_request - async def _require_fragment_header(): - if not request.headers.get(FRAGMENT_HEADER): - return Response("", status=403) - - @bp.get("/") - async def get_fragment(fragment_type: str): - handler_def = get_handler("blog", fragment_type) - if handler_def is not None: - result = await execute_handler( - handler_def, "blog", args=dict(request.args), - ) - return Response(result, status=200, content_type="text/sx") - return Response("", status=200, content_type="text/sx") - - return bp diff --git a/cart/app.py b/cart/app.py index 5ae5140..b957ec4 100644 --- a/cart/app.py +++ b/cart/app.py @@ -17,7 +17,6 @@ from bp import ( register_page_cart, register_cart_global, register_page_admin, - register_fragments, register_actions, register_data, register_inbox, @@ -141,7 +140,9 @@ def create_app() -> "Quart": app.jinja_env.globals["cart_quantity_url"] = lambda product_id: f"/quantity/{product_id}/" app.jinja_env.globals["cart_delete_url"] = lambda product_id: f"/delete/{product_id}/" - app.register_blueprint(register_fragments()) + from shared.sx.handlers import auto_mount_fragment_handlers + auto_mount_fragment_handlers(app, "cart") + app.register_blueprint(register_actions()) app.register_blueprint(register_data()) app.register_blueprint(register_inbox()) diff --git a/cart/bp/__init__.py b/cart/bp/__init__.py index a48e533..c14d2fa 100644 --- a/cart/bp/__init__.py +++ b/cart/bp/__init__.py @@ -2,7 +2,6 @@ from .cart.overview_routes import register as register_cart_overview from .cart.page_routes import register as register_page_cart from .cart.global_routes import register as register_cart_global from .page_admin.routes import register as register_page_admin -from .fragments import register_fragments from .actions import register_actions from .data import register_data from .inbox import register_inbox diff --git a/cart/bp/fragments/__init__.py b/cart/bp/fragments/__init__.py deleted file mode 100644 index a4af44b..0000000 --- a/cart/bp/fragments/__init__.py +++ /dev/null @@ -1 +0,0 @@ -from .routes import register as register_fragments diff --git a/cart/bp/fragments/routes.py b/cart/bp/fragments/routes.py deleted file mode 100644 index 6c84d22..0000000 --- a/cart/bp/fragments/routes.py +++ /dev/null @@ -1,36 +0,0 @@ -"""Cart app fragment endpoints. - -Exposes sx fragments at ``/internal/fragments/`` for consumption -by other coop apps via the fragment client. - -All handlers are defined declaratively in .sx files under -``cart/sx/handlers/`` and dispatched via the sx handler registry. -""" - -from __future__ import annotations - -from quart import Blueprint, Response, request - -from shared.infrastructure.fragments import FRAGMENT_HEADER -from shared.sx.handlers import get_handler, execute_handler - - -def register(): - bp = Blueprint("fragments", __name__, url_prefix="/internal/fragments") - - @bp.before_request - async def _require_fragment_header(): - if not request.headers.get(FRAGMENT_HEADER): - return Response("", status=403) - - @bp.get("/") - async def get_fragment(fragment_type: str): - handler_def = get_handler("cart", fragment_type) - if handler_def is not None: - result = await execute_handler( - handler_def, "cart", args=dict(request.args), - ) - return Response(result, status=200, content_type="text/sx") - return Response("", status=200, content_type="text/sx") - - return bp diff --git a/events/app.py b/events/app.py index 58a5ce0..59dae16 100644 --- a/events/app.py +++ b/events/app.py @@ -9,7 +9,7 @@ from jinja2 import FileSystemLoader, ChoiceLoader from shared.infrastructure.factory import create_base_app -from bp import register_all_events, register_calendar, register_calendars, register_markets, register_page, register_fragments, register_actions, register_data +from bp import register_all_events, register_calendar, register_calendars, register_markets, register_page, register_actions, register_data async def events_context() -> dict: @@ -112,7 +112,12 @@ def create_app() -> "Quart": url_prefix="//markets", ) - app.register_blueprint(register_fragments()) + from shared.sx.handlers import auto_mount_fragment_handlers + from bp.fragments.python_handlers import container_cards_handler, account_page_handler + add_fragment_handler = auto_mount_fragment_handlers(app, "events") + add_fragment_handler("container-cards", container_cards_handler, content_type="text/html") + add_fragment_handler("account-page", account_page_handler) + app.register_blueprint(register_actions()) app.register_blueprint(register_data()) diff --git a/events/bp/__init__.py b/events/bp/__init__.py index cbef22c..ab33a78 100644 --- a/events/bp/__init__.py +++ b/events/bp/__init__.py @@ -3,6 +3,5 @@ from .calendar.routes import register as register_calendar from .calendars.routes import register as register_calendars from .markets.routes import register as register_markets from .page.routes import register as register_page -from .fragments import register_fragments from .actions import register_actions from .data import register_data diff --git a/events/bp/fragments/__init__.py b/events/bp/fragments/__init__.py index a4af44b..e69de29 100644 --- a/events/bp/fragments/__init__.py +++ b/events/bp/fragments/__init__.py @@ -1 +0,0 @@ -from .routes import register as register_fragments diff --git a/events/bp/fragments/python_handlers.py b/events/bp/fragments/python_handlers.py new file mode 100644 index 0000000..0ea1dc4 --- /dev/null +++ b/events/bp/fragments/python_handlers.py @@ -0,0 +1,58 @@ +"""Python fragment handlers for events. + +These handlers call domain services and use sx_call() for rendering, +so they can't be expressed as declarative .sx handlers. +""" + +from __future__ import annotations + +from quart import g, request + +from shared.services.registry import services + + +async def container_cards_handler(): + """Container-cards fragment: entries for blog listing cards. + + Returns text/html with comment markers + so the blog consumer can split per-post fragments. + """ + from sx.sx_components import render_fragment_container_cards + + post_ids_raw = request.args.get("post_ids", "") + post_slugs_raw = request.args.get("post_slugs", "") + post_ids = [int(x) for x in post_ids_raw.split(",") if x.strip()] + post_slugs = [x.strip() for x in post_slugs_raw.split(",") if x.strip()] + if not post_ids: + return "" + + slug_map = {} + for i, pid in enumerate(post_ids): + slug_map[pid] = post_slugs[i] if i < len(post_slugs) else "" + + batch = await services.calendar.confirmed_entries_for_posts(g.s, post_ids) + return render_fragment_container_cards(batch, post_ids, slug_map) + + +async def account_page_handler(): + """Account-page fragment: tickets or bookings panel. + + Returns text/sx — the account app embeds this as sx source. + """ + from sx.sx_components import ( + render_fragment_account_tickets, + render_fragment_account_bookings, + ) + + slug = request.args.get("slug", "") + user_id = request.args.get("user_id", type=int) + if not user_id: + return "" + + if slug == "tickets": + tickets = await services.calendar.user_tickets(g.s, user_id=user_id) + return render_fragment_account_tickets(tickets) + elif slug == "bookings": + bookings = await services.calendar.user_bookings(g.s, user_id=user_id) + return render_fragment_account_bookings(bookings) + return "" diff --git a/events/bp/fragments/routes.py b/events/bp/fragments/routes.py deleted file mode 100644 index 01c9f4b..0000000 --- a/events/bp/fragments/routes.py +++ /dev/null @@ -1,104 +0,0 @@ -"""Events app fragment endpoints. - -Exposes sx fragments at ``/internal/fragments/`` for consumption -by other coop apps via the fragment client. - -All handlers are defined declaratively in .sx files under -``events/sx/handlers/`` and dispatched via the sx handler registry. - -container-cards and account-page remain as Python handlers because they -call domain service methods and return batched/conditional content, but -they use sx_call() for rendering (no Jinja templates). -""" - -from __future__ import annotations - -from quart import Blueprint, Response, g, request - -from shared.infrastructure.fragments import FRAGMENT_HEADER -from shared.services.registry import services -from shared.sx.handlers import get_handler, execute_handler - - -def register(): - bp = Blueprint("fragments", __name__, url_prefix="/internal/fragments") - - _handlers: dict[str, object] = {} - - # Fragment types that return HTML (comment-delimited batch) - _html_types = {"container-cards"} - - @bp.before_request - async def _require_fragment_header(): - if not request.headers.get(FRAGMENT_HEADER): - return Response("", status=403) - - @bp.get("/") - async def get_fragment(fragment_type: str): - # 1. Check Python handlers first - handler = _handlers.get(fragment_type) - if handler is not None: - result = await handler() - ct = "text/html" if fragment_type in _html_types else "text/sx" - return Response(result, status=200, content_type=ct) - - # 2. Check sx handler registry - handler_def = get_handler("events", fragment_type) - if handler_def is not None: - result = await execute_handler( - handler_def, "events", args=dict(request.args), - ) - return Response(result, status=200, content_type="text/sx") - - return Response("", status=200, content_type="text/sx") - - # --- container-cards fragment: entries for blog listing cards ----------- - # Returns text/html with comment markers - # so the blog consumer can split per-post fragments. - - async def _container_cards_handler(): - from sx.sx_components import render_fragment_container_cards - - post_ids_raw = request.args.get("post_ids", "") - post_slugs_raw = request.args.get("post_slugs", "") - post_ids = [int(x) for x in post_ids_raw.split(",") if x.strip()] - post_slugs = [x.strip() for x in post_slugs_raw.split(",") if x.strip()] - if not post_ids: - return "" - - slug_map = {} - for i, pid in enumerate(post_ids): - slug_map[pid] = post_slugs[i] if i < len(post_slugs) else "" - - batch = await services.calendar.confirmed_entries_for_posts(g.s, post_ids) - return render_fragment_container_cards(batch, post_ids, slug_map) - - _handlers["container-cards"] = _container_cards_handler - - # --- account-page fragment: tickets or bookings panel ------------------ - # Returns text/sx — the account app embeds this as sx source. - - async def _account_page_handler(): - from sx.sx_components import ( - render_fragment_account_tickets, - render_fragment_account_bookings, - ) - - slug = request.args.get("slug", "") - user_id = request.args.get("user_id", type=int) - if not user_id: - return "" - - if slug == "tickets": - tickets = await services.calendar.user_tickets(g.s, user_id=user_id) - return render_fragment_account_tickets(tickets) - elif slug == "bookings": - bookings = await services.calendar.user_bookings(g.s, user_id=user_id) - return render_fragment_account_bookings(bookings) - return "" - - _handlers["account-page"] = _account_page_handler - - bp._fragment_handlers = _handlers - - return bp diff --git a/federation/app.py b/federation/app.py index 60cd5a2..5b86d3d 100644 --- a/federation/app.py +++ b/federation/app.py @@ -12,7 +12,6 @@ from shared.services.registry import services from bp import ( register_identity_bp, register_social_bp, - register_fragments, ) @@ -99,7 +98,8 @@ def create_app() -> "Quart": from shared.sx.pages import auto_mount_pages auto_mount_pages(app, "federation") - app.register_blueprint(register_fragments()) + from shared.sx.handlers import auto_mount_fragment_handlers + auto_mount_fragment_handlers(app, "federation") # --- home page --- @app.get("/") diff --git a/federation/bp/__init__.py b/federation/bp/__init__.py index 1be06bb..0965667 100644 --- a/federation/bp/__init__.py +++ b/federation/bp/__init__.py @@ -1,3 +1,2 @@ from .identity.routes import register as register_identity_bp from .social.routes import register as register_social_bp -from .fragments import register_fragments diff --git a/federation/bp/fragments/__init__.py b/federation/bp/fragments/__init__.py deleted file mode 100644 index a4af44b..0000000 --- a/federation/bp/fragments/__init__.py +++ /dev/null @@ -1 +0,0 @@ -from .routes import register as register_fragments diff --git a/federation/bp/fragments/routes.py b/federation/bp/fragments/routes.py deleted file mode 100644 index 95da7d9..0000000 --- a/federation/bp/fragments/routes.py +++ /dev/null @@ -1,36 +0,0 @@ -"""Federation app fragment endpoints. - -Exposes sx fragments at ``/internal/fragments/`` for consumption -by other coop apps via the fragment client. - -All handlers are defined declaratively in .sx files under -``federation/sx/handlers/`` and dispatched via the sx handler registry. -""" - -from __future__ import annotations - -from quart import Blueprint, Response, request - -from shared.infrastructure.fragments import FRAGMENT_HEADER -from shared.sx.handlers import get_handler, execute_handler - - -def register(): - bp = Blueprint("fragments", __name__, url_prefix="/internal/fragments") - - @bp.before_request - async def _require_fragment_header(): - if not request.headers.get(FRAGMENT_HEADER): - return Response("", status=403) - - @bp.get("/") - async def get_fragment(fragment_type: str): - handler_def = get_handler("federation", fragment_type) - if handler_def is not None: - result = await execute_handler( - handler_def, "federation", args=dict(request.args), - ) - return Response(result, status=200, content_type="text/sx") - return Response("", status=200, content_type="text/sx") - - return bp diff --git a/market/app.py b/market/app.py index a65ccac..05c6e6b 100644 --- a/market/app.py +++ b/market/app.py @@ -11,7 +11,7 @@ from sqlalchemy import select from shared.infrastructure.factory import create_base_app from shared.config import config -from bp import register_market_bp, register_all_markets, register_page_markets, register_page_admin, register_fragments, register_actions, register_data +from bp import register_market_bp, register_all_markets, register_page_markets, register_page_admin, register_actions, register_data async def market_context() -> dict: @@ -126,7 +126,9 @@ def create_app() -> "Quart": url_prefix="//", ) - app.register_blueprint(register_fragments()) + from shared.sx.handlers import auto_mount_fragment_handlers + auto_mount_fragment_handlers(app, "market") + app.register_blueprint(register_actions()) app.register_blueprint(register_data()) diff --git a/market/bp/__init__.py b/market/bp/__init__.py index 1153c6e..03b3b2e 100644 --- a/market/bp/__init__.py +++ b/market/bp/__init__.py @@ -3,6 +3,5 @@ from .product.routes import register as register_product from .all_markets.routes import register as register_all_markets from .page_markets.routes import register as register_page_markets from .page_admin.routes import register as register_page_admin -from .fragments import register_fragments from .actions import register_actions from .data import register_data diff --git a/market/bp/fragments/__init__.py b/market/bp/fragments/__init__.py deleted file mode 100644 index a4af44b..0000000 --- a/market/bp/fragments/__init__.py +++ /dev/null @@ -1 +0,0 @@ -from .routes import register as register_fragments diff --git a/market/bp/fragments/routes.py b/market/bp/fragments/routes.py deleted file mode 100644 index 323c1e5..0000000 --- a/market/bp/fragments/routes.py +++ /dev/null @@ -1,36 +0,0 @@ -"""Market app fragment endpoints. - -Exposes sx fragments at ``/internal/fragments/`` for consumption -by other coop apps via the fragment client. - -All handlers are defined declaratively in .sx files under -``market/sx/handlers/`` and dispatched via the sx handler registry. -""" - -from __future__ import annotations - -from quart import Blueprint, Response, request - -from shared.infrastructure.fragments import FRAGMENT_HEADER -from shared.sx.handlers import get_handler, execute_handler - - -def register(): - bp = Blueprint("fragments", __name__, url_prefix="/internal/fragments") - - @bp.before_request - async def _require_fragment_header(): - if not request.headers.get(FRAGMENT_HEADER): - return Response("", status=403) - - @bp.get("/") - async def get_fragment(fragment_type: str): - handler_def = get_handler("market", fragment_type) - if handler_def is not None: - result = await execute_handler( - handler_def, "market", args=dict(request.args), - ) - return Response(result, status=200, content_type="text/sx") - return Response("", status=200, content_type="text/sx") - - return bp diff --git a/orders/app.py b/orders/app.py index a782056..762b2e1 100644 --- a/orders/app.py +++ b/orders/app.py @@ -14,7 +14,6 @@ from bp import ( register_orders, register_order, register_checkout, - register_fragments, register_actions, register_data, ) @@ -77,7 +76,9 @@ def create_app() -> "Quart": from sxc.pages import setup_orders_pages setup_orders_pages() - app.register_blueprint(register_fragments()) + from shared.sx.handlers import auto_mount_fragment_handlers + auto_mount_fragment_handlers(app, "orders") + app.register_blueprint(register_actions()) app.register_blueprint(register_data()) diff --git a/orders/bp/__init__.py b/orders/bp/__init__.py index 590fe33..61e6380 100644 --- a/orders/bp/__init__.py +++ b/orders/bp/__init__.py @@ -3,4 +3,3 @@ from .orders.routes import register as register_orders from .checkout.routes import register as register_checkout from .data.routes import register as register_data from .actions.routes import register as register_actions -from .fragments.routes import register as register_fragments diff --git a/orders/bp/fragments/__init__.py b/orders/bp/fragments/__init__.py deleted file mode 100644 index e69de29..0000000 diff --git a/orders/bp/fragments/routes.py b/orders/bp/fragments/routes.py deleted file mode 100644 index e18f5c4..0000000 --- a/orders/bp/fragments/routes.py +++ /dev/null @@ -1,36 +0,0 @@ -"""Orders app fragment endpoints. - -Exposes sx fragments at ``/internal/fragments/`` for consumption -by other coop apps via the fragment client. - -All handlers are defined declaratively in .sx files under -``orders/sx/handlers/`` and dispatched via the sx handler registry. -""" - -from __future__ import annotations - -from quart import Blueprint, Response, request - -from shared.infrastructure.fragments import FRAGMENT_HEADER -from shared.sx.handlers import get_handler, execute_handler - - -def register(): - bp = Blueprint("fragments", __name__, url_prefix="/internal/fragments") - - @bp.before_request - async def _require_fragment_header(): - if not request.headers.get(FRAGMENT_HEADER): - return Response("", status=403) - - @bp.get("/") - async def get_fragment(fragment_type: str): - handler_def = get_handler("orders", fragment_type) - if handler_def is not None: - result = await execute_handler( - handler_def, "orders", args=dict(request.args), - ) - return Response(result, status=200, content_type="text/sx") - return Response("", status=200, content_type="text/sx") - - return bp diff --git a/relations/app.py b/relations/app.py index 3515745..a93419c 100644 --- a/relations/app.py +++ b/relations/app.py @@ -4,7 +4,7 @@ import sx.sx_components as sx_components # noqa: F401 # ensure Hypercorn --rel from shared.infrastructure.factory import create_base_app -from bp import register_actions, register_data, register_fragments +from bp import register_actions, register_data from services import register_domain_services @@ -16,7 +16,9 @@ def create_app() -> "Quart": app.register_blueprint(register_actions()) app.register_blueprint(register_data()) - app.register_blueprint(register_fragments()) + + from shared.sx.handlers import auto_mount_fragment_handlers + auto_mount_fragment_handlers(app, "relations") return app diff --git a/relations/bp/__init__.py b/relations/bp/__init__.py index ee25922..7122ccd 100644 --- a/relations/bp/__init__.py +++ b/relations/bp/__init__.py @@ -1,3 +1,2 @@ from .data.routes import register as register_data from .actions.routes import register as register_actions -from .fragments.routes import register as register_fragments diff --git a/relations/bp/fragments/__init__.py b/relations/bp/fragments/__init__.py deleted file mode 100644 index e69de29..0000000 diff --git a/relations/bp/fragments/routes.py b/relations/bp/fragments/routes.py deleted file mode 100644 index cc90927..0000000 --- a/relations/bp/fragments/routes.py +++ /dev/null @@ -1,36 +0,0 @@ -"""Relations app fragment endpoints. - -Exposes sx fragments at ``/internal/fragments/`` for consumption -by other coop apps via the fragment client. - -All handlers are defined declaratively in .sx files under -``relations/sx/handlers/`` and dispatched via the sx handler registry. -""" - -from __future__ import annotations - -from quart import Blueprint, Response, request - -from shared.infrastructure.fragments import FRAGMENT_HEADER -from shared.sx.handlers import get_handler, execute_handler - - -def register(): - bp = Blueprint("fragments", __name__, url_prefix="/internal/fragments") - - @bp.before_request - async def _require_fragment_header(): - if not request.headers.get(FRAGMENT_HEADER): - return Response("", status=403) - - @bp.get("/") - async def get_fragment(fragment_type: str): - handler_def = get_handler("relations", fragment_type) - if handler_def is not None: - result = await execute_handler( - handler_def, "relations", args=dict(request.args), - ) - return Response(result, status=200, content_type="text/sx") - return Response("", status=200, content_type="text/sx") - - return bp diff --git a/shared/sx/handlers.py b/shared/sx/handlers.py index 6e653f2..4cf00cc 100644 --- a/shared/sx/handlers.py +++ b/shared/sx/handlers.py @@ -204,3 +204,42 @@ def create_handler_blueprint(service_name: str) -> Any: bp._python_handlers = _python_handlers # type: ignore[attr-defined] return bp + + +# --------------------------------------------------------------------------- +# Direct app mount — replaces per-service fragment blueprint boilerplate +# --------------------------------------------------------------------------- + +def auto_mount_fragment_handlers(app: Any, service_name: str) -> Callable: + """Mount ``/internal/fragments/`` directly on the app. + + Returns an ``add_handler(name, fn, content_type)`` function for + registering Python handler overrides (checked before SX handlers). + """ + from quart import Response, request + from shared.infrastructure.fragments import FRAGMENT_HEADER + + python_handlers: dict[str, Callable[[], Awaitable[str]]] = {} + html_types: set[str] = set() + + @app.get("/internal/fragments/") + async def _fragment_dispatch(fragment_type: str): + if not request.headers.get(FRAGMENT_HEADER): + return Response("", status=403) + py = python_handlers.get(fragment_type) + if py is not None: + result = await py() + ct = "text/html" if fragment_type in html_types else "text/sx" + return Response(result, status=200, content_type=ct) + hdef = get_handler(service_name, fragment_type) + if hdef is not None: + result = await execute_handler(hdef, service_name, args=dict(request.args)) + return Response(result, status=200, content_type="text/sx") + return Response("", status=200, content_type="text/sx") + + def add_handler(name: str, fn: Callable[[], Awaitable[str]], content_type: str = "text/sx") -> None: + python_handlers[name] = fn + if content_type == "text/html": + html_types.add(name) + + return add_handler From f551fc7453a5e66ccf03f542f26d6ded06a8cc72 Mon Sep 17 00:00:00 2001 From: giles Date: Tue, 3 Mar 2026 19:42:19 +0000 Subject: [PATCH 005/105] Convert last Python fragment handlers to SX defhandlers: 100% declarative fragment API - Add dict recursion to _convert_result for service methods returning dict[K, list[DTO]] - New container-cards.sx: parses post_ids/slugs, calls confirmed-entries-for-posts, emits card-widget markers - New account-page.sx: dispatches on slug for tickets/bookings panels with status pills and empty states - Fix blog _parse_card_fragments to handle SxExpr via str() cast - Remove events Python fragment handlers and simplify app.py to plain auto_mount Co-Authored-By: Claude Opus 4.6 --- blog/bp/blog/services/posts_data.py | 2 +- events/app.py | 5 +-- events/bp/fragments/__init__.py | 0 events/bp/fragments/python_handlers.py | 58 -------------------------- events/sx/handlers/account-page.sx | 49 ++++++++++++++++++++++ events/sx/handlers/container-cards.sx | 38 +++++++++++++++++ shared/sx/primitives_io.py | 2 + 7 files changed, 91 insertions(+), 63 deletions(-) delete mode 100644 events/bp/fragments/__init__.py delete mode 100644 events/bp/fragments/python_handlers.py create mode 100644 events/sx/handlers/account-page.sx create mode 100644 events/sx/handlers/container-cards.sx diff --git a/blog/bp/blog/services/posts_data.py b/blog/bp/blog/services/posts_data.py index 1d2c0ad..d24ee6b 100644 --- a/blog/bp/blog/services/posts_data.py +++ b/blog/bp/blog/services/posts_data.py @@ -126,7 +126,7 @@ _CARD_MARKER_RE = re.compile( def _parse_card_fragments(html: str) -> dict[str, str]: """Parse the container-cards fragment into {post_id_str: html} dict.""" result = {} - for m in _CARD_MARKER_RE.finditer(html): + for m in _CARD_MARKER_RE.finditer(str(html)): post_id_str = m.group(1) inner = m.group(2).strip() if inner: diff --git a/events/app.py b/events/app.py index 59dae16..be8fce9 100644 --- a/events/app.py +++ b/events/app.py @@ -113,10 +113,7 @@ def create_app() -> "Quart": ) from shared.sx.handlers import auto_mount_fragment_handlers - from bp.fragments.python_handlers import container_cards_handler, account_page_handler - add_fragment_handler = auto_mount_fragment_handlers(app, "events") - add_fragment_handler("container-cards", container_cards_handler, content_type="text/html") - add_fragment_handler("account-page", account_page_handler) + auto_mount_fragment_handlers(app, "events") app.register_blueprint(register_actions()) app.register_blueprint(register_data()) diff --git a/events/bp/fragments/__init__.py b/events/bp/fragments/__init__.py deleted file mode 100644 index e69de29..0000000 diff --git a/events/bp/fragments/python_handlers.py b/events/bp/fragments/python_handlers.py deleted file mode 100644 index 0ea1dc4..0000000 --- a/events/bp/fragments/python_handlers.py +++ /dev/null @@ -1,58 +0,0 @@ -"""Python fragment handlers for events. - -These handlers call domain services and use sx_call() for rendering, -so they can't be expressed as declarative .sx handlers. -""" - -from __future__ import annotations - -from quart import g, request - -from shared.services.registry import services - - -async def container_cards_handler(): - """Container-cards fragment: entries for blog listing cards. - - Returns text/html with comment markers - so the blog consumer can split per-post fragments. - """ - from sx.sx_components import render_fragment_container_cards - - post_ids_raw = request.args.get("post_ids", "") - post_slugs_raw = request.args.get("post_slugs", "") - post_ids = [int(x) for x in post_ids_raw.split(",") if x.strip()] - post_slugs = [x.strip() for x in post_slugs_raw.split(",") if x.strip()] - if not post_ids: - return "" - - slug_map = {} - for i, pid in enumerate(post_ids): - slug_map[pid] = post_slugs[i] if i < len(post_slugs) else "" - - batch = await services.calendar.confirmed_entries_for_posts(g.s, post_ids) - return render_fragment_container_cards(batch, post_ids, slug_map) - - -async def account_page_handler(): - """Account-page fragment: tickets or bookings panel. - - Returns text/sx — the account app embeds this as sx source. - """ - from sx.sx_components import ( - render_fragment_account_tickets, - render_fragment_account_bookings, - ) - - slug = request.args.get("slug", "") - user_id = request.args.get("user_id", type=int) - if not user_id: - return "" - - if slug == "tickets": - tickets = await services.calendar.user_tickets(g.s, user_id=user_id) - return render_fragment_account_tickets(tickets) - elif slug == "bookings": - bookings = await services.calendar.user_bookings(g.s, user_id=user_id) - return render_fragment_account_bookings(bookings) - return "" diff --git a/events/sx/handlers/account-page.sx b/events/sx/handlers/account-page.sx new file mode 100644 index 0000000..925eb8c --- /dev/null +++ b/events/sx/handlers/account-page.sx @@ -0,0 +1,49 @@ +;; Account-page fragment handler +;; +;; Renders tickets or bookings panel for the account dashboard. +;; slug=tickets → ticket list; slug=bookings → booking list. + +(defhandler account-page (&key slug user_id) + (let ((uid (parse-int (or user_id "0")))) + (when (> uid 0) + (cond + (= slug "tickets") + (let ((tickets (service "calendar" "user-tickets" :user-id uid))) + (~events-frag-tickets-panel + :items (if (empty? tickets) + (~empty-state :message "No tickets yet." + :cls "text-sm text-stone-500") + (~events-frag-tickets-list + :items (<> (map (fn (t) + (~events-frag-ticket-item + :href (app-url "events" + (str "/tickets/" (get t "code") "/")) + :entry-name (get t "entry_name") + :date-str (format-date (get t "entry_start_at") "%d %b %Y, %H:%M") + :calendar-name (when (get t "calendar_name") + (span (str "\u00b7 " (get t "calendar_name")))) + :type-name (when (get t "ticket_type_name") + (span (str "\u00b7 " (get t "ticket_type_name")))) + :badge (~status-pill :status (or (get t "state") "")))) + tickets)))))) + + (= slug "bookings") + (let ((bookings (service "calendar" "user-bookings" :user-id uid))) + (~events-frag-bookings-panel + :items (if (empty? bookings) + (~empty-state :message "No bookings yet." + :cls "text-sm text-stone-500") + (~events-frag-bookings-list + :items (<> (map (fn (b) + (~events-frag-booking-item + :name (get b "name") + :date-str (str (format-date (get b "start_at") "%d %b %Y, %H:%M") + (if (get b "end_at") + (str " \u2013 " (format-date (get b "end_at") "%H:%M")) + "")) + :calendar-name (when (get b "calendar_name") + (span (str "\u00b7 " (get b "calendar_name")))) + :cost-str (when (get b "cost") + (span (str "\u00b7 \u00a3" (get b "cost")))) + :badge (~status-pill :status (or (get b "state") "")))) + bookings)))))))))) diff --git a/events/sx/handlers/container-cards.sx b/events/sx/handlers/container-cards.sx new file mode 100644 index 0000000..193f23a --- /dev/null +++ b/events/sx/handlers/container-cards.sx @@ -0,0 +1,38 @@ +;; Container-cards fragment handler +;; +;; Returns HTML with comment markers so the +;; blog consumer can split per-post fragments. Each post section +;; contains an events-frag-entries-widget with entry cards. + +(defhandler container-cards (&key post_ids post_slugs) + (let ((ids (filter (fn (x) (> x 0)) + (map parse-int + (filter (fn (s) (not (empty? s))) + (split (or post_ids "") ","))))) + (slugs (map trim + (split (or post_slugs "") ",")))) + (when (not (empty? ids)) + (let ((batch (service "calendar" "confirmed-entries-for-posts" :post-ids ids))) + (<> (map-indexed (fn (i pid) + (let ((entries (or (get batch pid) (list))) + (post-slug (or (nth slugs i) ""))) + (<> (str "") + (when (not (empty? entries)) + (~events-frag-entries-widget + :cards (<> (map (fn (e) + (let ((time-str (str (format-date (get e "start_at") "%H:%M") + (if (get e "end_at") + (str " \u2013 " (format-date (get e "end_at") "%H:%M")) + "")))) + (~events-frag-entry-card + :href (app-url "events" + (str "/" post-slug + "/" (get e "calendar_slug") + "/" (get e "start_at_year") + "/" (get e "start_at_month") + "/" (get e "start_at_day") + "/entries/" (get e "id") "/")) + :name (get e "name") + :date-str (format-date (get e "start_at") "%a, %b %d") + :time-str time-str))) entries)))) + (str "")))) ids)))))) diff --git a/shared/sx/primitives_io.py b/shared/sx/primitives_io.py index ae17a28..c00565c 100644 --- a/shared/sx/primitives_io.py +++ b/shared/sx/primitives_io.py @@ -242,6 +242,8 @@ def _convert_result(result: Any) -> Any: if result is None: from .types import NIL return NIL + if isinstance(result, dict): + return {k: _convert_result(v) for k, v in result.items()} if isinstance(result, tuple): # Tuple returns (e.g. (entries, has_more)) → list for sx access return [_convert_result(item) for item in result] From 03f0929fdf3af30d285ae4458eeb57b062201402 Mon Sep 17 00:00:00 2001 From: giles Date: Tue, 3 Mar 2026 20:37:17 +0000 Subject: [PATCH 006/105] Fix SX nav morphing, retry error modal, and aria-selected CSS extraction - Re-read verb URL from element attributes at execution time so morphed nav links navigate to the correct destination - Reset retry backoff on fresh requests; skip error modal when sx-retry handles the failure - Strip attribute selectors in CSS registry so aria-selected:* classes resolve correctly for on-demand CSS - Add @css annotations for dynamic aria-selected variant classes - Add SX docs integration test suite (102 tests) Co-Authored-By: Claude Opus 4.6 --- shared/static/scripts/body.js | 4 +- shared/static/scripts/sx.js | 8 + shared/sx/css_registry.py | 2 + shared/sx/templates/layout.sx | 1 + shared/tests/test_sx_app_pages.py | 443 ++++++++++++++++++++++++++++++ 5 files changed, 457 insertions(+), 1 deletion(-) create mode 100644 shared/tests/test_sx_app_pages.py diff --git a/shared/static/scripts/body.js b/shared/static/scripts/body.js index 3db0826..b70debb 100644 --- a/shared/static/scripts/body.js +++ b/shared/static/scripts/body.js @@ -542,8 +542,10 @@ document.addEventListener('DOMContentLoaded', () => { document.body.addEventListener("sx:responseError", function (event) { var resp = event.detail.response; if (!resp) return; - var status = resp.status || 0; + // Don't show error modal when sx-retry will handle the failure var triggerEl = event.target; + if (triggerEl && triggerEl.getAttribute("sx-retry")) return; + var status = resp.status || 0; var form = triggerEl ? triggerEl.closest("form") : null; var title = "Something went wrong"; diff --git a/shared/static/scripts/sx.js b/shared/static/scripts/sx.js index 136a3d0..c6769e7 100644 --- a/shared/static/scripts/sx.js +++ b/shared/static/scripts/sx.js @@ -1685,9 +1685,17 @@ // ---- Request executor ------------------------------------------------- function executeRequest(el, verbInfo, extraParams) { + // Re-read verb from element in case attributes were morphed since binding + var currentVerb = getVerb(el); + if (currentVerb) verbInfo = currentVerb; var method = verbInfo.method; var url = verbInfo.url; + // Reset retry backoff on fresh (non-retry) requests + if (!el.classList.contains("sx-error")) { + el.removeAttribute("data-sx-retry-ms"); + } + // sx-media: skip if media query doesn't match var media = el.getAttribute("sx-media"); if (media && !window.matchMedia(media).matches) return Promise.resolve(); diff --git a/shared/sx/css_registry.py b/shared/sx/css_registry.py index 114f95e..c6a2a2c 100644 --- a/shared/sx/css_registry.py +++ b/shared/sx/css_registry.py @@ -252,6 +252,8 @@ def _css_selector_to_class(selector: str) -> str: i += 2 elif name[i] == ':': break # pseudo-class — stop here + elif name[i] == '[': + break # attribute selector — stop here else: result.append(name[i]) i += 1 diff --git a/shared/sx/templates/layout.sx b/shared/sx/templates/layout.sx index 507fd53..ba7571e 100644 --- a/shared/sx/templates/layout.sx +++ b/shared/sx/templates/layout.sx @@ -83,6 +83,7 @@ (when auth-menu auth-menu)))) ; @css bg-sky-400 bg-sky-300 bg-sky-200 bg-sky-100 bg-violet-400 bg-violet-300 bg-violet-200 bg-violet-100 +; @css aria-selected:bg-violet-200 aria-selected:text-violet-900 aria-selected:bg-stone-500 aria-selected:text-white (defcomp ~menu-row-sx (&key id level colour link-href link-label link-label-content icon selected hx-select nav child-id child oob external) (let* ((c (or colour "sky")) diff --git a/shared/tests/test_sx_app_pages.py b/shared/tests/test_sx_app_pages.py new file mode 100644 index 0000000..4830dcf --- /dev/null +++ b/shared/tests/test_sx_app_pages.py @@ -0,0 +1,443 @@ +"""Integration tests for SX docs app — page rendering + interactive API endpoints. + +Runs inside the test container, hitting the sx_docs service over the internal +network. Uses ``SX-Request: true`` header to bypass the silent-SSO OAuth +redirect on page requests. + +Tested: + - All 27 example pages render with 200 and contain meaningful content + - All 23 attribute detail pages render and mention the attribute name + - All 35+ interactive API endpoints return 200 with expected content +""" +from __future__ import annotations + +import os +import re + +import httpx +import pytest + +SX_BASE = os.environ.get("INTERNAL_URL_SX", "http://sx_docs:8000") +HEADERS = {"SX-Request": "true"} +TIMEOUT = 15.0 + + +def _get(path: str, **kw) -> httpx.Response: + return httpx.get( + f"{SX_BASE}{path}", + headers=HEADERS, + timeout=TIMEOUT, + follow_redirects=True, + **kw, + ) + + +def _post(path: str, **kw) -> httpx.Response: + return httpx.post( + f"{SX_BASE}{path}", + headers=HEADERS, + timeout=TIMEOUT, + follow_redirects=True, + **kw, + ) + + +def _put(path: str, **kw) -> httpx.Response: + return httpx.put( + f"{SX_BASE}{path}", + headers=HEADERS, + timeout=TIMEOUT, + follow_redirects=True, + **kw, + ) + + +def _patch(path: str, **kw) -> httpx.Response: + return httpx.patch( + f"{SX_BASE}{path}", + headers=HEADERS, + timeout=TIMEOUT, + follow_redirects=True, + **kw, + ) + + +def _delete(path: str, **kw) -> httpx.Response: + return httpx.delete( + f"{SX_BASE}{path}", + headers=HEADERS, + timeout=TIMEOUT, + follow_redirects=True, + **kw, + ) + + +# ── Check that the sx_docs service is reachable ────────────────────────── + +def _sx_reachable() -> bool: + try: + r = httpx.get(f"{SX_BASE}/", timeout=5, follow_redirects=False) + return r.status_code in (200, 302) + except Exception: + return False + + +pytestmark = pytest.mark.skipif( + not _sx_reachable(), + reason=f"sx_docs service not reachable at {SX_BASE}", +) + + +# ═════════════════════════════════════════════════════════════════════════ +# Example pages — rendering +# ═════════════════════════════════════════════════════════════════════════ + +EXAMPLES = [ + "click-to-load", + "form-submission", + "polling", + "delete-row", + "inline-edit", + "oob-swaps", + "lazy-loading", + "infinite-scroll", + "progress-bar", + "active-search", + "inline-validation", + "value-select", + "reset-on-submit", + "edit-row", + "bulk-update", + "swap-positions", + "select-filter", + "tabs", + "animations", + "dialogs", + "keyboard-shortcuts", + "put-patch", + "json-encoding", + "vals-and-headers", + "loading-states", + "sync-replace", + "retry", +] + + +@pytest.mark.parametrize("slug", EXAMPLES) +def test_example_page_renders(slug: str): + """Each example page must render successfully on two consecutive loads.""" + for attempt in (1, 2): + r = _get(f"/examples/{slug}") + assert r.status_code == 200, ( + f"/examples/{slug} returned {r.status_code} on attempt {attempt}" + ) + assert len(r.text) > 500, ( + f"/examples/{slug} response too short ({len(r.text)} bytes) on attempt {attempt}" + ) + # Every example page should have a demo section + assert "demo" in r.text.lower() or "example" in r.text.lower(), ( + f"/examples/{slug} missing demo/example content" + ) + + +# ═════════════════════════════════════════════════════════════════════════ +# Attribute detail pages — rendering +# ═════════════════════════════════════════════════════════════════════════ + +ATTRIBUTES = [ + "sx-get", + "sx-post", + "sx-put", + "sx-delete", + "sx-patch", + "sx-trigger", + "sx-target", + "sx-swap", + "sx-swap-oob", + "sx-select", + "sx-confirm", + "sx-push-url", + "sx-sync", + "sx-encoding", + "sx-headers", + "sx-include", + "sx-vals", + "sx-media", + "sx-disable", + "sx-on", # URL slug for sx-on:* + "sx-retry", + "data-sx", + "data-sx-env", +] + + +@pytest.mark.parametrize("slug", ATTRIBUTES) +def test_attribute_page_renders(slug: str): + """Each attribute page must render successfully on two consecutive loads.""" + for attempt in (1, 2): + r = _get(f"/reference/attributes/{slug}") + assert r.status_code == 200, ( + f"/reference/attributes/{slug} returned {r.status_code} on attempt {attempt}" + ) + assert len(r.text) > 500, ( + f"/reference/attributes/{slug} response too short on attempt {attempt}" + ) + # The attribute name (or a prefix of it) should appear somewhere + check = slug.rstrip("*").rstrip(":") + assert check.lower() in r.text.lower(), ( + f"/reference/attributes/{slug} does not mention '{check}'" + ) + + +# ═════════════════════════════════════════════════════════════════════════ +# Example API endpoints — interactive demos +# ═════════════════════════════════════════════════════════════════════════ + +class TestExampleAPIs: + """Test the interactive demo API endpoints.""" + + def test_click_to_load(self): + r = _get("/examples/api/click") + assert r.status_code == 200 + + def test_form_submission(self): + r = _post("/examples/api/form", data={"name": "Alice"}) + assert r.status_code == 200 + assert "Alice" in r.text + + def test_polling(self): + r = _get("/examples/api/poll") + assert r.status_code == 200 + + def test_delete_row(self): + r = _delete("/examples/api/delete/1") + assert r.status_code == 200 + + def test_inline_edit_get(self): + r = _get("/examples/api/edit") + assert r.status_code == 200 + + def test_inline_edit_post(self): + r = _post("/examples/api/edit", data={"name": "New Name"}) + assert r.status_code == 200 + + def test_inline_edit_cancel(self): + r = _get("/examples/api/edit/cancel") + assert r.status_code == 200 + + def test_oob_swap(self): + r = _get("/examples/api/oob") + assert r.status_code == 200 + + def test_lazy_loading(self): + r = _get("/examples/api/lazy") + assert r.status_code == 200 + + def test_infinite_scroll(self): + r = _get("/examples/api/scroll", params={"page": "1"}) + assert r.status_code == 200 + + def test_progress_start(self): + r = _post("/examples/api/progress/start") + assert r.status_code == 200 + + def test_progress_status(self): + r = _get("/examples/api/progress/status") + assert r.status_code == 200 + + def test_active_search(self): + r = _get("/examples/api/search", params={"q": "py"}) + assert r.status_code == 200 + assert "Python" in r.text + + def test_inline_validation(self): + r = _get("/examples/api/validate", params={"email": "test@example.com"}) + assert r.status_code == 200 + + def test_validation_submit(self): + r = _post("/examples/api/validate/submit", data={"email": "test@example.com"}) + assert r.status_code == 200 + + def test_value_select(self): + r = _get("/examples/api/values", params={"category": "Languages"}) + assert r.status_code == 200 + + def test_reset_on_submit(self): + r = _post("/examples/api/reset-submit", data={"message": "hello"}) + assert r.status_code == 200 + + def test_edit_row_get(self): + r = _get("/examples/api/editrow/1") + assert r.status_code == 200 + + def test_edit_row_post(self): + r = _post("/examples/api/editrow/1", data={"name": "X", "price": "10", "stock": "5"}) + assert r.status_code == 200 + + def test_edit_row_cancel(self): + r = _get("/examples/api/editrow/1/cancel") + assert r.status_code == 200 + + def test_bulk_update(self): + r = _post("/examples/api/bulk", data={"ids": ["1", "2"], "status": "active"}) + assert r.status_code == 200 + + def test_swap_positions(self): + r = _post("/examples/api/swap-log") + assert r.status_code == 200 + + def test_dashboard_filter(self): + r = _get("/examples/api/dashboard", params={"region": "all"}) + assert r.status_code == 200 + + def test_tabs(self): + r = _get("/examples/api/tabs/overview") + assert r.status_code == 200 + + def test_animate(self): + r = _get("/examples/api/animate") + assert r.status_code == 200 + + def test_dialog_open(self): + r = _get("/examples/api/dialog") + assert r.status_code == 200 + + def test_dialog_close(self): + r = _get("/examples/api/dialog/close") + assert r.status_code == 200 + + def test_keyboard(self): + r = _get("/examples/api/keyboard") + assert r.status_code == 200 + + def test_put_patch_edit(self): + r = _get("/examples/api/putpatch/edit-all") + assert r.status_code == 200 + + def test_put_request(self): + r = _put("/examples/api/putpatch", data={"name": "X", "email": "x@x.com", "role": "Dev"}) + assert r.status_code == 200 + + def test_put_patch_cancel(self): + r = _get("/examples/api/putpatch/cancel") + assert r.status_code == 200 + + def test_json_encoding(self): + r = httpx.post( + f"{SX_BASE}/examples/api/json-echo", + content='{"key":"val"}', + headers={**HEADERS, "Content-Type": "application/json"}, + timeout=TIMEOUT, + ) + assert r.status_code == 200 + + def test_echo_vals(self): + r = _get("/examples/api/echo-vals", params={"source": "test"}) + assert r.status_code == 200 + + def test_echo_headers(self): + r = httpx.get( + f"{SX_BASE}/examples/api/echo-headers", + headers={**HEADERS, "X-Custom": "hello"}, + timeout=TIMEOUT, + ) + assert r.status_code == 200 + + def test_slow_endpoint(self): + r = _get("/examples/api/slow") + assert r.status_code == 200 + + def test_slow_search(self): + r = _get("/examples/api/slow-search", params={"q": "test"}) + assert r.status_code == 200 + + def test_flaky_endpoint(self): + # May fail 2/3 times — just check it returns *something* + r = _get("/examples/api/flaky") + assert r.status_code in (200, 503) + + +# ═════════════════════════════════════════════════════════════════════════ +# Reference API endpoints — attribute demos +# ═════════════════════════════════════════════════════════════════════════ + +class TestReferenceAPIs: + """Test the reference attribute demo API endpoints.""" + + def test_time(self): + r = _get("/reference/api/time") + assert r.status_code == 200 + # Should contain a time string (HH:MM:SS pattern) + assert re.search(r"\d{2}:\d{2}:\d{2}", r.text), "No time found in response" + + def test_greet(self): + r = _post("/reference/api/greet", data={"name": "Bob"}) + assert r.status_code == 200 + assert "Bob" in r.text + + def test_status_put(self): + r = _put("/reference/api/status", data={"status": "published"}) + assert r.status_code == 200 + assert "published" in r.text.lower() + + def test_theme_patch(self): + r = _patch("/reference/api/theme", data={"theme": "dark"}) + assert r.status_code == 200 + assert "dark" in r.text.lower() + + def test_delete_item(self): + r = _delete("/reference/api/item/42") + assert r.status_code == 200 + + def test_trigger_search(self): + r = _get("/reference/api/trigger-search", params={"q": "hello"}) + assert r.status_code == 200 + assert "hello" in r.text.lower() + + def test_swap_item(self): + r = _get("/reference/api/swap-item") + assert r.status_code == 200 + + def test_oob(self): + r = _get("/reference/api/oob") + assert r.status_code == 200 + # OOB response should contain sx-swap-oob attribute + assert "oob" in r.text.lower() + + def test_select_page(self): + r = _get("/reference/api/select-page") + assert r.status_code == 200 + assert "the-content" in r.text + + def test_slow_echo(self): + r = _get("/reference/api/slow-echo", params={"q": "sync"}) + assert r.status_code == 200 + assert "sync" in r.text.lower() + + def test_upload_name(self): + r = _post( + "/reference/api/upload-name", + files={"file": ("test.txt", b"hello", "text/plain")}, + ) + assert r.status_code == 200 + assert "test.txt" in r.text + + def test_echo_headers(self): + r = httpx.get( + f"{SX_BASE}/reference/api/echo-headers", + headers={**HEADERS, "X-Custom-Token": "abc123"}, + timeout=TIMEOUT, + ) + assert r.status_code == 200 + + def test_echo_vals_get(self): + r = _get("/reference/api/echo-vals", params={"category": "books"}) + assert r.status_code == 200 + + def test_echo_vals_post(self): + r = _post("/reference/api/echo-vals", data={"source": "demo", "page": "3"}) + assert r.status_code == 200 + + def test_flaky(self): + r = _get("/reference/api/flaky") + assert r.status_code in (200, 503) From 193578ef8882b6fdd4e155034d468bd3ed9bbd20 Mon Sep 17 00:00:00 2001 From: giles Date: Tue, 3 Mar 2026 22:36:34 +0000 Subject: [PATCH 007/105] Move SX construction from Python to .sx defcomps (phases 0-4) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Eliminate Python s-expression string building across account, orders, federation, and cart services. Visual rendering logic now lives entirely in .sx defcomp components; Python files contain only data serialization, header/layout wiring, and thin wrappers that call defcomps. Phase 0: Shared DRY extraction — auth/orders header defcomps, format-decimal/ pluralize/escape/route-prefix primitives. Phase 1: Account — dashboard, newsletters, login/device/check-email content. Phase 2: Orders — order list, detail, filter, checkout return assembled defcomps. Phase 3: Federation — social nav, post cards, timeline, search, actors, notifications, compose, profile assembled defcomps. Phase 4: Cart — overview, page cart items/calendar/tickets/summary, admin, payments assembled defcomps; orders rendering reuses Phase 2 shared defcomps. Co-Authored-By: Claude Opus 4.6 --- account/sx/auth.sx | 22 + account/sx/dashboard.sx | 17 + account/sx/newsletters.sx | 31 ++ account/sx/sx_components.py | 320 +++--------- account/sxc/pages/__init__.py | 70 ++- account/sxc/pages/account.sx | 2 +- cart/sx/items.sx | 111 +++++ cart/sx/overview.sx | 53 ++ cart/sx/payments.sx | 24 + cart/sx/sx_components.py | 693 ++++++-------------------- cart/sxc/pages/__init__.py | 208 +++++++- federation/sx/notifications.sx | 47 ++ federation/sx/profile.sx | 37 ++ federation/sx/search.sx | 96 ++++ federation/sx/social.sx | 126 +++++ federation/sx/sx_components.py | 803 ++++++++----------------------- federation/sxc/pages/__init__.py | 148 +++++- orders/bp/orders/routes.py | 36 +- orders/sx/sx_components.py | 401 +++++---------- orders/sxc/pages/__init__.py | 142 +++++- shared/sx/primitives.py | 40 ++ shared/sx/templates/auth.sx | 43 +- shared/sx/templates/orders.sx | 149 ++++++ 23 files changed, 1824 insertions(+), 1795 deletions(-) diff --git a/account/sx/auth.sx b/account/sx/auth.sx index 672b5db..c357397 100644 --- a/account/sx/auth.sx +++ b/account/sx/auth.sx @@ -27,3 +27,25 @@ (h1 :class "text-2xl font-bold mb-4" "Device authorized") (p :class "text-stone-600" "You can close this window and return to your terminal."))) +;; Assembled auth page content — replaces Python _login_page_content etc. + +(defcomp ~account-login-content (&key error email) + (~auth-login-form + :error (when error (~auth-error-banner :error error)) + :action (url-for "auth.start_login") + :csrf-token (csrf-token) + :email (or email ""))) + +(defcomp ~account-device-content (&key error code) + (~account-device-form + :error (when error (~account-device-error :error error)) + :action (url-for "auth.device_submit") + :csrf-token (csrf-token) + :code (or code ""))) + +(defcomp ~account-check-email-content (&key email email-error) + (~auth-check-email + :email (escape (or email "")) + :error (when email-error + (~auth-check-email-error :error (escape email-error))))) + diff --git a/account/sx/dashboard.sx b/account/sx/dashboard.sx index e666551..479e1b0 100644 --- a/account/sx/dashboard.sx +++ b/account/sx/dashboard.sx @@ -41,3 +41,20 @@ name) logout) labels))) + +;; Assembled dashboard content — replaces Python _account_main_panel_sx +(defcomp ~account-dashboard-content (&key error) + (let* ((user (current-user)) + (csrf (csrf-token))) + (~account-main-panel + :error (when error (~account-error-banner :error error)) + :email (when (get user "email") + (~account-user-email :email (get user "email"))) + :name (when (get user "name") + (~account-user-name :name (get user "name"))) + :logout (~account-logout-form :csrf-token csrf) + :labels (when (not (empty? (or (get user "labels") (list)))) + (~account-labels-section + :items (map (lambda (label) + (~account-label-item :name (get label "name"))) + (get user "labels"))))))) diff --git a/account/sx/newsletters.sx b/account/sx/newsletters.sx index ab4e518..4b0626b 100644 --- a/account/sx/newsletters.sx +++ b/account/sx/newsletters.sx @@ -29,3 +29,34 @@ (div :class "bg-white/70 backdrop-blur rounded-2xl shadow border border-stone-200 p-6 sm:p-8 space-y-6" (h1 :class "text-xl font-semibold tracking-tight" "Newsletters") list))) + +;; Assembled newsletters content — replaces Python _newsletters_panel_sx +;; Takes pre-fetched newsletter-list from page helper +(defcomp ~account-newsletters-content (&key newsletter-list account-url) + (let* ((csrf (csrf-token))) + (if (empty? newsletter-list) + (~account-newsletter-empty) + (~account-newsletters-panel + :list (~account-newsletter-list + :items (map (lambda (item) + (let* ((nl (get item "newsletter")) + (un (get item "un")) + (nid (get nl "id")) + (subscribed (get item "subscribed")) + (toggle-url (str (or account-url "") "/newsletter/" nid "/toggle/")) + (bg (if subscribed "bg-emerald-500" "bg-stone-300")) + (translate (if subscribed "translate-x-6" "translate-x-1")) + (checked (if subscribed "true" "false"))) + (~account-newsletter-item + :name (get nl "name") + :desc (when (get nl "description") + (~account-newsletter-desc :description (get nl "description"))) + :toggle (~account-newsletter-toggle + :id (str "nl-" nid) + :url toggle-url + :hdrs (str "{\"X-CSRFToken\": \"" csrf "\"}") + :target (str "#nl-" nid) + :cls (str "relative inline-flex h-6 w-11 items-center rounded-full transition-colors focus:outline-none focus:ring-2 focus:ring-emerald-500 focus:ring-offset-2 " bg) + :checked checked + :knob-cls (str "inline-block h-4 w-4 rounded-full bg-white shadow transform transition-transform " translate))))) + newsletter-list)))))) diff --git a/account/sx/sx_components.py b/account/sx/sx_components.py index 75eb232..a094a5e 100644 --- a/account/sx/sx_components.py +++ b/account/sx/sx_components.py @@ -1,8 +1,8 @@ """ Account service s-expression page components. -Renders account dashboard, newsletters, fragment pages, login, and device -auth pages. Called from route handlers in place of ``render_template()``. +Renders login, device auth, and check-email pages. Dashboard and newsletters +are now fully handled by .sx defcomps called from defpage expressions. """ from __future__ import annotations @@ -11,7 +11,7 @@ from typing import Any from shared.sx.jinja_bridge import load_service_components from shared.sx.helpers import ( - call_url, sx_call, SxExpr, + sx_call, SxExpr, root_header_sx, full_page_sx, ) @@ -21,101 +21,70 @@ load_service_components(os.path.dirname(os.path.dirname(__file__)), # --------------------------------------------------------------------------- -# Header helpers +# Public API: Auth pages (login, device, check_email) # --------------------------------------------------------------------------- -def _auth_nav_sx(ctx: dict) -> str: - """Auth section desktop nav items.""" - parts = [ - sx_call("nav-link", - href=call_url(ctx, "account_url", "/newsletters/"), - label="newsletters", - select_colours=ctx.get("select_colours", ""), - ) - ] - account_nav = ctx.get("account_nav") - if account_nav: - parts.append(account_nav) - return "(<> " + " ".join(parts) + ")" +async def render_login_page(ctx: dict) -> str: + """Full page: login form.""" + error = ctx.get("error", "") + email = ctx.get("email", "") + hdr = root_header_sx(ctx) + content = sx_call("account-login-content", error=error or None, email=email) + return full_page_sx(ctx, header_rows=hdr, + content=content, + meta_html='Login \u2014 Rose Ash') -def _auth_header_sx(ctx: dict, *, oob: bool = False) -> str: - """Build the account section header row.""" - return sx_call( - "menu-row-sx", - id="auth-row", level=1, colour="sky", - link_href=call_url(ctx, "account_url", "/"), - link_label="account", icon="fa-solid fa-user", - nav=SxExpr(_auth_nav_sx(ctx)), - child_id="auth-header-child", oob=oob, - ) +async def render_device_page(ctx: dict) -> str: + """Full page: device authorization form.""" + error = ctx.get("error", "") + code = ctx.get("code", "") + hdr = root_header_sx(ctx) + content = sx_call("account-device-content", error=error or None, code=code) + return full_page_sx(ctx, header_rows=hdr, + content=content, + meta_html='Authorize Device \u2014 Rose Ash') -def _auth_nav_mobile_sx(ctx: dict) -> str: - """Mobile nav menu for auth section.""" - parts = [ - sx_call("nav-link", - href=call_url(ctx, "account_url", "/newsletters/"), - label="newsletters", - select_colours=ctx.get("select_colours", ""), - ) - ] - account_nav = ctx.get("account_nav") - if account_nav: - parts.append(account_nav) - return "(<> " + " ".join(parts) + ")" +async def render_device_approved_page(ctx: dict) -> str: + """Full page: device approved.""" + hdr = root_header_sx(ctx) + content = sx_call("account-device-approved") + return full_page_sx(ctx, header_rows=hdr, + content=content, + meta_html='Device Authorized \u2014 Rose Ash') + + +async def render_check_email_page(ctx: dict) -> str: + """Full page: check email after magic link sent.""" + email = ctx.get("email", "") + email_error = ctx.get("email_error") + hdr = root_header_sx(ctx) + content = sx_call("account-check-email-content", + email=email, email_error=email_error) + return full_page_sx(ctx, header_rows=hdr, + content=content, + meta_html='Check your email \u2014 Rose Ash') # --------------------------------------------------------------------------- -# Account dashboard (GET /) +# Public API: Fragment renderers for POST handlers # --------------------------------------------------------------------------- -def _account_main_panel_sx(ctx: dict) -> str: - """Account info panel with user details and logout.""" - from quart import g +def render_newsletter_toggle(un) -> str: + """Render a newsletter toggle switch for POST response.""" from shared.browser.app.csrf import generate_csrf_token - user = getattr(g, "user", None) - error = ctx.get("error", "") - - error_sx = sx_call("account-error-banner", error=error) if error else "" - - user_email_sx = "" - user_name_sx = "" - if user: - user_email_sx = sx_call("account-user-email", email=user.email) - if user.name: - user_name_sx = sx_call("account-user-name", name=user.name) - - logout_sx = sx_call("account-logout-form", csrf_token=generate_csrf_token()) - - labels_sx = "" - if user and hasattr(user, "labels") and user.labels: - label_items = " ".join( - sx_call("account-label-item", name=label.name) - for label in user.labels - ) - labels_sx = sx_call("account-labels-section", - items=SxExpr("(<> " + label_items + ")")) - - return sx_call( - "account-main-panel", - error=SxExpr(error_sx) if error_sx else None, - email=SxExpr(user_email_sx) if user_email_sx else None, - name=SxExpr(user_name_sx) if user_name_sx else None, - logout=SxExpr(logout_sx), - labels=SxExpr(labels_sx) if labels_sx else None, - ) - - -# --------------------------------------------------------------------------- -# Newsletters (GET /newsletters/) -# --------------------------------------------------------------------------- - -def _newsletter_toggle_sx(un: Any, account_url_fn: Any, csrf_token: str) -> str: - """Render a single newsletter toggle switch.""" nid = un.newsletter_id + from quart import g + account_url_fn = getattr(g, "_account_url", None) + if account_url_fn is None: + from shared.infrastructure.urls import account_url + account_url_fn = account_url + toggle_url = account_url_fn(f"/newsletter/{nid}/toggle/") + csrf = generate_csrf_token() + if un.subscribed: bg = "bg-emerald-500" translate = "translate-x-6" @@ -124,10 +93,11 @@ def _newsletter_toggle_sx(un: Any, account_url_fn: Any, csrf_token: str) -> str: bg = "bg-stone-300" translate = "translate-x-1" checked = "false" + return sx_call( "account-newsletter-toggle", id=f"nl-{nid}", url=toggle_url, - hdrs=f'{{"X-CSRFToken": "{csrf_token}"}}', + hdrs=f'{{"X-CSRFToken": "{csrf}"}}', target=f"#nl-{nid}", cls=f"relative inline-flex h-6 w-11 items-center rounded-full transition-colors focus:outline-none focus:ring-2 focus:ring-emerald-500 focus:ring-offset-2 {bg}", checked=checked, @@ -135,113 +105,10 @@ def _newsletter_toggle_sx(un: Any, account_url_fn: Any, csrf_token: str) -> str: ) -def _newsletter_toggle_off_sx(nid: int, toggle_url: str, csrf_token: str) -> str: - """Render an unsubscribed newsletter toggle (no subscription record yet).""" - return sx_call( - "account-newsletter-toggle", - id=f"nl-{nid}", url=toggle_url, - hdrs=f'{{"X-CSRFToken": "{csrf_token}"}}', - target=f"#nl-{nid}", - cls="relative inline-flex h-6 w-11 items-center rounded-full transition-colors focus:outline-none focus:ring-2 focus:ring-emerald-500 focus:ring-offset-2 bg-stone-300", - checked="false", - knob_cls="inline-block h-4 w-4 rounded-full bg-white shadow transform transition-transform translate-x-1", - ) - - -def _newsletters_panel_sx(ctx: dict, newsletter_list: list) -> str: - """Newsletters management panel.""" - from shared.browser.app.csrf import generate_csrf_token - - account_url_fn = ctx.get("account_url") or (lambda p: p) - csrf = generate_csrf_token() - - if newsletter_list: - items = [] - for item in newsletter_list: - nl = item["newsletter"] - un = item.get("un") - - desc_sx = sx_call( - "account-newsletter-desc", description=nl.description - ) if nl.description else "" - - if un: - toggle = _newsletter_toggle_sx(un, account_url_fn, csrf) - else: - toggle_url = account_url_fn(f"/newsletter/{nl.id}/toggle/") - toggle = _newsletter_toggle_off_sx(nl.id, toggle_url, csrf) - - items.append(sx_call( - "account-newsletter-item", - name=nl.name, - desc=SxExpr(desc_sx) if desc_sx else None, - toggle=SxExpr(toggle), - )) - list_sx = sx_call( - "account-newsletter-list", - items=SxExpr("(<> " + " ".join(items) + ")"), - ) - else: - list_sx = sx_call("account-newsletter-empty") - - return sx_call("account-newsletters-panel", list=SxExpr(list_sx)) - - # --------------------------------------------------------------------------- -# Auth pages (login, device, check_email) +# Internal helpers # --------------------------------------------------------------------------- -def _login_page_content(ctx: dict) -> str: - """Login form content.""" - from shared.browser.app.csrf import generate_csrf_token - from quart import url_for - - error = ctx.get("error", "") - email = ctx.get("email", "") - action = url_for("auth.start_login") - - error_sx = sx_call("auth-error-banner", error=error) if error else "" - - return sx_call( - "auth-login-form", - error=SxExpr(error_sx) if error_sx else None, - action=action, - csrf_token=generate_csrf_token(), email=email, - ) - - -def _device_page_content(ctx: dict) -> str: - """Device authorization form content.""" - from shared.browser.app.csrf import generate_csrf_token - from quart import url_for - - error = ctx.get("error", "") - code = ctx.get("code", "") - action = url_for("auth.device_submit") - - error_sx = sx_call("account-device-error", error=error) if error else "" - - return sx_call( - "account-device-form", - error=SxExpr(error_sx) if error_sx else None, - action=action, - csrf_token=generate_csrf_token(), code=code, - ) - - -def _device_approved_content() -> str: - """Device approved success content.""" - return sx_call("account-device-approved") - - -# --------------------------------------------------------------------------- -# Public API: Account dashboard -# --------------------------------------------------------------------------- - - - - - def _fragment_content(frag: object) -> str: """Convert a fragment response to sx content string. @@ -257,83 +124,6 @@ def _fragment_content(frag: object) -> str: return f'(~rich-text :html "{_sx_escape(s)}")' -# --------------------------------------------------------------------------- -# Public API: Auth pages (login, device) -# --------------------------------------------------------------------------- - -async def render_login_page(ctx: dict) -> str: - """Full page: login form.""" - hdr = root_header_sx(ctx) - return full_page_sx(ctx, header_rows=hdr, - content=_login_page_content(ctx), - meta_html='Login \u2014 Rose Ash') - - -async def render_device_page(ctx: dict) -> str: - """Full page: device authorization form.""" - hdr = root_header_sx(ctx) - return full_page_sx(ctx, header_rows=hdr, - content=_device_page_content(ctx), - meta_html='Authorize Device \u2014 Rose Ash') - - -async def render_device_approved_page(ctx: dict) -> str: - """Full page: device approved.""" - hdr = root_header_sx(ctx) - return full_page_sx(ctx, header_rows=hdr, - content=_device_approved_content(), - meta_html='Device Authorized \u2014 Rose Ash') - - -# --------------------------------------------------------------------------- -# Public API: Check email page (POST /start/ success) -# --------------------------------------------------------------------------- - -def _check_email_content(email: str, email_error: str | None = None) -> str: - """Check email confirmation content.""" - from markupsafe import escape - - error_sx = sx_call( - "auth-check-email-error", error=str(escape(email_error)) - ) if email_error else "" - - return sx_call( - "auth-check-email", - email=str(escape(email)), - error=SxExpr(error_sx) if error_sx else None, - ) - - -async def render_check_email_page(ctx: dict) -> str: - """Full page: check email after magic link sent.""" - email = ctx.get("email", "") - email_error = ctx.get("email_error") - hdr = root_header_sx(ctx) - return full_page_sx(ctx, header_rows=hdr, - content=_check_email_content(email, email_error), - meta_html='Check your email \u2014 Rose Ash') - - -# --------------------------------------------------------------------------- -# Public API: Fragment renderers for POST handlers -# --------------------------------------------------------------------------- - - -def render_newsletter_toggle(un) -> str: - """Render a newsletter toggle switch for POST response (uses account_url).""" - from shared.browser.app.csrf import generate_csrf_token - from quart import g - account_url_fn = getattr(g, "_account_url", None) - if account_url_fn is None: - from shared.infrastructure.urls import account_url - account_url_fn = account_url - return _newsletter_toggle_sx(un, account_url_fn, generate_csrf_token()) - - -# --------------------------------------------------------------------------- -# Internal helpers -# --------------------------------------------------------------------------- - def _sx_escape(s: str) -> str: """Escape a string for embedding in sx string literals.""" return s.replace("\\", "\\\\").replace('"', '\\"') diff --git a/account/sxc/pages/__init__.py b/account/sxc/pages/__init__.py index 269ad2d..12aebc4 100644 --- a/account/sxc/pages/__init__.py +++ b/account/sxc/pages/__init__.py @@ -27,28 +27,41 @@ def _register_account_layouts() -> None: def _account_full(ctx: dict, **kw: Any) -> str: - from shared.sx.helpers import root_header_sx, header_child_sx - from sx.sx_components import _auth_header_sx + from shared.sx.helpers import root_header_sx, header_child_sx, call_url, sx_call, SxExpr root_hdr = root_header_sx(ctx) - hdr_child = header_child_sx(_auth_header_sx(ctx)) + auth_hdr = sx_call("auth-header-row", + account_url=call_url(ctx, "account_url", ""), + select_colours=ctx.get("select_colours", ""), + account_nav=_as_sx_nav(ctx), + ) + hdr_child = header_child_sx(auth_hdr) return "(<> " + root_hdr + " " + hdr_child + ")" def _account_oob(ctx: dict, **kw: Any) -> str: - from shared.sx.helpers import root_header_sx - from sx.sx_components import _auth_header_sx + from shared.sx.helpers import root_header_sx, call_url, sx_call - return "(<> " + _auth_header_sx(ctx, oob=True) + " " + root_header_sx(ctx, oob=True) + ")" + auth_hdr = sx_call("auth-header-row", + account_url=call_url(ctx, "account_url", ""), + select_colours=ctx.get("select_colours", ""), + account_nav=_as_sx_nav(ctx), + oob=True, + ) + return "(<> " + auth_hdr + " " + root_header_sx(ctx, oob=True) + ")" def _account_mobile(ctx: dict, **kw: Any) -> str: - from shared.sx.helpers import mobile_menu_sx, mobile_root_nav_sx, sx_call, SxExpr - from sx.sx_components import _auth_nav_mobile_sx + from shared.sx.helpers import mobile_menu_sx, mobile_root_nav_sx, sx_call, SxExpr, call_url ctx = _inject_account_nav(ctx) + nav_items = sx_call("auth-nav-items", + account_url=call_url(ctx, "account_url", ""), + select_colours=ctx.get("select_colours", ""), + account_nav=_as_sx_nav(ctx), + ) auth_section = sx_call("mobile-menu-section", label="account", href="/", level=1, colour="sky", - items=SxExpr(_auth_nav_mobile_sx(ctx))) + items=SxExpr(nav_items)) return mobile_menu_sx(auth_section, mobile_root_nav_sx(ctx)) @@ -61,6 +74,13 @@ def _inject_account_nav(ctx: dict) -> dict: return ctx +def _as_sx_nav(ctx: dict) -> Any: + """Convert account_nav fragment to SxExpr for use in sx_call.""" + from shared.sx.helpers import _as_sx + ctx = _inject_account_nav(ctx) + return _as_sx(ctx.get("account_nav")) + + # --------------------------------------------------------------------------- # Page helpers # --------------------------------------------------------------------------- @@ -69,22 +89,19 @@ def _register_account_helpers() -> None: from shared.sx.pages import register_page_helpers register_page_helpers("account", { - "account-content": _h_account_content, "newsletters-content": _h_newsletters_content, "fragment-content": _h_fragment_content, }) -def _h_account_content(**kw): - from sx.sx_components import _account_main_panel_sx - return _account_main_panel_sx({}) - - async def _h_newsletters_content(**kw): + """Fetch newsletter data, return assembled defcomp call.""" from quart import g from sqlalchemy import select from shared.models import UserNewsletter from shared.models.ghost_membership_entities import GhostNewsletter + from shared.sx.helpers import sx_call, SxExpr + from shared.sx.parser import serialize result = await g.s.execute( select(GhostNewsletter).order_by(GhostNewsletter.name) @@ -102,20 +119,21 @@ async def _h_newsletters_content(**kw): for nl in all_newsletters: un = user_subs.get(nl.id) newsletter_list.append({ - "newsletter": nl, - "un": un, + "newsletter": {"id": nl.id, "name": nl.name, "description": nl.description}, + "un": {"newsletter_id": un.newsletter_id, "subscribed": un.subscribed} if un else None, "subscribed": un.subscribed if un else False, }) - if not newsletter_list: - from shared.sx.helpers import sx_call - return sx_call("account-newsletter-empty") - from sx.sx_components import _newsletters_panel_sx - ctx = {"account_url": getattr(g, "_account_url", None)} - if ctx["account_url"] is None: - from shared.infrastructure.urls import account_url - ctx["account_url"] = account_url - return _newsletters_panel_sx(ctx, newsletter_list) + account_url = getattr(g, "_account_url", None) + if account_url is None: + from shared.infrastructure.urls import account_url as _account_url + account_url = _account_url + # Call account_url to get the base URL string + account_url_str = account_url("") if callable(account_url) else str(account_url or "") + + return sx_call("account-newsletters-content", + newsletter_list=SxExpr(serialize(newsletter_list)), + account_url=account_url_str) async def _h_fragment_content(slug=None, **kw): diff --git a/account/sxc/pages/account.sx b/account/sxc/pages/account.sx index 1e3b3ca..8da12d1 100644 --- a/account/sxc/pages/account.sx +++ b/account/sxc/pages/account.sx @@ -8,7 +8,7 @@ :path "/" :auth :login :layout :account - :content (account-content)) + :content (~account-dashboard-content)) ;; --------------------------------------------------------------------------- ;; Newsletters diff --git a/cart/sx/items.sx b/cart/sx/items.sx index 30f1f59..00d86e7 100644 --- a/cart/sx/items.sx +++ b/cart/sx/items.sx @@ -52,3 +52,114 @@ (div :id "cart" (div (section :class "space-y-3 sm:space-y-4" items cal tickets) summary)))) + +;; Assembled cart item from serialized data — replaces Python _cart_item_sx +(defcomp ~cart-item-from-data (&key item) + (let* ((slug (or (get item "slug") "")) + (title (or (get item "title") "")) + (image (get item "image")) + (brand (get item "brand")) + (is-deleted (get item "is_deleted")) + (unit-price (get item "unit_price")) + (special-price (get item "special_price")) + (regular-price (get item "regular_price")) + (currency (or (get item "currency") "GBP")) + (symbol (if (= currency "GBP") "\u00a3" currency)) + (quantity (or (get item "quantity") 1)) + (product-id (get item "product_id")) + (prod-url (or (get item "product_url") "")) + (qty-url (or (get item "qty_url") "")) + (csrf (csrf-token)) + (line-total (when unit-price (* unit-price quantity)))) + (~cart-item + :id (str "cart-item-" slug) + :img (if image + (~cart-item-img :src image :alt title) + (~img-or-placeholder :src nil + :size-cls "w-24 h-24 sm:w-32 sm:h-28 rounded-xl border border-dashed border-stone-300" + :placeholder-text "No image")) + :prod-url prod-url + :title title + :brand (when brand (~cart-item-brand :brand brand)) + :deleted (when is-deleted (~cart-item-deleted)) + :price (if unit-price + (<> + (~cart-item-price :text (str symbol (format-decimal unit-price 2))) + (when (and special-price (!= special-price regular-price)) + (~cart-item-price-was :text (str symbol (format-decimal regular-price 2))))) + (~cart-item-no-price)) + :qty-url qty-url :csrf csrf + :minus (str (- quantity 1)) + :qty (str quantity) + :plus (str (+ quantity 1)) + :line-total (when line-total + (~cart-item-line-total :text (str "Line total: " symbol (format-decimal line-total 2))))))) + +;; Assembled calendar entries section — replaces Python _calendar_entries_sx +(defcomp ~cart-cal-section-from-data (&key entries) + (when (not (empty? entries)) + (~cart-cal-section + :items (map (lambda (e) + (let* ((name (or (get e "name") "")) + (date-str (or (get e "date_str") ""))) + (~cart-cal-entry + :name name :date-str date-str + :cost (str "\u00a3" (format-decimal (or (get e "cost") 0) 2))))) + entries)))) + +;; Assembled ticket groups section — replaces Python _ticket_groups_sx +(defcomp ~cart-tickets-section-from-data (&key ticket-groups) + (when (not (empty? ticket-groups)) + (let* ((csrf (csrf-token)) + (qty-url (url-for "cart_global.update_ticket_quantity"))) + (~cart-tickets-section + :items (map (lambda (tg) + (let* ((name (or (get tg "entry_name") "")) + (tt-name (get tg "ticket_type_name")) + (price (or (get tg "price") 0)) + (quantity (or (get tg "quantity") 0)) + (line-total (or (get tg "line_total") 0)) + (entry-id (str (or (get tg "entry_id") ""))) + (tt-id (get tg "ticket_type_id")) + (date-str (or (get tg "date_str") ""))) + (~cart-ticket-article + :name name + :type-name (when tt-name (~cart-ticket-type-name :name tt-name)) + :date-str date-str + :price (str "\u00a3" (format-decimal price 2)) + :qty-url qty-url :csrf csrf + :entry-id entry-id + :type-hidden (when tt-id (~cart-ticket-type-hidden :value (str tt-id))) + :minus (str (max (- quantity 1) 0)) + :qty (str quantity) + :plus (str (+ quantity 1)) + :line-total (str "Line total: \u00a3" (format-decimal line-total 2))))) + ticket-groups))))) + +;; Assembled cart summary — replaces Python _cart_summary_sx +(defcomp ~cart-summary-from-data (&key item-count grand-total symbol is-logged-in checkout-action login-href user-email) + (~cart-summary-panel + :item-count (str item-count) + :subtotal (str symbol (format-decimal grand-total 2)) + :checkout (if is-logged-in + (~cart-checkout-form + :action checkout-action :csrf (csrf-token) + :label (str " Checkout as " user-email)) + (~cart-checkout-signin :href login-href)))) + +;; Assembled page cart content — replaces Python _page_cart_main_panel_sx +(defcomp ~cart-page-cart-content (&key cart-items cal-entries ticket-groups summary) + (if (and (empty? (or cart-items (list))) + (empty? (or cal-entries (list))) + (empty? (or ticket-groups (list)))) + (div :class "max-w-full px-3 py-3 space-y-3" + (div :id "cart" + (div :class "rounded-2xl border border-dashed border-stone-300 bg-white/80 p-6 sm:p-8 text-center" + (~empty-state :icon "fa fa-shopping-cart" :message "Your cart is empty" :cls "text-center")))) + (~cart-page-panel + :items (map (lambda (item) (~cart-item-from-data :item item)) (or cart-items (list))) + :cal (when (not (empty? (or cal-entries (list)))) + (~cart-cal-section-from-data :entries cal-entries)) + :tickets (when (not (empty? (or ticket-groups (list)))) + (~cart-tickets-section-from-data :ticket-groups ticket-groups)) + :summary summary))) diff --git a/cart/sx/overview.sx b/cart/sx/overview.sx index cbeaa4f..9d04328 100644 --- a/cart/sx/overview.sx +++ b/cart/sx/overview.sx @@ -39,3 +39,56 @@ (defcomp ~cart-overview-panel (&key cards) (div :class "max-w-full px-3 py-3 space-y-3" (div :class "space-y-4" cards))) + +(defcomp ~cart-empty () + (div :class "max-w-full px-3 py-3 space-y-3" + (div :class "rounded-2xl border border-dashed border-stone-300 bg-white/80 p-6 sm:p-8 text-center" + (~empty-state :icon "fa fa-shopping-cart" :message "Your cart is empty" :cls "text-center")))) + +;; Assembled page group card — replaces Python _page_group_card_sx +(defcomp ~cart-page-group-card-from-data (&key grp cart-url-base) + (let* ((post (get grp "post")) + (product-count (or (get grp "product_count") 0)) + (calendar-count (or (get grp "calendar_count") 0)) + (ticket-count (or (get grp "ticket_count") 0)) + (total (or (get grp "total") 0)) + (market-place (get grp "market_place")) + (badges (<> + (when (> product-count 0) + (~cart-badge :icon "fa fa-box-open" + :text (str product-count " item" (pluralize product-count)))) + (when (> calendar-count 0) + (~cart-badge :icon "fa fa-calendar" + :text (str calendar-count " booking" (pluralize calendar-count)))) + (when (> ticket-count 0) + (~cart-badge :icon "fa fa-ticket" + :text (str ticket-count " ticket" (pluralize ticket-count))))))) + (if post + (let* ((slug (or (get post "slug") "")) + (title (or (get post "title") "")) + (feature-image (get post "feature_image")) + (mp-name (if market-place (or (get market-place "name") "") "")) + (display-title (if (!= mp-name "") mp-name title))) + (~cart-group-card + :href (str cart-url-base "/" slug "/") + :img (if feature-image + (~cart-group-card-img :src feature-image :alt title) + (~img-or-placeholder :src nil :size-cls "h-16 w-16 rounded-xl" + :placeholder-icon "fa fa-store text-xl")) + :display-title display-title + :subtitle (when (!= mp-name "") + (~cart-mp-subtitle :title title)) + :badges (~cart-badges-wrap :badges badges) + :total (str "\u00a3" (format-decimal total 2)))) + (~cart-orphan-card + :badges (~cart-badges-wrap :badges badges) + :total (str "\u00a3" (format-decimal total 2)))))) + +;; Assembled cart overview content — replaces Python _overview_main_panel_sx +(defcomp ~cart-overview-content (&key page-groups cart-url-base) + (if (empty? page-groups) + (~cart-empty) + (~cart-overview-panel + :cards (map (lambda (grp) + (~cart-page-group-card-from-data :grp grp :cart-url-base cart-url-base)) + page-groups)))) diff --git a/cart/sx/payments.sx b/cart/sx/payments.sx index 50343cd..36f6d35 100644 --- a/cart/sx/payments.sx +++ b/cart/sx/payments.sx @@ -5,3 +5,27 @@ (~sumup-settings-form :update-url update-url :csrf csrf :merchant-code merchant-code :placeholder placeholder :input-cls input-cls :sumup-configured sumup-configured :checkout-prefix checkout-prefix :sx-select "#payments-panel"))) + +;; Assembled cart admin overview content +(defcomp ~cart-admin-content () + (let* ((payments-href (url-for "defpage_cart_payments"))) + (div :id "main-panel" + (div :class "flex items-center justify-between p-3 border-b" + (span :class "font-medium" (i :class "fa fa-credit-card text-purple-600 mr-1") " Payments") + (a :href payments-href :class "text-sm underline" "configure"))))) + +;; Assembled cart payments content +(defcomp ~cart-payments-content (&key page-config) + (let* ((sumup-configured (and page-config (get page-config "sumup_api_key"))) + (merchant-code (or (get page-config "sumup_merchant_code") "")) + (checkout-prefix (or (get page-config "sumup_checkout_prefix") "")) + (placeholder (if sumup-configured "--------" "sup_sk_...")) + (input-cls "w-full px-3 py-1.5 text-sm border border-stone-300 rounded focus:ring-purple-500 focus:border-purple-500")) + (~cart-payments-panel + :update-url (url-for "page_admin.update_sumup") + :csrf (csrf-token) + :merchant-code merchant-code + :placeholder placeholder + :input-cls input-cls + :sumup-configured sumup-configured + :checkout-prefix checkout-prefix))) diff --git a/cart/sx/sx_components.py b/cart/sx/sx_components.py index 293ffcf..f8174a2 100644 --- a/cart/sx/sx_components.py +++ b/cart/sx/sx_components.py @@ -1,8 +1,8 @@ """ Cart service s-expression page components. -Renders cart overview, page cart, orders list, and single order detail. -Called from route handlers in place of ``render_template()``. +Thin Python wrappers for header/layout helpers and route-level render +functions. All visual rendering logic lives in .sx defcomps. """ from __future__ import annotations @@ -18,7 +18,8 @@ from shared.sx.helpers import ( full_page_sx, oob_page_sx, header_child_sx, sx_call, SxExpr, ) -from shared.infrastructure.urls import market_product_url, cart_url +from shared.sx.parser import serialize +from shared.infrastructure.urls import cart_url # Load cart-specific .sx components + handlers at import time load_service_components(os.path.dirname(os.path.dirname(__file__)), @@ -26,7 +27,7 @@ load_service_components(os.path.dirname(os.path.dirname(__file__)), # --------------------------------------------------------------------------- -# Header helpers +# Header helpers (used by layouts in sxc/pages/__init__.py) # --------------------------------------------------------------------------- def _ensure_post_ctx(ctx: dict, page_post: Any) -> dict: @@ -104,493 +105,74 @@ def _page_cart_header_sx(ctx: dict, page_post: Any, *, oob: bool = False) -> str def _auth_header_sx(ctx: dict, *, oob: bool = False) -> str: """Build the account section header row (for orders).""" return sx_call( - "menu-row-sx", - id="auth-row", level=1, colour="sky", - link_href=call_url(ctx, "account_url", "/"), - link_label="account", icon="fa-solid fa-user", - child_id="auth-header-child", oob=oob, + "auth-header-row-simple", + account_url=call_url(ctx, "account_url", ""), + oob=oob, ) def _orders_header_sx(ctx: dict, list_url: str) -> str: """Build the orders section header row.""" - return sx_call( - "menu-row-sx", - id="orders-row", level=2, colour="sky", - link_href=list_url, link_label="Orders", icon="fa fa-gbp", - child_id="orders-header-child", - ) + return sx_call("orders-header-row", list_url=list_url) + + +def _cart_page_admin_header_sx(ctx: dict, page_post: Any, *, oob: bool = False, + selected: str = "") -> str: + """Build the page-level admin header row.""" + slug = page_post.slug if page_post else "" + ctx = _ensure_post_ctx(ctx, page_post) + return post_admin_header_sx(ctx, slug, oob=oob, selected=selected) # --------------------------------------------------------------------------- -# Cart overview +# Serialization helpers (shared with sxc/pages/__init__.py) # --------------------------------------------------------------------------- -def _badge_sx(icon: str, count: int, label: str) -> str: - """Render a count badge.""" - s = "s" if count != 1 else "" - return sx_call("cart-badge", icon=icon, text=f"{count} {label}{s}") - - -def _page_group_card_sx(grp: Any, ctx: dict) -> str: - """Render a single page group card for cart overview.""" - post = grp.get("post") if isinstance(grp, dict) else getattr(grp, "post", None) - cart_items = grp.get("cart_items", []) if isinstance(grp, dict) else getattr(grp, "cart_items", []) - cal_entries = grp.get("calendar_entries", []) if isinstance(grp, dict) else getattr(grp, "calendar_entries", []) - tickets = grp.get("tickets", []) if isinstance(grp, dict) else getattr(grp, "tickets", []) - product_count = grp.get("product_count", 0) if isinstance(grp, dict) else getattr(grp, "product_count", 0) - calendar_count = grp.get("calendar_count", 0) if isinstance(grp, dict) else getattr(grp, "calendar_count", 0) - ticket_count = grp.get("ticket_count", 0) if isinstance(grp, dict) else getattr(grp, "ticket_count", 0) - total = grp.get("total", 0) if isinstance(grp, dict) else getattr(grp, "total", 0) - market_place = grp.get("market_place") if isinstance(grp, dict) else getattr(grp, "market_place", None) - - if not cart_items and not cal_entries and not tickets: - return "" - - # Count badges - badge_parts = [] - if product_count > 0: - badge_parts.append(_badge_sx("fa fa-box-open", product_count, "item")) - if calendar_count > 0: - badge_parts.append(_badge_sx("fa fa-calendar", calendar_count, "booking")) - if ticket_count > 0: - badge_parts.append(_badge_sx("fa fa-ticket", ticket_count, "ticket")) - badges_sx = "(<> " + " ".join(badge_parts) + ")" if badge_parts else '""' - badges_wrap = sx_call("cart-badges-wrap", badges=SxExpr(badges_sx)) - - if post: - slug = post.slug if hasattr(post, "slug") else post.get("slug", "") - title = post.title if hasattr(post, "title") else post.get("title", "") - feature_image = post.feature_image if hasattr(post, "feature_image") else post.get("feature_image") - cart_href = call_url(ctx, "cart_url", f"/{slug}/") - - if feature_image: - img = sx_call("cart-group-card-img", src=feature_image, alt=title) - else: - img = sx_call("img-or-placeholder", src=None, - size_cls="h-16 w-16 rounded-xl", - placeholder_icon="fa fa-store text-xl") - - mp_sub = "" - if market_place: - mp_name = market_place.name if hasattr(market_place, "name") else market_place.get("name", "") - mp_sub = sx_call("cart-mp-subtitle", title=title) - else: - mp_name = "" - display_title = mp_name or title - - return sx_call( - "cart-group-card", - href=cart_href, img=SxExpr(img), display_title=display_title, - subtitle=SxExpr(mp_sub) if mp_sub else None, - badges=SxExpr(badges_wrap), - total=f"\u00a3{total:.2f}", - ) - else: - # Orphan items - return sx_call( - "cart-orphan-card", - badges=SxExpr(badges_wrap), - total=f"\u00a3{total:.2f}", - ) - - -def _empty_cart_sx() -> str: - """Empty cart state.""" - empty = sx_call("empty-state", icon="fa fa-shopping-cart", - message="Your cart is empty", cls="text-center") - return ( - '(div :class "max-w-full px-3 py-3 space-y-3"' - ' (div :class "rounded-2xl border border-dashed border-stone-300 bg-white/80 p-6 sm:p-8 text-center"' - f' {empty}))' - ) - - -def _overview_main_panel_sx(page_groups: list, ctx: dict) -> str: - """Cart overview main panel.""" - if not page_groups: - return _empty_cart_sx() - - cards = [_page_group_card_sx(grp, ctx) for grp in page_groups] - has_items = any(c for c in cards) - if not has_items: - return _empty_cart_sx() - - cards_sx = "(<> " + " ".join(c for c in cards if c) + ")" - return sx_call("cart-overview-panel", cards=SxExpr(cards_sx)) - - -# --------------------------------------------------------------------------- -# Page cart -# --------------------------------------------------------------------------- - -def _cart_item_sx(item: Any, ctx: dict) -> str: - """Render a single product cart item.""" - from shared.browser.app.csrf import generate_csrf_token - from quart import url_for - - p = item.product if hasattr(item, "product") else item - slug = p.slug if hasattr(p, "slug") else "" - unit_price = getattr(p, "special_price", None) or getattr(p, "regular_price", None) - currency = getattr(p, "regular_price_currency", "GBP") or "GBP" - symbol = "\u00a3" if currency == "GBP" else currency - csrf = generate_csrf_token() - qty_url = url_for("cart_global.update_quantity", product_id=p.id) - prod_url = market_product_url(slug) - - if p.image: - img = sx_call("cart-item-img", src=p.image, alt=p.title) - else: - img = sx_call("img-or-placeholder", src=None, - size_cls="w-24 h-24 sm:w-32 sm:h-28 rounded-xl border border-dashed border-stone-300", - placeholder_text="No image") - - price_parts = [] - if unit_price: - price_parts.append(sx_call("cart-item-price", text=f"{symbol}{unit_price:.2f}")) - if p.special_price and p.special_price != p.regular_price: - price_parts.append(sx_call("cart-item-price-was", text=f"{symbol}{p.regular_price:.2f}")) - else: - price_parts.append(sx_call("cart-item-no-price")) - price_sx = "(<> " + " ".join(price_parts) + ")" if len(price_parts) > 1 else price_parts[0] - - deleted_sx = sx_call("cart-item-deleted") if getattr(item, "is_deleted", False) else None - - brand_sx = sx_call("cart-item-brand", brand=p.brand) if getattr(p, "brand", None) else None - - line_total_sx = None - if unit_price: - lt = unit_price * item.quantity - line_total_sx = sx_call("cart-item-line-total", text=f"Line total: {symbol}{lt:.2f}") - - return sx_call( - "cart-item", - id=f"cart-item-{slug}", img=SxExpr(img), prod_url=prod_url, title=p.title, - brand=SxExpr(brand_sx) if brand_sx else None, - deleted=SxExpr(deleted_sx) if deleted_sx else None, - price=SxExpr(price_sx), - qty_url=qty_url, csrf=csrf, minus=str(item.quantity - 1), - qty=str(item.quantity), plus=str(item.quantity + 1), - line_total=SxExpr(line_total_sx) if line_total_sx else None, - ) - - -def _calendar_entries_sx(entries: list) -> str: - """Render calendar booking entries in cart.""" - if not entries: - return "" - parts = [] - for e in entries: - name = getattr(e, "name", None) or getattr(e, "calendar_name", "") - start = e.start_at if hasattr(e, "start_at") else "" - end = getattr(e, "end_at", None) - cost = getattr(e, "cost", 0) or 0 - end_str = f" \u2013 {end}" if end else "" - parts.append(sx_call( - "cart-cal-entry", - name=name, date_str=f"{start}{end_str}", cost=f"\u00a3{cost:.2f}", - )) - items_sx = "(<> " + " ".join(parts) + ")" - return sx_call("cart-cal-section", items=SxExpr(items_sx)) - - -def _ticket_groups_sx(ticket_groups: list, ctx: dict) -> str: - """Render ticket groups in cart.""" - if not ticket_groups: - return "" - from shared.browser.app.csrf import generate_csrf_token - from quart import url_for - - csrf = generate_csrf_token() - qty_url = url_for("cart_global.update_ticket_quantity") - parts = [] - - for tg in ticket_groups: - name = tg.entry_name if hasattr(tg, "entry_name") else tg.get("entry_name", "") - tt_name = tg.ticket_type_name if hasattr(tg, "ticket_type_name") else tg.get("ticket_type_name", "") - price = tg.price if hasattr(tg, "price") else tg.get("price", 0) - quantity = tg.quantity if hasattr(tg, "quantity") else tg.get("quantity", 0) - line_total = tg.line_total if hasattr(tg, "line_total") else tg.get("line_total", 0) - entry_id = tg.entry_id if hasattr(tg, "entry_id") else tg.get("entry_id", "") - tt_id = tg.ticket_type_id if hasattr(tg, "ticket_type_id") else tg.get("ticket_type_id", "") - start_at = tg.entry_start_at if hasattr(tg, "entry_start_at") else tg.get("entry_start_at") - end_at = tg.entry_end_at if hasattr(tg, "entry_end_at") else tg.get("entry_end_at") - - date_str = start_at.strftime("%-d %b %Y, %H:%M") if start_at else "" - if end_at: - date_str += f" \u2013 {end_at.strftime('%-d %b %Y, %H:%M')}" - - tt_name_sx = sx_call("cart-ticket-type-name", name=tt_name) if tt_name else None - tt_hidden_sx = sx_call("cart-ticket-type-hidden", value=str(tt_id)) if tt_id else None - - parts.append(sx_call( - "cart-ticket-article", - name=name, - type_name=SxExpr(tt_name_sx) if tt_name_sx else None, - date_str=date_str, - price=f"\u00a3{price or 0:.2f}", qty_url=qty_url, csrf=csrf, - entry_id=str(entry_id), - type_hidden=SxExpr(tt_hidden_sx) if tt_hidden_sx else None, - minus=str(max(quantity - 1, 0)), qty=str(quantity), - plus=str(quantity + 1), line_total=f"Line total: \u00a3{line_total:.2f}", - )) - - items_sx = "(<> " + " ".join(parts) + ")" - return sx_call("cart-tickets-section", items=SxExpr(items_sx)) - - -def _cart_summary_sx(ctx: dict, cart: list, cal_entries: list, tickets: list, - total_fn: Any, cal_total_fn: Any, ticket_total_fn: Any) -> str: - """Render the order summary sidebar.""" - from shared.browser.app.csrf import generate_csrf_token - from quart import g, url_for, request - from shared.infrastructure.urls import login_url - - csrf = generate_csrf_token() - product_qty = sum(ci.quantity for ci in cart) if cart else 0 - ticket_qty = len(tickets) if tickets else 0 - item_count = product_qty + ticket_qty - - product_total = total_fn(cart) or 0 - cal_total = cal_total_fn(cal_entries) or 0 - tk_total = ticket_total_fn(tickets) or 0 - grand = float(product_total) + float(cal_total) + float(tk_total) - - symbol = "\u00a3" - if cart and hasattr(cart[0], "product") and getattr(cart[0].product, "regular_price_currency", None): - cur = cart[0].product.regular_price_currency - symbol = "\u00a3" if cur == "GBP" else cur - - user = getattr(g, "user", None) - page_post = ctx.get("page_post") - - if user: - if page_post: - action = url_for("page_cart.page_checkout") - else: - action = url_for("cart_global.checkout") - from shared.utils import route_prefix - action = route_prefix() + action - checkout_sx = sx_call( - "cart-checkout-form", - action=action, csrf=csrf, label=f" Checkout as {user.email}", - ) - else: - href = login_url(request.url) - checkout_sx = sx_call("cart-checkout-signin", href=href) - - return sx_call( - "cart-summary-panel", - item_count=str(item_count), subtotal=f"{symbol}{grand:.2f}", - checkout=SxExpr(checkout_sx), - ) - - -def _page_cart_main_panel_sx(ctx: dict, cart: list, cal_entries: list, - tickets: list, ticket_groups: list, - total_fn: Any, cal_total_fn: Any, - ticket_total_fn: Any) -> str: - """Page cart main panel.""" - if not cart and not cal_entries and not tickets: - empty = sx_call("empty-state", icon="fa fa-shopping-cart", - message="Your cart is empty", cls="text-center") - return ( - '(div :class "max-w-full px-3 py-3 space-y-3"' - ' (div :id "cart"' - ' (div :class "rounded-2xl border border-dashed border-stone-300 bg-white/80 p-6 sm:p-8 text-center"' - f' {empty})))' - ) - - item_parts = [_cart_item_sx(item, ctx) for item in cart] - items_sx = "(<> " + " ".join(item_parts) + ")" if item_parts else '""' - cal_sx = _calendar_entries_sx(cal_entries) - tickets_sx = _ticket_groups_sx(ticket_groups, ctx) - summary_sx = _cart_summary_sx(ctx, cart, cal_entries, tickets, total_fn, cal_total_fn, ticket_total_fn) - - return sx_call( - "cart-page-panel", - items=SxExpr(items_sx), - cal=SxExpr(cal_sx) if cal_sx else None, - tickets=SxExpr(tickets_sx) if tickets_sx else None, - summary=SxExpr(summary_sx), - ) - - -# --------------------------------------------------------------------------- -# Orders list (same pattern as orders service) -# --------------------------------------------------------------------------- - -def _order_row_sx(order: Any, detail_url: str) -> str: - """Render a single order as desktop table row + mobile card.""" - status = order.status or "pending" - sl = status.lower() - pill = ( - "border-emerald-300 bg-emerald-50 text-emerald-700" if sl == "paid" - else "border-rose-300 bg-rose-50 text-rose-700" if sl in ("failed", "cancelled") - else "border-stone-300 bg-stone-50 text-stone-700" - ) - pill_cls = f"inline-flex items-center rounded-full border px-2 py-0.5 text-[11px] sm:text-xs {pill}" +def _serialize_order(order: Any) -> dict: + """Serialize an order for SX defcomps.""" + from shared.infrastructure.urls import market_product_url created = order.created_at.strftime("%-d %b %Y, %H:%M") if order.created_at else "\u2014" - total = f"{order.currency or 'GBP'} {order.total_amount or 0:.2f}" - - desktop = sx_call( - "order-row-desktop", - oid=f"#{order.id}", created=created, desc=order.description or "", - total=total, pill=pill_cls, status=status, url=detail_url, - ) - - mobile_pill = f"inline-flex items-center rounded-full border px-2 py-0.5 text-[11px] {pill}" - mobile = sx_call( - "order-row-mobile", - oid=f"#{order.id}", pill=mobile_pill, status=status, - created=created, total=total, url=detail_url, - ) - - return "(<> " + desktop + " " + mobile + ")" + items = [] + if order.items: + for item in order.items: + items.append({ + "product_image": item.product_image, + "product_title": item.product_title or "Unknown product", + "product_id": item.product_id, + "product_slug": item.product_slug, + "product_url": market_product_url(item.product_slug), + "quantity": item.quantity, + "unit_price_formatted": f"{item.unit_price or 0:.2f}", + "currency": item.currency or order.currency or "GBP", + }) + return { + "id": order.id, + "status": order.status or "pending", + "created_at_formatted": created, + "description": order.description or "", + "total_formatted": f"{order.total_amount or 0:.2f}", + "total_amount": float(order.total_amount or 0), + "currency": order.currency or "GBP", + "items": items, + } -def _orders_rows_sx(orders: list, page: int, total_pages: int, - url_for_fn: Any, qs_fn: Any) -> str: - """Render order rows + infinite scroll sentinel.""" - from shared.utils import route_prefix - pfx = route_prefix() - - parts = [ - _order_row_sx(o, pfx + url_for_fn("orders.order.order_detail", order_id=o.id)) - for o in orders - ] - - if page < total_pages: - next_url = pfx + url_for_fn("orders.list_orders") + qs_fn(page=page + 1) - parts.append(sx_call( - "infinite-scroll", - url=next_url, page=page, total_pages=total_pages, - id_prefix="orders", colspan=5, - )) - else: - parts.append(sx_call("order-end-row")) - - return "(<> " + " ".join(parts) + ")" - - -def _orders_main_panel_sx(orders: list, rows_sx: str) -> str: - """Main panel for orders list.""" - if not orders: - return sx_call("order-empty-state") - return sx_call("order-table", rows=SxExpr(rows_sx)) - - -def _orders_summary_sx(ctx: dict) -> str: - """Filter section for orders list.""" - return sx_call("order-list-header", search_mobile=SxExpr(search_mobile_sx(ctx))) +def _serialize_calendar_entry(e: Any) -> dict: + """Serialize an order calendar entry for SX defcomps.""" + st = e.state or "" + ds = e.start_at.strftime("%-d %b %Y, %H:%M") if e.start_at else "" + if e.end_at: + ds += f" \u2013 {e.end_at.strftime('%-d %b %Y, %H:%M')}" + return { + "name": e.name, + "state": st, + "date_str": ds, + "cost_formatted": f"{e.cost or 0:.2f}", + } # --------------------------------------------------------------------------- -# Single order detail -# --------------------------------------------------------------------------- - -def _order_items_sx(order: Any) -> str: - """Render order items list.""" - if not order or not order.items: - return "" - parts = [] - for item in order.items: - prod_url = market_product_url(item.product_slug) - if item.product_image: - img = sx_call( - "order-item-image", - src=item.product_image, alt=item.product_title or "Product image", - ) - else: - img = sx_call("order-item-no-image") - parts.append(sx_call( - "order-item-row", - href=prod_url, img=SxExpr(img), - title=item.product_title or "Unknown product", - pid=f"Product ID: {item.product_id}", - qty=f"Qty: {item.quantity}", - price=f"{item.currency or order.currency or 'GBP'} {item.unit_price or 0:.2f}", - )) - items_sx = "(<> " + " ".join(parts) + ")" - return sx_call("order-items-panel", items=SxExpr(items_sx)) - - -def _order_summary_sx(order: Any) -> str: - """Order summary card.""" - return sx_call( - "order-summary-card", - order_id=order.id, - created_at=order.created_at.strftime("%-d %b %Y, %H:%M") if order.created_at else None, - description=order.description, status=order.status, currency=order.currency, - total_amount=f"{order.total_amount:.2f}" if order.total_amount else None, - ) - - -def _order_calendar_items_sx(calendar_entries: list | None) -> str: - """Render calendar bookings for an order.""" - if not calendar_entries: - return "" - parts = [] - for e in calendar_entries: - st = e.state or "" - pill = ( - "bg-emerald-100 text-emerald-800" if st == "confirmed" - else "bg-amber-100 text-amber-800" if st == "provisional" - else "bg-blue-100 text-blue-800" if st == "ordered" - else "bg-stone-100 text-stone-700" - ) - pill_cls = f"inline-flex items-center rounded-full px-2 py-0.5 text-[11px] font-medium {pill}" - ds = e.start_at.strftime("%-d %b %Y, %H:%M") if e.start_at else "" - if e.end_at: - ds += f" \u2013 {e.end_at.strftime('%-d %b %Y, %H:%M')}" - parts.append(sx_call( - "order-calendar-entry", - name=e.name, pill=pill_cls, status=st.capitalize(), - date_str=ds, cost=f"\u00a3{e.cost or 0:.2f}", - )) - items_sx = "(<> " + " ".join(parts) + ")" - return sx_call("order-calendar-section", items=SxExpr(items_sx)) - - -def _order_main_sx(order: Any, calendar_entries: list | None) -> str: - """Main panel for single order detail.""" - summary = _order_summary_sx(order) - items = _order_items_sx(order) - cal = _order_calendar_items_sx(calendar_entries) - return sx_call( - "order-detail-panel", - summary=SxExpr(summary), - items=SxExpr(items) if items else None, - calendar=SxExpr(cal) if cal else None, - ) - - -def _order_filter_sx(order: Any, list_url: str, recheck_url: str, - pay_url: str, csrf_token: str) -> str: - """Filter section for single order detail.""" - created = order.created_at.strftime("%-d %b %Y, %H:%M") if order.created_at else "\u2014" - status = order.status or "pending" - - pay_sx = None - if status != "paid": - pay_sx = sx_call("order-pay-btn", url=pay_url) - - return sx_call( - "order-detail-filter", - info=f"Placed {created} \u00b7 Status: {status}", - list_url=list_url, recheck_url=recheck_url, csrf=csrf_token, - pay=SxExpr(pay_sx) if pay_sx else None, - ) - - -# --------------------------------------------------------------------------- -# Public API: Cart overview -# --------------------------------------------------------------------------- - - - -# --------------------------------------------------------------------------- -# Public API: Orders list +# Public API: Orders list (used by cart/bp/orders/routes.py) # --------------------------------------------------------------------------- async def render_orders_page(ctx: dict, orders: list, page: int, @@ -602,10 +184,16 @@ async def render_orders_page(ctx: dict, orders: list, page: int, ctx["search"] = search ctx["search_count"] = search_count - list_url = route_prefix() + url_for_fn("orders.list_orders") + pfx = route_prefix() + list_url = pfx + url_for_fn("orders.list_orders") + rows_url = list_url + detail_url_prefix = pfx + url_for_fn("orders.order.order_detail", order_id=0).rsplit("0/", 1)[0] - rows = _orders_rows_sx(orders, page, total_pages, url_for_fn, qs_fn) - main = _orders_main_panel_sx(orders, rows) + order_dicts = [_serialize_order(o) for o in orders] + content = sx_call("orders-list-content", + orders=SxExpr(serialize(order_dicts)), + page=page, total_pages=total_pages, + rows_url=rows_url, detail_url_prefix=detail_url_prefix) hdr = root_header_sx(ctx) auth = _auth_header_sx(ctx) @@ -616,17 +204,40 @@ async def render_orders_page(ctx: dict, orders: list, page: int, ) header_rows = "(<> " + hdr + " " + auth_child + ")" + filt = sx_call("order-list-header", search_mobile=SxExpr(search_mobile_sx(ctx))) return full_page_sx(ctx, header_rows=header_rows, - filter=_orders_summary_sx(ctx), + filter=filt, aside=search_desktop_sx(ctx), - content=main) + content=content) async def render_orders_rows(ctx: dict, orders: list, page: int, total_pages: int, url_for_fn: Any, qs_fn: Any) -> str: """Pagination: just the table rows.""" - return _orders_rows_sx(orders, page, total_pages, url_for_fn, qs_fn) + from shared.utils import route_prefix + + pfx = route_prefix() + list_url = pfx + url_for_fn("orders.list_orders") + detail_url_prefix = pfx + url_for_fn("orders.order.order_detail", order_id=0).rsplit("0/", 1)[0] + + order_dicts = [_serialize_order(o) for o in orders] + parts = [sx_call("order-row-pair", + order=SxExpr(serialize(od)), + detail_url_prefix=detail_url_prefix) + for od in order_dicts] + + if page < total_pages: + next_url = list_url + qs_fn(page=page + 1) + parts.append(sx_call( + "infinite-scroll", + url=next_url, page=page, total_pages=total_pages, + id_prefix="orders", colspan=5, + )) + else: + parts.append(sx_call("order-end-row")) + + return "(<> " + " ".join(parts) + ")" async def render_orders_oob(ctx: dict, orders: list, page: int, @@ -638,10 +249,16 @@ async def render_orders_oob(ctx: dict, orders: list, page: int, ctx["search"] = search ctx["search_count"] = search_count - list_url = route_prefix() + url_for_fn("orders.list_orders") + pfx = route_prefix() + list_url = pfx + url_for_fn("orders.list_orders") + rows_url = list_url + detail_url_prefix = pfx + url_for_fn("orders.order.order_detail", order_id=0).rsplit("0/", 1)[0] - rows = _orders_rows_sx(orders, page, total_pages, url_for_fn, qs_fn) - main = _orders_main_panel_sx(orders, rows) + order_dicts = [_serialize_order(o) for o in orders] + content = sx_call("orders-list-content", + orders=SxExpr(serialize(order_dicts)), + page=page, total_pages=total_pages, + rows_url=rows_url, detail_url_prefix=detail_url_prefix) auth_oob = _auth_header_sx(ctx, oob=True) auth_child_oob = sx_call( @@ -652,14 +269,15 @@ async def render_orders_oob(ctx: dict, orders: list, page: int, root_oob = root_header_sx(ctx, oob=True) oobs = "(<> " + auth_oob + " " + auth_child_oob + " " + root_oob + ")" + filt = sx_call("order-list-header", search_mobile=SxExpr(search_mobile_sx(ctx))) return oob_page_sx(oobs=oobs, - filter=_orders_summary_sx(ctx), + filter=filt, aside=search_desktop_sx(ctx), - content=main) + content=content) # --------------------------------------------------------------------------- -# Public API: Single order detail +# Public API: Single order detail (used by cart/bp/order/routes.py) # --------------------------------------------------------------------------- async def render_order_page(ctx: dict, order: Any, @@ -675,8 +293,16 @@ async def render_order_page(ctx: dict, order: Any, recheck_url = pfx + url_for_fn("orders.order.order_recheck", order_id=order.id) pay_url = pfx + url_for_fn("orders.order.order_pay", order_id=order.id) - main = _order_main_sx(order, calendar_entries) - filt = _order_filter_sx(order, list_url, recheck_url, pay_url, generate_csrf_token()) + order_data = _serialize_order(order) + cal_data = [_serialize_calendar_entry(e) for e in (calendar_entries or [])] + + main = sx_call("order-detail-content", + order=SxExpr(serialize(order_data)), + calendar_entries=SxExpr(serialize(cal_data))) + filt = sx_call("order-detail-filter-content", + order=SxExpr(serialize(order_data)), + list_url=list_url, recheck_url=recheck_url, + pay_url=pay_url, csrf=generate_csrf_token()) hdr = root_header_sx(ctx) order_row = sx_call( @@ -708,8 +334,16 @@ async def render_order_oob(ctx: dict, order: Any, recheck_url = pfx + url_for_fn("orders.order.order_recheck", order_id=order.id) pay_url = pfx + url_for_fn("orders.order.order_pay", order_id=order.id) - main = _order_main_sx(order, calendar_entries) - filt = _order_filter_sx(order, list_url, recheck_url, pay_url, generate_csrf_token()) + order_data = _serialize_order(order) + cal_data = [_serialize_calendar_entry(e) for e in (calendar_entries or [])] + + main = sx_call("order-detail-content", + order=SxExpr(serialize(order_data)), + calendar_entries=SxExpr(serialize(cal_data))) + filt = sx_call("order-detail-filter-content", + order=SxExpr(serialize(order_data)), + list_url=list_url, recheck_url=recheck_url, + pay_url=pay_url, csrf=generate_csrf_token()) order_row_oob = sx_call( "menu-row-sx", @@ -727,81 +361,42 @@ async def render_order_oob(ctx: dict, order: Any, # --------------------------------------------------------------------------- -# Public API: Checkout error +# Public API: Checkout error (used by cart/bp/cart routes + order routes) # --------------------------------------------------------------------------- -def _checkout_error_filter_sx() -> str: - return sx_call("checkout-error-header") - - -def _checkout_error_content_sx(error: str | None, order: Any | None) -> str: +async def render_checkout_error_page(ctx: dict, error: str | None = None, + order: Any | None = None) -> str: + """Full page: checkout error.""" err_msg = error or "Unexpected error while creating the hosted checkout session." order_sx = None if order: order_sx = sx_call("checkout-error-order-id", oid=f"#{order.id}") back_url = cart_url("/") - return sx_call( + + hdr = root_header_sx(ctx) + filt = sx_call("checkout-error-header") + content = sx_call( "checkout-error-content", msg=err_msg, order=SxExpr(order_sx) if order_sx else None, back_url=back_url, ) - - -async def render_checkout_error_page(ctx: dict, error: str | None = None, order: Any | None = None) -> str: - """Full page: checkout error.""" - hdr = root_header_sx(ctx) - filt = _checkout_error_filter_sx() - content = _checkout_error_content_sx(error, order) return full_page_sx(ctx, header_rows=hdr, filter=filt, content=content) # --------------------------------------------------------------------------- -# Page admin (//admin/) +# Public API: POST response renderers # --------------------------------------------------------------------------- -def _cart_page_admin_header_sx(ctx: dict, page_post: Any, *, oob: bool = False, - selected: str = "") -> str: - """Build the page-level admin header row -- delegates to shared helper.""" - slug = page_post.slug if page_post else "" - ctx = _ensure_post_ctx(ctx, page_post) - return post_admin_header_sx(ctx, slug, oob=oob, selected=selected) - - -def _cart_admin_main_panel_sx(ctx: dict) -> str: - """Admin overview panel -- links to sub-admin pages.""" - from quart import url_for - payments_href = url_for("defpage_cart_payments") - return ( - '(div :id "main-panel"' - ' (div :class "flex items-center justify-between p-3 border-b"' - ' (span :class "font-medium" (i :class "fa fa-credit-card text-purple-600 mr-1") " Payments")' - f' (a :href "{payments_href}" :class "text-sm underline" "configure")))' - ) - - -def _cart_payments_main_panel_sx(ctx: dict) -> str: - """Render SumUp payment config form.""" - from quart import url_for - csrf_token = ctx.get("csrf_token") - csrf = csrf_token() if callable(csrf_token) else (csrf_token or "") - page_config = ctx.get("page_config") - sumup_configured = bool(page_config and getattr(page_config, "sumup_api_key", None)) - merchant_code = (getattr(page_config, "sumup_merchant_code", None) or "") if page_config else "" - checkout_prefix = (getattr(page_config, "sumup_checkout_prefix", None) or "") if page_config else "" - update_url = url_for("page_admin.update_sumup") - - placeholder = "--------" if sumup_configured else "sup_sk_..." - input_cls = "w-full px-3 py-1.5 text-sm border border-stone-300 rounded focus:ring-purple-500 focus:border-purple-500" - - return sx_call("cart-payments-panel", - update_url=update_url, csrf=csrf, - merchant_code=merchant_code, placeholder=placeholder, - input_cls=input_cls, sumup_configured=sumup_configured, - checkout_prefix=checkout_prefix) - - - def render_cart_payments_panel(ctx: dict) -> str: """Render the payments config panel for PUT response.""" - return _cart_payments_main_panel_sx(ctx) + page_config = ctx.get("page_config") + pc_data = None + if page_config: + pc_data = { + "sumup_api_key": bool(getattr(page_config, "sumup_api_key", None)), + "sumup_merchant_code": getattr(page_config, "sumup_merchant_code", None) or "", + "sumup_checkout_prefix": getattr(page_config, "sumup_checkout_prefix", None) or "", + } + return sx_call("cart-payments-content", + page_config=SxExpr(serialize(pc_data)) if pc_data else None) diff --git a/cart/sxc/pages/__init__.py b/cart/sxc/pages/__init__.py index 710e6ed..b41a861 100644 --- a/cart/sxc/pages/__init__.py +++ b/cart/sxc/pages/__init__.py @@ -90,20 +90,177 @@ def _register_cart_helpers() -> None: }) +# --------------------------------------------------------------------------- +# Serialization helpers +# --------------------------------------------------------------------------- + +def _serialize_cart_item(item: Any) -> dict: + """Serialize a cart item + product for SX defcomps.""" + from quart import url_for + from shared.infrastructure.urls import market_product_url + + p = item.product if hasattr(item, "product") else item + slug = p.slug if hasattr(p, "slug") else "" + unit_price = getattr(p, "special_price", None) or getattr(p, "regular_price", None) + currency = getattr(p, "regular_price_currency", "GBP") or "GBP" + return { + "slug": slug, + "title": p.title if hasattr(p, "title") else "", + "image": p.image if hasattr(p, "image") else None, + "brand": getattr(p, "brand", None), + "is_deleted": getattr(item, "is_deleted", False), + "unit_price": float(unit_price) if unit_price else None, + "special_price": float(p.special_price) if getattr(p, "special_price", None) else None, + "regular_price": float(p.regular_price) if getattr(p, "regular_price", None) else None, + "currency": currency, + "quantity": item.quantity, + "product_id": p.id, + "product_url": market_product_url(slug), + "qty_url": url_for("cart_global.update_quantity", product_id=p.id), + } + + +def _serialize_cal_entry(e: Any) -> dict: + """Serialize a calendar entry for SX defcomps.""" + name = getattr(e, "name", None) or getattr(e, "calendar_name", "") + start = e.start_at if hasattr(e, "start_at") else "" + end = getattr(e, "end_at", None) + cost = getattr(e, "cost", 0) or 0 + end_str = f" \u2013 {end}" if end else "" + return { + "name": name, + "date_str": f"{start}{end_str}", + "cost": float(cost), + } + + +def _serialize_ticket_group(tg: Any) -> dict: + """Serialize a ticket group for SX defcomps.""" + name = tg.entry_name if hasattr(tg, "entry_name") else tg.get("entry_name", "") + tt_name = tg.ticket_type_name if hasattr(tg, "ticket_type_name") else tg.get("ticket_type_name", "") + price = tg.price if hasattr(tg, "price") else tg.get("price", 0) + quantity = tg.quantity if hasattr(tg, "quantity") else tg.get("quantity", 0) + line_total = tg.line_total if hasattr(tg, "line_total") else tg.get("line_total", 0) + entry_id = tg.entry_id if hasattr(tg, "entry_id") else tg.get("entry_id", "") + tt_id = tg.ticket_type_id if hasattr(tg, "ticket_type_id") else tg.get("ticket_type_id", "") + start_at = tg.entry_start_at if hasattr(tg, "entry_start_at") else tg.get("entry_start_at") + end_at = tg.entry_end_at if hasattr(tg, "entry_end_at") else tg.get("entry_end_at") + + date_str = start_at.strftime("%-d %b %Y, %H:%M") if start_at else "" + if end_at: + date_str += f" \u2013 {end_at.strftime('%-d %b %Y, %H:%M')}" + + return { + "entry_name": name, + "ticket_type_name": tt_name or None, + "price": float(price or 0), + "quantity": quantity, + "line_total": float(line_total or 0), + "entry_id": entry_id, + "ticket_type_id": tt_id or None, + "date_str": date_str, + } + + +def _serialize_page_group(grp: Any) -> dict: + """Serialize a page group for SX defcomps.""" + post = grp.get("post") if isinstance(grp, dict) else getattr(grp, "post", None) + cart_items = grp.get("cart_items", []) if isinstance(grp, dict) else getattr(grp, "cart_items", []) + cal_entries = grp.get("calendar_entries", []) if isinstance(grp, dict) else getattr(grp, "calendar_entries", []) + tickets = grp.get("tickets", []) if isinstance(grp, dict) else getattr(grp, "tickets", []) + + if not cart_items and not cal_entries and not tickets: + return None + + post_data = None + if post: + post_data = { + "slug": post.slug if hasattr(post, "slug") else post.get("slug", ""), + "title": post.title if hasattr(post, "title") else post.get("title", ""), + "feature_image": post.feature_image if hasattr(post, "feature_image") else post.get("feature_image"), + } + market_place = grp.get("market_place") if isinstance(grp, dict) else getattr(grp, "market_place", None) + mp_data = None + if market_place: + mp_data = {"name": market_place.name if hasattr(market_place, "name") else market_place.get("name", "")} + + return { + "post": post_data, + "product_count": grp.get("product_count", 0) if isinstance(grp, dict) else getattr(grp, "product_count", 0), + "calendar_count": grp.get("calendar_count", 0) if isinstance(grp, dict) else getattr(grp, "calendar_count", 0), + "ticket_count": grp.get("ticket_count", 0) if isinstance(grp, dict) else getattr(grp, "ticket_count", 0), + "total": float(grp.get("total", 0) if isinstance(grp, dict) else getattr(grp, "total", 0)), + "market_place": mp_data, + } + + +def _build_summary_data(ctx: dict, cart: list, cal_entries: list, tickets: list, + total_fn, cal_total_fn, ticket_total_fn) -> dict: + """Build cart summary data dict for SX defcomps.""" + from quart import g, request, url_for + from shared.infrastructure.urls import login_url + from shared.utils import route_prefix + + product_qty = sum(ci.quantity for ci in cart) if cart else 0 + ticket_qty = len(tickets) if tickets else 0 + item_count = product_qty + ticket_qty + + product_total = total_fn(cart) or 0 + cal_total = cal_total_fn(cal_entries) or 0 + tk_total = ticket_total_fn(tickets) or 0 + grand = float(product_total) + float(cal_total) + float(tk_total) + + symbol = "\u00a3" + if cart and hasattr(cart[0], "product") and getattr(cart[0].product, "regular_price_currency", None): + cur = cart[0].product.regular_price_currency + symbol = "\u00a3" if cur == "GBP" else cur + + user = getattr(g, "user", None) + page_post = ctx.get("page_post") + + result = { + "item_count": item_count, + "grand_total": grand, + "symbol": symbol, + "is_logged_in": bool(user), + } + + if user: + if page_post: + action = url_for("page_cart.page_checkout") + else: + action = url_for("cart_global.checkout") + result["checkout_action"] = route_prefix() + action + result["user_email"] = user.email + else: + result["login_href"] = login_url(request.url) + + return result + + +# --------------------------------------------------------------------------- +# Page helper implementations +# --------------------------------------------------------------------------- + async def _h_overview_content(**kw): from quart import g - from shared.sx.page import get_template_context - from sx.sx_components import _overview_main_panel_sx + from shared.sx.helpers import sx_call, SxExpr + from shared.sx.parser import serialize + from shared.infrastructure.urls import cart_url from bp.cart.services import get_cart_grouped_by_page + page_groups = await get_cart_grouped_by_page(g.s) - ctx = await get_template_context() - return _overview_main_panel_sx(page_groups, ctx) + grp_dicts = [d for d in (_serialize_page_group(grp) for grp in page_groups) if d] + return sx_call("cart-overview-content", + page_groups=SxExpr(serialize(grp_dicts)), + cart_url_base=cart_url("")) async def _h_page_cart_content(page_slug=None, **kw): from quart import g + from shared.sx.helpers import sx_call, SxExpr + from shared.sx.parser import serialize from shared.sx.page import get_template_context - from sx.sx_components import _page_cart_main_panel_sx from bp.cart.services import total, calendar_total, ticket_total from bp.cart.services.page_cart import ( get_cart_for_page, get_calendar_entries_for_page, get_tickets_for_page, @@ -117,21 +274,42 @@ async def _h_page_cart_content(page_slug=None, **kw): ticket_groups = group_tickets(page_tickets) ctx = await get_template_context() - return _page_cart_main_panel_sx( - ctx, cart, cal_entries, page_tickets, ticket_groups, - total, calendar_total, ticket_total, - ) + sd = _build_summary_data(ctx, cart, cal_entries, page_tickets, + total, calendar_total, ticket_total) + + summary_sx = sx_call("cart-summary-from-data", + item_count=sd["item_count"], + grand_total=sd["grand_total"], + symbol=sd["symbol"], + is_logged_in=sd["is_logged_in"], + checkout_action=sd.get("checkout_action"), + login_href=sd.get("login_href"), + user_email=sd.get("user_email")) + + return sx_call("cart-page-cart-content", + cart_items=SxExpr(serialize([_serialize_cart_item(i) for i in cart])), + cal_entries=SxExpr(serialize([_serialize_cal_entry(e) for e in cal_entries])), + ticket_groups=SxExpr(serialize([_serialize_ticket_group(tg) for tg in ticket_groups])), + summary=SxExpr(summary_sx)) async def _h_cart_admin_content(page_slug=None, **kw): - from shared.sx.page import get_template_context - from sx.sx_components import _cart_admin_main_panel_sx - ctx = await get_template_context() - return _cart_admin_main_panel_sx(ctx) + return '(~cart-admin-content)' async def _h_cart_payments_content(page_slug=None, **kw): from shared.sx.page import get_template_context - from sx.sx_components import _cart_payments_main_panel_sx + from shared.sx.helpers import sx_call, SxExpr + from shared.sx.parser import serialize + ctx = await get_template_context() - return _cart_payments_main_panel_sx(ctx) + page_config = ctx.get("page_config") + pc_data = None + if page_config: + pc_data = { + "sumup_api_key": bool(getattr(page_config, "sumup_api_key", None)), + "sumup_merchant_code": getattr(page_config, "sumup_merchant_code", None) or "", + "sumup_checkout_prefix": getattr(page_config, "sumup_checkout_prefix", None) or "", + } + return sx_call("cart-payments-content", + page_config=SxExpr(serialize(pc_data)) if pc_data else None) diff --git a/federation/sx/notifications.sx b/federation/sx/notifications.sx index de56df6..05c0499 100644 --- a/federation/sx/notifications.sx +++ b/federation/sx/notifications.sx @@ -20,3 +20,50 @@ (defcomp ~federation-notifications-page (&key notifs) (h1 :class "text-2xl font-bold mb-6" "Notifications") notifs) + +;; Assembled notification card — replaces Python _notification_sx +(defcomp ~federation-notification-from-data (&key notif) + (let* ((from-name (or (get notif "from_actor_name") "?")) + (from-username (or (get notif "from_actor_username") "")) + (from-domain (or (get notif "from_actor_domain") "")) + (from-icon (get notif "from_actor_icon")) + (ntype (or (get notif "notification_type") "")) + (preview (get notif "target_content_preview")) + (created (or (get notif "created_at_formatted") "")) + (read (get notif "read")) + (app-domain (or (get notif "app_domain") "")) + (border (if (not read) " border-l-4 border-l-stone-400" "")) + (initial (if (and (not from-icon) from-name) + (upper (slice from-name 0 1)) "?")) + (action-text (cond + ((= ntype "follow") (str "followed you" + (if (and app-domain (!= app-domain "federation")) + (str " on " (escape app-domain)) ""))) + ((= ntype "like") "liked your post") + ((= ntype "boost") "boosted your post") + ((= ntype "mention") "mentioned you") + ((= ntype "reply") "replied to your post") + (true "")))) + (~federation-notification-card + :cls (str "bg-white rounded-lg shadow-sm border border-stone-200 p-4" border) + :avatar (~avatar + :src from-icon + :cls (if from-icon "w-8 h-8 rounded-full" + "w-8 h-8 rounded-full bg-stone-300 flex items-center justify-center text-stone-600 font-bold text-xs") + :initial (when (not from-icon) initial)) + :from-name (escape from-name) + :from-username (escape from-username) + :from-domain (if from-domain (str "@" (escape from-domain)) "") + :action-text action-text + :preview (when preview (~federation-notification-preview :preview (escape preview))) + :time created))) + +;; Assembled notifications content — replaces Python _notifications_content_sx +(defcomp ~federation-notifications-content (&key notifications) + (~federation-notifications-page + :notifs (if (empty? notifications) + (~empty-state :message "No notifications yet." :cls "text-stone-500") + (~federation-notifications-list + :items (map (lambda (n) + (~federation-notification-from-data :notif n)) + notifications))))) diff --git a/federation/sx/profile.sx b/federation/sx/profile.sx index 9ce33e6..16108a0 100644 --- a/federation/sx/profile.sx +++ b/federation/sx/profile.sx @@ -53,3 +53,40 @@ (defcomp ~federation-profile-summary-text (&key text) (p :class "mt-2" text)) + +;; Assembled actor timeline content — replaces Python _actor_timeline_content_sx +(defcomp ~federation-actor-timeline-content (&key remote-actor items is-following actor) + (let* ((display-name (or (get remote-actor "display_name") (get remote-actor "preferred_username") "")) + (icon-url (get remote-actor "icon_url")) + (summary (get remote-actor "summary")) + (actor-url (or (get remote-actor "actor_url") "")) + (csrf (csrf-token)) + (initial (if (and (not icon-url) display-name) + (upper (slice display-name 0 1)) "?"))) + (~federation-actor-timeline-layout + :header (~federation-actor-profile-header + :avatar (~avatar + :src icon-url + :cls (if icon-url "w-16 h-16 rounded-full" + "w-16 h-16 rounded-full bg-stone-300 flex items-center justify-center text-stone-600 font-bold text-xl") + :initial (when (not icon-url) initial)) + :display-name (escape display-name) + :username (escape (or (get remote-actor "preferred_username") "")) + :domain (escape (or (get remote-actor "domain") "")) + :summary (when summary (~federation-profile-summary :summary summary)) + :follow (when actor + (if is-following + (~federation-follow-form + :action (url-for "social.unfollow") :csrf csrf :actor-url actor-url + :label "Unfollow" + :cls "border border-stone-300 rounded px-4 py-2 hover:bg-stone-100") + (~federation-follow-form + :action (url-for "social.follow") :csrf csrf :actor-url actor-url + :label "Follow" + :cls "bg-stone-800 text-white rounded px-4 py-2 hover:bg-stone-700")))) + :timeline (~federation-timeline-items + :items items :timeline-type "actor" :actor actor + :next-url (when (not (empty? items)) + (url-for "social.actor_timeline_page" + :id (get remote-actor "id") + :before (get (last items) "before_cursor"))))))) diff --git a/federation/sx/search.sx b/federation/sx/search.sx index ad8d2c8..9ead36d 100644 --- a/federation/sx/search.sx +++ b/federation/sx/search.sx @@ -60,3 +60,99 @@ (h1 :class "text-2xl font-bold mb-6" title " " (span :class "text-stone-400 font-normal" count-str)) (div :id "actor-list" items)) + +;; --------------------------------------------------------------------------- +;; Assembled actor card — replaces Python _actor_card_sx +;; --------------------------------------------------------------------------- + +(defcomp ~federation-actor-card-from-data (&key a actor followed-urls list-type) + (let* ((display-name (or (get a "display_name") (get a "preferred_username") "")) + (username (or (get a "preferred_username") "")) + (domain (or (get a "domain") "")) + (icon-url (get a "icon_url")) + (actor-url (or (get a "actor_url") "")) + (summary (get a "summary")) + (aid (get a "id")) + (safe-id (replace (replace actor-url "/" "_") ":" "_")) + (initial (if (and (not icon-url) (or display-name username)) + (upper (slice (or display-name username) 0 1)) "?")) + (csrf (csrf-token)) + (is-followed (contains? (or followed-urls (list)) actor-url))) + (~federation-actor-card + :cls "bg-white rounded-lg shadow-sm border border-stone-200 p-4 mb-3 flex items-center gap-4" + :id (str "actor-" safe-id) + :avatar (~avatar + :src icon-url + :cls (if icon-url "w-12 h-12 rounded-full" + "w-12 h-12 rounded-full bg-stone-300 flex items-center justify-center text-stone-600 font-bold") + :initial (when (not icon-url) initial)) + :name (if (and (or (= list-type "following") (= list-type "search")) aid) + (~federation-actor-name-link + :href (url-for "social.defpage_actor_timeline" :id aid) + :name (escape display-name)) + (~federation-actor-name-link-external + :href (str "https://" domain "/@" username) + :name (escape display-name))) + :username (escape username) + :domain (escape domain) + :summary (when summary (~federation-actor-summary :summary summary)) + :button (when actor + (if (or (= list-type "following") is-followed) + (~federation-unfollow-button + :action (url-for "social.unfollow") :csrf csrf :actor-url actor-url) + (~federation-follow-button + :action (url-for "social.follow") :csrf csrf :actor-url actor-url + :label (if (= list-type "followers") "Follow Back" "Follow"))))))) + +;; Assembled search content — replaces Python _search_content_sx +(defcomp ~federation-search-content (&key query actors total followed-urls actor) + (~federation-search-page + :search-url (url-for "social.defpage_search") + :search-page-url (url-for "social.search_page") + :query (escape (or query "")) + :info (cond + ((and query (> total 0)) + (~federation-search-info + :cls "text-sm text-stone-500 mb-4" + :text (str total " result" (pluralize total) " for " (escape query)))) + (query + (~federation-search-info + :cls "text-stone-500 mb-4" + :text (str "No results found for " (escape query)))) + (true nil)) + :results (when (not (empty? actors)) + (<> + (map (lambda (a) + (~federation-actor-card-from-data + :a a :actor actor :followed-urls followed-urls :list-type "search")) + actors) + (when (>= (len actors) 20) + (~federation-scroll-sentinel + :url (url-for "social.search_page" :q query :page 2))))))) + +;; Assembled following/followers content — replaces Python _following_content_sx etc. +(defcomp ~federation-following-content (&key actors total actor) + (~federation-actor-list-page + :title "Following" :count-str (str "(" total ")") + :items (when (not (empty? actors)) + (<> + (map (lambda (a) + (~federation-actor-card-from-data + :a a :actor actor :followed-urls (list) :list-type "following")) + actors) + (when (>= (len actors) 20) + (~federation-scroll-sentinel + :url (url-for "social.following_list_page" :page 2))))))) + +(defcomp ~federation-followers-content (&key actors total followed-urls actor) + (~federation-actor-list-page + :title "Followers" :count-str (str "(" total ")") + :items (when (not (empty? actors)) + (<> + (map (lambda (a) + (~federation-actor-card-from-data + :a a :actor actor :followed-urls followed-urls :list-type "followers")) + actors) + (when (>= (len actors) 20) + (~federation-scroll-sentinel + :url (url-for "social.followers_list_page" :page 2))))))) diff --git a/federation/sx/social.sx b/federation/sx/social.sx index b7b59ae..1f3fa8c 100644 --- a/federation/sx/social.sx +++ b/federation/sx/social.sx @@ -110,3 +110,129 @@ (option :value "unlisted" "Unlisted") (option :value "followers" "Followers only")) (button :type "submit" :class "bg-stone-800 text-white px-6 py-2 rounded hover:bg-stone-700" "Publish")))) + +;; --------------------------------------------------------------------------- +;; Assembled social nav — replaces Python _social_nav_sx +;; --------------------------------------------------------------------------- + +(defcomp ~federation-social-nav (&key actor) + (if (not actor) + (~federation-nav-choose-username :url (url-for "identity.choose_username_form")) + (let* ((rp (request-path)) + (links (list + (dict :endpoint "social.defpage_home_timeline" :label "Timeline") + (dict :endpoint "social.defpage_public_timeline" :label "Public") + (dict :endpoint "social.defpage_compose_form" :label "Compose") + (dict :endpoint "social.defpage_following_list" :label "Following") + (dict :endpoint "social.defpage_followers_list" :label "Followers") + (dict :endpoint "social.defpage_search" :label "Search")))) + (~federation-nav-bar + :items (<> + (map (lambda (lnk) + (let* ((href (url-for (get lnk "endpoint"))) + (bold (if (= rp href) " font-bold" ""))) + (a :href href + :class (str "px-2 py-1 rounded hover:bg-stone-200" bold) + (get lnk "label")))) + links) + (let* ((notif-url (url-for "social.defpage_notifications")) + (notif-bold (if (= rp notif-url) " font-bold" ""))) + (~federation-nav-notification-link + :href notif-url + :cls (str "px-2 py-1 rounded hover:bg-stone-200 relative" notif-bold) + :count-url (url-for "social.notification_count"))) + (a :href (url-for "activitypub.actor_profile" :username (get actor "preferred_username")) + :class "px-2 py-1 rounded hover:bg-stone-200" + (str "@" (get actor "preferred_username")))))))) + +;; --------------------------------------------------------------------------- +;; Assembled post card — replaces Python _post_card_sx +;; --------------------------------------------------------------------------- + +(defcomp ~federation-post-card-from-data (&key item actor) + (let* ((boosted-by (get item "boosted_by")) + (actor-icon (get item "actor_icon")) + (actor-name (or (get item "actor_name") "?")) + (actor-username (or (get item "actor_username") "")) + (actor-domain (or (get item "actor_domain") "")) + (content (or (get item "content") "")) + (summary (get item "summary")) + (published (or (get item "published") "")) + (url (get item "url")) + (post-type (or (get item "post_type") "")) + (oid (or (get item "object_id") "")) + (safe-id (replace (replace oid "/" "_") ":" "_")) + (initial (if (and (not actor-icon) actor-name) + (upper (slice actor-name 0 1)) "?"))) + (~federation-post-card + :boost (when boosted-by (~federation-boost-label :name (escape boosted-by))) + :avatar (~avatar + :src actor-icon + :cls (if actor-icon "w-10 h-10 rounded-full" + "w-10 h-10 rounded-full bg-stone-300 flex items-center justify-center text-stone-600 font-bold text-sm") + :initial (when (not actor-icon) initial)) + :actor-name (escape actor-name) + :actor-username (escape actor-username) + :domain (if actor-domain (str "@" (escape actor-domain)) "") + :time published + :content (if summary + (~federation-content :content content :summary (escape summary)) + (~federation-content :content content)) + :original (when (and url (= post-type "remote")) + (~federation-original-link :url url)) + :interactions (when actor + (let* ((csrf (csrf-token)) + (liked (get item "liked_by_me")) + (boosted-me (get item "boosted_by_me")) + (lcount (or (get item "like_count") 0)) + (bcount (or (get item "boost_count") 0)) + (ainbox (or (get item "author_inbox") "")) + (target (str "#interactions-" safe-id))) + (div :id (str "interactions-" safe-id) + (~federation-interaction-buttons + :like (~federation-like-form + :action (url-for (if liked "social.unlike" "social.like")) + :target target :oid oid :ainbox ainbox :csrf csrf + :cls (str "flex items-center gap-1 " (if liked "text-red-500 hover:text-red-600" "hover:text-red-500")) + :icon (if liked "\u2665" "\u2661") :count (str lcount)) + :boost (~federation-boost-form + :action (url-for (if boosted-me "social.unboost" "social.boost")) + :target target :oid oid :ainbox ainbox :csrf csrf + :cls (str "flex items-center gap-1 " (if boosted-me "text-green-600 hover:text-green-700" "hover:text-green-600")) + :count (str bcount)) + :reply (when oid + (~federation-reply-link + :url (url-for "social.defpage_compose_form" :reply-to oid)))))))))) + +;; --------------------------------------------------------------------------- +;; Assembled timeline items — replaces Python _timeline_items_sx +;; --------------------------------------------------------------------------- + +(defcomp ~federation-timeline-items (&key items timeline-type actor next-url) + (<> + (map (lambda (item) + (~federation-post-card-from-data :item item :actor actor)) + items) + (when next-url + (~federation-scroll-sentinel :url next-url)))) + +;; Assembled timeline content — replaces Python _timeline_content_sx +(defcomp ~federation-timeline-content (&key items timeline-type actor) + (let* ((label (if (= timeline-type "home") "Home" "Public"))) + (~federation-timeline-page + :label label + :compose (when actor + (~federation-compose-button :url (url-for "social.defpage_compose_form"))) + :timeline (~federation-timeline-items + :items items :timeline-type timeline-type :actor actor + :next-url (when (not (empty? items)) + (url-for (str "social." timeline-type "_timeline_page") + :before (get (last items) "before_cursor"))))))) + +;; Assembled compose content — replaces Python _compose_content_sx +(defcomp ~federation-compose-content (&key reply-to) + (~federation-compose-form + :action (url-for "social.compose_submit") + :csrf (csrf-token) + :reply (when reply-to + (~federation-compose-reply :reply-to (escape reply-to))))) diff --git a/federation/sx/sx_components.py b/federation/sx/sx_components.py index 4435c3d..9f9371d 100644 --- a/federation/sx/sx_components.py +++ b/federation/sx/sx_components.py @@ -1,8 +1,10 @@ """ Federation service s-expression page components. -Renders social timeline, compose, search, following/followers, notifications, -actor profiles, login, and username selection pages. +Page helpers now call assembled defcomps in .sx files. This file contains +only functions still called directly from route handlers: full-page renders +(login, choose-username, profile) and POST fragment renderers (interaction +buttons, actor cards, pagination items). """ from __future__ import annotations @@ -23,63 +25,69 @@ load_service_components(os.path.dirname(os.path.dirname(__file__)), # --------------------------------------------------------------------------- -# Social header nav +# Serialization helpers (shared with pages/__init__.py) # --------------------------------------------------------------------------- -def _social_nav_sx(actor: Any) -> str: - """Build the social header nav bar content.""" - from quart import url_for, request - +def _serialize_actor(actor) -> dict | None: if not actor: - choose_url = url_for("identity.choose_username_form") - return sx_call("federation-nav-choose-username", url=choose_url) - - links = [ - ("social.defpage_home_timeline", "Timeline"), - ("social.defpage_public_timeline", "Public"), - ("social.defpage_compose_form", "Compose"), - ("social.defpage_following_list", "Following"), - ("social.defpage_followers_list", "Followers"), - ("social.defpage_search", "Search"), - ] - - parts = [] - for endpoint, label in links: - href = url_for(endpoint) - bold = " font-bold" if request.path == href else "" - cls = f"px-2 py-1 rounded hover:bg-stone-200{bold}" - parts.append(f'(a :href {serialize(href)} :class {serialize(cls)} {serialize(label)})') - - # Notifications with live badge - notif_url = url_for("social.defpage_notifications") - notif_count_url = url_for("social.notification_count") - notif_bold = " font-bold" if request.path == notif_url else "" - parts.append(sx_call( - "federation-nav-notification-link", - href=notif_url, - cls=f"px-2 py-1 rounded hover:bg-stone-200 relative{notif_bold}", - count_url=notif_count_url, - )) - - # Profile link - profile_url = url_for("activitypub.actor_profile", username=actor.preferred_username) - parts.append(f'(a :href {serialize(profile_url)} :class "px-2 py-1 rounded hover:bg-stone-200" {serialize("@" + actor.preferred_username)})') - - items_sx = "(<> " + " ".join(parts) + ")" - return sx_call("federation-nav-bar", items=SxExpr(items_sx)) + return None + return { + "id": actor.id, + "preferred_username": actor.preferred_username, + "display_name": getattr(actor, "display_name", None), + "icon_url": getattr(actor, "icon_url", None), + "summary": getattr(actor, "summary", None), + "actor_url": getattr(actor, "actor_url", ""), + "domain": getattr(actor, "domain", ""), + } -def _social_header_sx(actor: Any) -> str: - """Build the social section header row.""" - nav_sx = _social_nav_sx(actor) - return sx_call("federation-social-header", nav=SxExpr(nav_sx)) +def _serialize_timeline_item(item) -> dict: + published = getattr(item, "published", None) + return { + "object_id": getattr(item, "object_id", "") or "", + "author_inbox": getattr(item, "author_inbox", "") or "", + "actor_icon": getattr(item, "actor_icon", None), + "actor_name": getattr(item, "actor_name", "?"), + "actor_username": getattr(item, "actor_username", ""), + "actor_domain": getattr(item, "actor_domain", ""), + "content": getattr(item, "content", ""), + "summary": getattr(item, "summary", None), + "published": published.strftime("%b %d, %H:%M") if published else "", + "before_cursor": published.isoformat() if published else "", + "url": getattr(item, "url", None), + "post_type": getattr(item, "post_type", ""), + "boosted_by": getattr(item, "boosted_by", None), + "like_count": getattr(item, "like_count", 0) or 0, + "boost_count": getattr(item, "boost_count", 0) or 0, + "liked_by_me": getattr(item, "liked_by_me", False), + "boosted_by_me": getattr(item, "boosted_by_me", False), + } +def _serialize_remote_actor(a) -> dict: + return { + "id": getattr(a, "id", None), + "display_name": getattr(a, "display_name", None) or getattr(a, "preferred_username", ""), + "preferred_username": getattr(a, "preferred_username", ""), + "domain": getattr(a, "domain", ""), + "icon_url": getattr(a, "icon_url", None), + "actor_url": getattr(a, "actor_url", ""), + "summary": getattr(a, "summary", None), + } + + +# --------------------------------------------------------------------------- +# Social page shell +# --------------------------------------------------------------------------- + def _social_page(ctx: dict, actor: Any, *, content: str, title: str = "Rose Ash", meta_html: str = "") -> str: - """Render a social page with header and content.""" + actor_data = _serialize_actor(actor) + nav = sx_call("federation-social-nav", + actor=SxExpr(serialize(actor_data)) if actor_data else None) + social_hdr = sx_call("federation-social-header", nav=SxExpr(nav)) hdr = root_header_sx(ctx) - social_hdr = _social_header_sx(actor) child = header_child_sx(social_hdr) header_rows = "(<> " + hdr + " " + child + ")" return full_page_sx(ctx, header_rows=header_rows, content=content, @@ -87,562 +95,32 @@ def _social_page(ctx: dict, actor: Any, *, content: str, # --------------------------------------------------------------------------- -# Post card -# --------------------------------------------------------------------------- - -def _interaction_buttons_sx(item: Any, actor: Any) -> str: - """Render like/boost/reply buttons for a post.""" - from shared.browser.app.csrf import generate_csrf_token - from quart import url_for - - oid = getattr(item, "object_id", "") or "" - ainbox = getattr(item, "author_inbox", "") or "" - lcount = getattr(item, "like_count", 0) or 0 - bcount = getattr(item, "boost_count", 0) or 0 - liked = getattr(item, "liked_by_me", False) - boosted = getattr(item, "boosted_by_me", False) - csrf = generate_csrf_token() - - safe_id = oid.replace("/", "_").replace(":", "_") - target = f"#interactions-{safe_id}" - - if liked: - like_action = url_for("social.unlike") - like_cls = "text-red-500 hover:text-red-600" - like_icon = "\u2665" - else: - like_action = url_for("social.like") - like_cls = "hover:text-red-500" - like_icon = "\u2661" - - if boosted: - boost_action = url_for("social.unboost") - boost_cls = "text-green-600 hover:text-green-700" - else: - boost_action = url_for("social.boost") - boost_cls = "hover:text-green-600" - - reply_url = url_for("social.defpage_compose_form", reply_to=oid) if oid else "" - reply_sx = sx_call("federation-reply-link", url=reply_url) if reply_url else "" - - like_form = sx_call( - "federation-like-form", - action=like_action, target=target, oid=oid, ainbox=ainbox, - csrf=csrf, cls=f"flex items-center gap-1 {like_cls}", - icon=like_icon, count=str(lcount), - ) - - boost_form = sx_call( - "federation-boost-form", - action=boost_action, target=target, oid=oid, ainbox=ainbox, - csrf=csrf, cls=f"flex items-center gap-1 {boost_cls}", - count=str(bcount), - ) - - return sx_call( - "federation-interaction-buttons", - like=SxExpr(like_form), - boost=SxExpr(boost_form), - reply=SxExpr(reply_sx) if reply_sx else None, - ) - - -def _post_card_sx(item: Any, actor: Any) -> str: - """Render a single timeline post card.""" - boosted_by = getattr(item, "boosted_by", None) - actor_icon = getattr(item, "actor_icon", None) - actor_name = getattr(item, "actor_name", "?") - actor_username = getattr(item, "actor_username", "") - actor_domain = getattr(item, "actor_domain", "") - content = getattr(item, "content", "") - summary = getattr(item, "summary", None) - published = getattr(item, "published", None) - url = getattr(item, "url", None) - post_type = getattr(item, "post_type", "") - - boost_sx = sx_call( - "federation-boost-label", name=str(escape(boosted_by)), - ) if boosted_by else "" - - initial = actor_name[0].upper() if (not actor_icon and actor_name) else "?" - avatar = sx_call( - "avatar", src=actor_icon or None, - cls="w-10 h-10 rounded-full" if actor_icon else "w-10 h-10 rounded-full bg-stone-300 flex items-center justify-center text-stone-600 font-bold text-sm", - initial=None if actor_icon else initial, - ) - - domain_str = f"@{escape(actor_domain)}" if actor_domain else "" - time_str = published.strftime("%b %d, %H:%M") if published else "" - - if summary: - content_sx = sx_call( - "federation-content", - content=content, summary=str(escape(summary)), - ) - else: - content_sx = sx_call("federation-content", content=content) - - original_sx = "" - if url and post_type == "remote": - original_sx = sx_call("federation-original-link", url=url) - - interactions_sx = "" - if actor: - oid = getattr(item, "object_id", "") or "" - safe_id = oid.replace("/", "_").replace(":", "_") - interactions_sx = f'(div :id {serialize(f"interactions-{safe_id}")} {_interaction_buttons_sx(item, actor)})' - - return sx_call( - "federation-post-card", - boost=SxExpr(boost_sx) if boost_sx else None, - avatar=SxExpr(avatar), - actor_name=str(escape(actor_name)), - actor_username=str(escape(actor_username)), - domain=domain_str, time=time_str, - content=SxExpr(content_sx), - original=SxExpr(original_sx) if original_sx else None, - interactions=SxExpr(interactions_sx) if interactions_sx else None, - ) - - -# --------------------------------------------------------------------------- -# Timeline items (pagination fragment) -# --------------------------------------------------------------------------- - -def _timeline_items_sx(items: list, timeline_type: str, actor: Any, - actor_id: int | None = None) -> str: - """Render timeline items with infinite scroll sentinel.""" - from quart import url_for - - parts = [_post_card_sx(item, actor) for item in items] - - if items: - last = items[-1] - before = last.published.isoformat() if last.published else "" - if timeline_type == "actor" and actor_id is not None: - next_url = url_for("social.actor_timeline_page", id=actor_id, before=before) - else: - next_url = url_for(f"social.{timeline_type}_timeline_page", before=before) - parts.append(sx_call("federation-scroll-sentinel", url=next_url)) - - return "(<> " + " ".join(parts) + ")" if parts else "" - - -# --------------------------------------------------------------------------- -# Search results (pagination fragment) -# --------------------------------------------------------------------------- - -def _actor_card_sx(a: Any, actor: Any, followed_urls: set, - *, list_type: str = "search") -> str: - """Render a single actor card with follow/unfollow button.""" - from shared.browser.app.csrf import generate_csrf_token - from quart import url_for - - csrf = generate_csrf_token() - display_name = getattr(a, "display_name", None) or getattr(a, "preferred_username", "") - username = getattr(a, "preferred_username", "") - domain = getattr(a, "domain", "") - icon_url = getattr(a, "icon_url", None) - actor_url = getattr(a, "actor_url", "") - summary = getattr(a, "summary", None) - aid = getattr(a, "id", None) - - safe_id = actor_url.replace("/", "_").replace(":", "_") - - initial = (display_name or username)[0].upper() if (not icon_url and (display_name or username)) else "?" - avatar = sx_call( - "avatar", src=icon_url or None, - cls="w-12 h-12 rounded-full" if icon_url else "w-12 h-12 rounded-full bg-stone-300 flex items-center justify-center text-stone-600 font-bold", - initial=None if icon_url else initial, - ) - - # Name link - if (list_type in ("following", "search")) and aid: - name_sx = sx_call( - "federation-actor-name-link", - href=url_for("social.defpage_actor_timeline", id=aid), - name=str(escape(display_name)), - ) - else: - name_sx = sx_call( - "federation-actor-name-link-external", - href=f"https://{domain}/@{username}", - name=str(escape(display_name)), - ) - - summary_sx = sx_call("federation-actor-summary", summary=summary) if summary else "" - - # Follow/unfollow button - button_sx = "" - if actor: - is_followed = actor_url in (followed_urls or set()) - if list_type == "following" or is_followed: - button_sx = sx_call( - "federation-unfollow-button", - action=url_for("social.unfollow"), csrf=csrf, actor_url=actor_url, - ) - else: - label = "Follow Back" if list_type == "followers" else "Follow" - button_sx = sx_call( - "federation-follow-button", - action=url_for("social.follow"), csrf=csrf, actor_url=actor_url, label=label, - ) - - return sx_call( - "federation-actor-card", - cls="bg-white rounded-lg shadow-sm border border-stone-200 p-4 mb-3 flex items-center gap-4", - id=f"actor-{safe_id}", - avatar=SxExpr(avatar), - name=SxExpr(name_sx), - username=str(escape(username)), domain=str(escape(domain)), - summary=SxExpr(summary_sx) if summary_sx else None, - button=SxExpr(button_sx) if button_sx else None, - ) - - -def _search_results_sx(actors: list, query: str, page: int, - followed_urls: set, actor: Any) -> str: - """Render search results with pagination sentinel.""" - from quart import url_for - - parts = [_actor_card_sx(a, actor, followed_urls, list_type="search") for a in actors] - if len(actors) >= 20: - next_url = url_for("social.search_page", q=query, page=page + 1) - parts.append(sx_call("federation-scroll-sentinel", url=next_url)) - return "(<> " + " ".join(parts) + ")" if parts else "" - - -def _actor_list_items_sx(actors: list, page: int, list_type: str, - followed_urls: set, actor: Any) -> str: - """Render actor list items (following/followers) with pagination sentinel.""" - from quart import url_for - - parts = [_actor_card_sx(a, actor, followed_urls, list_type=list_type) for a in actors] - if len(actors) >= 20: - next_url = url_for(f"social.{list_type}_list_page", page=page + 1) - parts.append(sx_call("federation-scroll-sentinel", url=next_url)) - return "(<> " + " ".join(parts) + ")" if parts else "" - - -# --------------------------------------------------------------------------- -# Notification card -# --------------------------------------------------------------------------- - -def _notification_sx(notif: Any) -> str: - """Render a single notification.""" - from_name = getattr(notif, "from_actor_name", "?") - from_username = getattr(notif, "from_actor_username", "") - from_domain = getattr(notif, "from_actor_domain", "") - from_icon = getattr(notif, "from_actor_icon", None) - ntype = getattr(notif, "notification_type", "") - preview = getattr(notif, "target_content_preview", None) - created = getattr(notif, "created_at", None) - read = getattr(notif, "read", True) - app_domain = getattr(notif, "app_domain", "") - - border = " border-l-4 border-l-stone-400" if not read else "" - - initial = from_name[0].upper() if (not from_icon and from_name) else "?" - avatar = sx_call( - "avatar", src=from_icon or None, - cls="w-8 h-8 rounded-full" if from_icon else "w-8 h-8 rounded-full bg-stone-300 flex items-center justify-center text-stone-600 font-bold text-xs", - initial=None if from_icon else initial, - ) - - domain_str = f"@{escape(from_domain)}" if from_domain else "" - - type_map = { - "follow": "followed you", - "like": "liked your post", - "boost": "boosted your post", - "mention": "mentioned you", - "reply": "replied to your post", - } - action = type_map.get(ntype, "") - if ntype == "follow" and app_domain and app_domain != "federation": - action += f" on {escape(app_domain)}" - - preview_sx = sx_call( - "federation-notification-preview", preview=str(escape(preview)), - ) if preview else "" - time_str = created.strftime("%b %d, %H:%M") if created else "" - - return sx_call( - "federation-notification-card", - cls=f"bg-white rounded-lg shadow-sm border border-stone-200 p-4{border}", - avatar=SxExpr(avatar), - from_name=str(escape(from_name)), - from_username=str(escape(from_username)), - from_domain=domain_str, action_text=action, - preview=SxExpr(preview_sx) if preview_sx else None, - time=time_str, - ) - - -# --------------------------------------------------------------------------- -# Public API: Home page +# Public API: Full page renders # --------------------------------------------------------------------------- async def render_federation_home(ctx: dict) -> str: - """Full page: federation home (minimal).""" hdr = root_header_sx(ctx) return full_page_sx(ctx, header_rows=hdr) -# --------------------------------------------------------------------------- -# Public API: Login -# --------------------------------------------------------------------------- - async def render_login_page(ctx: dict) -> str: - """Full page: federation login form.""" - from shared.browser.app.csrf import generate_csrf_token - from quart import url_for - error = ctx.get("error", "") email = ctx.get("email", "") - action = url_for("auth.start_login") - csrf = generate_csrf_token() - - error_sx = sx_call("auth-error-banner", error=error) if error else "" - - content = sx_call( - "auth-login-form", - error=SxExpr(error_sx) if error_sx else None, - action=action, csrf_token=csrf, - email=str(escape(email)), - ) - - return _social_page(ctx, None, content=content, - title="Login \u2014 Rose Ash") + content = sx_call("account-login-content", + error=error or None, email=str(escape(email))) + return _social_page(ctx, None, content=content, title="Login \u2014 Rose Ash") async def render_check_email_page(ctx: dict) -> str: - """Full page: check email after magic link sent.""" email = ctx.get("email", "") email_error = ctx.get("email_error") - - error_sx = sx_call( - "auth-check-email-error", error=str(escape(email_error)), - ) if email_error else "" - - content = sx_call( - "auth-check-email", - email=str(escape(email)), - error=SxExpr(error_sx) if error_sx else None, - ) - + content = sx_call("account-check-email-content", + email=str(escape(email)), email_error=email_error) return _social_page(ctx, None, content=content, title="Check your email \u2014 Rose Ash") -# --------------------------------------------------------------------------- -# Content builders (used by defpage before_request) -# --------------------------------------------------------------------------- - -def _timeline_content_sx(items: list, timeline_type: str, actor: Any) -> str: - """Build timeline content SX string.""" - from quart import url_for - - label = "Home" if timeline_type == "home" else "Public" - compose_sx = "" - if actor: - compose_url = url_for("social.defpage_compose_form") - compose_sx = sx_call("federation-compose-button", url=compose_url) - - timeline_sx = _timeline_items_sx(items, timeline_type, actor) - - return sx_call( - "federation-timeline-page", - label=label, - compose=SxExpr(compose_sx) if compose_sx else None, - timeline=SxExpr(timeline_sx) if timeline_sx else None, - ) - - -async def render_timeline_items(items: list, timeline_type: str, - actor: Any, actor_id: int | None = None) -> str: - """Pagination fragment: timeline items.""" - return _timeline_items_sx(items, timeline_type, actor, actor_id) - - -def _compose_content_sx(actor: Any, reply_to: str | None) -> str: - """Build compose form content SX string.""" - from shared.browser.app.csrf import generate_csrf_token - from quart import url_for - - csrf = generate_csrf_token() - action = url_for("social.compose_submit") - - reply_sx = "" - if reply_to: - reply_sx = sx_call( - "federation-compose-reply", - reply_to=str(escape(reply_to)), - ) - - return sx_call( - "federation-compose-form", - action=action, csrf=csrf, - reply=SxExpr(reply_sx) if reply_sx else None, - ) - - -def _search_content_sx(query: str, actors: list, total: int, - page: int, followed_urls: set, actor: Any) -> str: - """Build search page content SX string.""" - from quart import url_for - - search_url = url_for("social.defpage_search") - search_page_url = url_for("social.search_page") - - results_sx = _search_results_sx(actors, query, page, followed_urls, actor) - - info_sx = "" - if query and total: - s = "s" if total != 1 else "" - info_sx = sx_call( - "federation-search-info", - cls="text-sm text-stone-500 mb-4", - text=f"{total} result{s} for {escape(query)}", - ) - elif query: - info_sx = sx_call( - "federation-search-info", - cls="text-stone-500 mb-4", - text=f"No results found for {escape(query)}", - ) - - return sx_call( - "federation-search-page", - search_url=search_url, search_page_url=search_page_url, - query=str(escape(query)), - info=SxExpr(info_sx) if info_sx else None, - results=SxExpr(results_sx) if results_sx else None, - ) - - -async def render_search_results(actors: list, query: str, page: int, - followed_urls: set, actor: Any) -> str: - """Pagination fragment: search results.""" - return _search_results_sx(actors, query, page, followed_urls, actor) - - -def _following_content_sx(actors: list, total: int, actor: Any) -> str: - """Build following list content SX string.""" - items_sx = _actor_list_items_sx(actors, 1, "following", set(), actor) - return sx_call( - "federation-actor-list-page", - title="Following", count_str=f"({total})", - items=SxExpr(items_sx) if items_sx else None, - ) - - -async def render_following_items(actors: list, page: int, actor: Any) -> str: - """Pagination fragment: following items.""" - return _actor_list_items_sx(actors, page, "following", set(), actor) - - -def _followers_content_sx(actors: list, total: int, - followed_urls: set, actor: Any) -> str: - """Build followers list content SX string.""" - items_sx = _actor_list_items_sx(actors, 1, "followers", followed_urls, actor) - return sx_call( - "federation-actor-list-page", - title="Followers", count_str=f"({total})", - items=SxExpr(items_sx) if items_sx else None, - ) - - -async def render_followers_items(actors: list, page: int, - followed_urls: set, actor: Any) -> str: - """Pagination fragment: followers items.""" - return _actor_list_items_sx(actors, page, "followers", followed_urls, actor) - - -def _actor_timeline_content_sx(remote_actor: Any, items: list, - is_following: bool, actor: Any) -> str: - """Build actor timeline content SX string.""" - from shared.browser.app.csrf import generate_csrf_token - from quart import url_for - - csrf = generate_csrf_token() - display_name = remote_actor.display_name or remote_actor.preferred_username - icon_url = getattr(remote_actor, "icon_url", None) - summary = getattr(remote_actor, "summary", None) - actor_url = getattr(remote_actor, "actor_url", "") - - initial = display_name[0].upper() if (not icon_url and display_name) else "?" - avatar = sx_call( - "avatar", src=icon_url or None, - cls="w-16 h-16 rounded-full" if icon_url else "w-16 h-16 rounded-full bg-stone-300 flex items-center justify-center text-stone-600 font-bold text-xl", - initial=None if icon_url else initial, - ) - - summary_sx = sx_call("federation-profile-summary", summary=summary) if summary else "" - - follow_sx = "" - if actor: - if is_following: - follow_sx = sx_call( - "federation-follow-form", - action=url_for("social.unfollow"), csrf=csrf, actor_url=actor_url, - label="Unfollow", - cls="border border-stone-300 rounded px-4 py-2 hover:bg-stone-100", - ) - else: - follow_sx = sx_call( - "federation-follow-form", - action=url_for("social.follow"), csrf=csrf, actor_url=actor_url, - label="Follow", - cls="bg-stone-800 text-white rounded px-4 py-2 hover:bg-stone-700", - ) - - timeline_sx = _timeline_items_sx(items, "actor", actor, remote_actor.id) - - header_sx = sx_call( - "federation-actor-profile-header", - avatar=SxExpr(avatar), - display_name=str(escape(display_name)), - username=str(escape(remote_actor.preferred_username)), - domain=str(escape(remote_actor.domain)), - summary=SxExpr(summary_sx) if summary_sx else None, - follow=SxExpr(follow_sx) if follow_sx else None, - ) - - return sx_call( - "federation-actor-timeline-layout", - header=SxExpr(header_sx), - timeline=SxExpr(timeline_sx) if timeline_sx else None, - ) - - -async def render_actor_timeline_items(items: list, actor_id: int, - actor: Any) -> str: - """Pagination fragment: actor timeline items.""" - return _timeline_items_sx(items, "actor", actor, actor_id) - - -def _notifications_content_sx(notifications: list) -> str: - """Build notifications content SX string.""" - if not notifications: - notif_sx = sx_call("empty-state", message="No notifications yet.", - cls="text-stone-500") - else: - items_sx = "(<> " + " ".join(_notification_sx(n) for n in notifications) + ")" - notif_sx = sx_call( - "federation-notifications-list", - items=SxExpr(items_sx), - ) - - return sx_call("federation-notifications-page", notifs=SxExpr(notif_sx)) - - -# --------------------------------------------------------------------------- -# Public API: Choose username -# --------------------------------------------------------------------------- - async def render_choose_username_page(ctx: dict) -> str: - """Full page: choose username form.""" from shared.browser.app.csrf import generate_csrf_token from quart import url_for from shared.config import config @@ -655,7 +133,6 @@ async def render_choose_username_page(ctx: dict) -> str: actor = ctx.get("actor") error_sx = sx_call("auth-error-banner", error=error) if error else "" - content = sx_call( "federation-choose-username", domain=str(escape(ap_domain)), @@ -663,18 +140,12 @@ async def render_choose_username_page(ctx: dict) -> str: csrf=csrf, username=str(escape(username)), check_url=check_url, ) - return _social_page(ctx, actor, content=content, title="Choose Username \u2014 Rose Ash") -# --------------------------------------------------------------------------- -# Public API: Actor profile -# --------------------------------------------------------------------------- - async def render_profile_page(ctx: dict, actor: Any, activities: list, total: int) -> str: - """Full page: actor profile.""" from shared.config import config ap_domain = config().get("ap_domain", "rose-ash.com") @@ -710,11 +181,95 @@ async def render_profile_page(ctx: dict, actor: Any, activities: list, activities_heading=f"Activities ({total})", activities=SxExpr(activities_sx), ) - return _social_page(ctx, actor, content=content, title=f"@{actor.preferred_username} \u2014 Rose Ash") +# --------------------------------------------------------------------------- +# Public API: Pagination fragment renderers +# --------------------------------------------------------------------------- + +async def render_timeline_items(items: list, timeline_type: str, + actor: Any, actor_id: int | None = None) -> str: + from quart import url_for + item_dicts = [_serialize_timeline_item(i) for i in items] + actor_data = _serialize_actor(actor) + + # Build next URL + next_url = None + if items: + last = items[-1] + before = last.published.isoformat() if last.published else "" + if timeline_type == "actor" and actor_id is not None: + next_url = url_for("social.actor_timeline_page", id=actor_id, before=before) + else: + next_url = url_for(f"social.{timeline_type}_timeline_page", before=before) + + return sx_call("federation-timeline-items", + items=SxExpr(serialize(item_dicts)), + timeline_type=timeline_type, + actor=SxExpr(serialize(actor_data)) if actor_data else None, + next_url=next_url) + + +async def render_search_results(actors: list, query: str, page: int, + followed_urls: set, actor: Any) -> str: + from quart import url_for + actor_dicts = [_serialize_remote_actor(a) for a in actors] + actor_data = _serialize_actor(actor) + parts = [] + for ad in actor_dicts: + parts.append(sx_call("federation-actor-card-from-data", + a=SxExpr(serialize(ad)), + actor=SxExpr(serialize(actor_data)) if actor_data else None, + followed_urls=SxExpr(serialize(list(followed_urls))), + list_type="search")) + if len(actors) >= 20: + next_url = url_for("social.search_page", q=query, page=page + 1) + parts.append(sx_call("federation-scroll-sentinel", url=next_url)) + return "(<> " + " ".join(parts) + ")" if parts else "" + + +async def render_following_items(actors: list, page: int, actor: Any) -> str: + from quart import url_for + actor_dicts = [_serialize_remote_actor(a) for a in actors] + actor_data = _serialize_actor(actor) + parts = [] + for ad in actor_dicts: + parts.append(sx_call("federation-actor-card-from-data", + a=SxExpr(serialize(ad)), + actor=SxExpr(serialize(actor_data)) if actor_data else None, + followed_urls=SxExpr(serialize([])), + list_type="following")) + if len(actors) >= 20: + next_url = url_for("social.following_list_page", page=page + 1) + parts.append(sx_call("federation-scroll-sentinel", url=next_url)) + return "(<> " + " ".join(parts) + ")" if parts else "" + + +async def render_followers_items(actors: list, page: int, + followed_urls: set, actor: Any) -> str: + from quart import url_for + actor_dicts = [_serialize_remote_actor(a) for a in actors] + actor_data = _serialize_actor(actor) + parts = [] + for ad in actor_dicts: + parts.append(sx_call("federation-actor-card-from-data", + a=SxExpr(serialize(ad)), + actor=SxExpr(serialize(actor_data)) if actor_data else None, + followed_urls=SxExpr(serialize(list(followed_urls))), + list_type="followers")) + if len(actors) >= 20: + next_url = url_for("social.followers_list_page", page=page + 1) + parts.append(sx_call("federation-scroll-sentinel", url=next_url)) + return "(<> " + " ".join(parts) + ")" if parts else "" + + +async def render_actor_timeline_items(items: list, actor_id: int, + actor: Any) -> str: + return await render_timeline_items(items, "actor", actor, actor_id) + + # --------------------------------------------------------------------------- # Public API: POST handler fragment renderers # --------------------------------------------------------------------------- @@ -723,20 +278,56 @@ def render_interaction_buttons(object_id: str, author_inbox: str, like_count: int, boost_count: int, liked_by_me: bool, boosted_by_me: bool, actor: Any) -> str: - """Render interaction buttons fragment for HTMX POST response.""" - from types import SimpleNamespace - item = SimpleNamespace( - object_id=object_id, - author_inbox=author_inbox, - like_count=like_count, - boost_count=boost_count, - liked_by_me=liked_by_me, - boosted_by_me=boosted_by_me, - ) - return _interaction_buttons_sx(item, actor) + """Render interaction buttons fragment for POST response.""" + from shared.browser.app.csrf import generate_csrf_token + from quart import url_for + + csrf = generate_csrf_token() + safe_id = object_id.replace("/", "_").replace(":", "_") + target = f"#interactions-{safe_id}" + + if liked_by_me: + like_action = url_for("social.unlike") + like_cls = "text-red-500 hover:text-red-600" + like_icon = "\u2665" + else: + like_action = url_for("social.like") + like_cls = "hover:text-red-500" + like_icon = "\u2661" + + if boosted_by_me: + boost_action = url_for("social.unboost") + boost_cls = "text-green-600 hover:text-green-700" + else: + boost_action = url_for("social.boost") + boost_cls = "hover:text-green-600" + + reply_url = url_for("social.defpage_compose_form", reply_to=object_id) if object_id else "" + reply_sx = sx_call("federation-reply-link", url=reply_url) if reply_url else "" + + like_form = sx_call("federation-like-form", + action=like_action, target=target, oid=object_id, ainbox=author_inbox, + csrf=csrf, cls=f"flex items-center gap-1 {like_cls}", + icon=like_icon, count=str(like_count)) + + boost_form = sx_call("federation-boost-form", + action=boost_action, target=target, oid=object_id, ainbox=author_inbox, + csrf=csrf, cls=f"flex items-center gap-1 {boost_cls}", + count=str(boost_count)) + + return sx_call("federation-interaction-buttons", + like=SxExpr(like_form), + boost=SxExpr(boost_form), + reply=SxExpr(reply_sx) if reply_sx else None) def render_actor_card(actor_dto: Any, actor: Any, followed_urls: set, *, list_type: str = "following") -> str: - """Render a single actor card fragment for HTMX POST response.""" - return _actor_card_sx(actor_dto, actor, followed_urls, list_type=list_type) + """Render a single actor card fragment for POST response.""" + actor_data = _serialize_actor(actor) + ad = _serialize_remote_actor(actor_dto) + return sx_call("federation-actor-card-from-data", + a=SxExpr(serialize(ad)), + actor=SxExpr(serialize(actor_data)) if actor_data else None, + followed_urls=SxExpr(serialize(list(followed_urls))), + list_type=list_type) diff --git a/federation/sxc/pages/__init__.py b/federation/sxc/pages/__init__.py index 4d30f0c..5d5fd3d 100644 --- a/federation/sxc/pages/__init__.py +++ b/federation/sxc/pages/__init__.py @@ -27,22 +27,28 @@ def _register_federation_layouts() -> None: def _social_full(ctx: dict, **kw: Any) -> str: - from shared.sx.helpers import root_header_sx, header_child_sx - from sx.sx_components import _social_header_sx + from shared.sx.helpers import root_header_sx, header_child_sx, sx_call, SxExpr + from shared.sx.parser import serialize actor = ctx.get("actor") + actor_data = _serialize_actor(actor) if actor else None + nav = sx_call("federation-social-nav", + actor=SxExpr(serialize(actor_data)) if actor_data else None) + social_hdr = sx_call("federation-social-header", nav=SxExpr(nav)) root_hdr = root_header_sx(ctx) - social_hdr = _social_header_sx(actor) child = header_child_sx(social_hdr) return "(<> " + root_hdr + " " + child + ")" def _social_oob(ctx: dict, **kw: Any) -> str: from shared.sx.helpers import root_header_sx, sx_call, SxExpr - from sx.sx_components import _social_header_sx + from shared.sx.parser import serialize actor = ctx.get("actor") - social_hdr = _social_header_sx(actor) + actor_data = _serialize_actor(actor) if actor else None + nav = sx_call("federation-social-nav", + actor=SxExpr(serialize(actor_data)) if actor_data else None) + social_hdr = sx_call("federation-social-header", nav=SxExpr(nav)) child_oob = sx_call("oob-header-sx", parent_id="root-header-child", row=SxExpr(social_hdr)) @@ -69,6 +75,58 @@ def _register_federation_helpers() -> None: }) +def _serialize_actor(actor) -> dict | None: + """Serialize an actor profile to a dict for sx defcomps.""" + if not actor: + return None + return { + "id": actor.id, + "preferred_username": actor.preferred_username, + "display_name": getattr(actor, "display_name", None), + "icon_url": getattr(actor, "icon_url", None), + "summary": getattr(actor, "summary", None), + "actor_url": getattr(actor, "actor_url", ""), + "domain": getattr(actor, "domain", ""), + } + + +def _serialize_timeline_item(item) -> dict: + """Serialize a timeline item DTO to a dict for sx defcomps.""" + published = getattr(item, "published", None) + return { + "object_id": getattr(item, "object_id", "") or "", + "author_inbox": getattr(item, "author_inbox", "") or "", + "actor_icon": getattr(item, "actor_icon", None), + "actor_name": getattr(item, "actor_name", "?"), + "actor_username": getattr(item, "actor_username", ""), + "actor_domain": getattr(item, "actor_domain", ""), + "content": getattr(item, "content", ""), + "summary": getattr(item, "summary", None), + "published": published.strftime("%b %d, %H:%M") if published else "", + "before_cursor": published.isoformat() if published else "", + "url": getattr(item, "url", None), + "post_type": getattr(item, "post_type", ""), + "boosted_by": getattr(item, "boosted_by", None), + "like_count": getattr(item, "like_count", 0) or 0, + "boost_count": getattr(item, "boost_count", 0) or 0, + "liked_by_me": getattr(item, "liked_by_me", False), + "boosted_by_me": getattr(item, "boosted_by_me", False), + } + + +def _serialize_remote_actor(a) -> dict: + """Serialize a remote actor DTO to a dict for sx defcomps.""" + return { + "id": getattr(a, "id", None), + "display_name": getattr(a, "display_name", None) or getattr(a, "preferred_username", ""), + "preferred_username": getattr(a, "preferred_username", ""), + "domain": getattr(a, "domain", ""), + "icon_url": getattr(a, "icon_url", None), + "actor_url": getattr(a, "actor_url", ""), + "summary": getattr(a, "summary", None), + } + + def _get_actor(): """Return current user's actor or None.""" from quart import g @@ -87,32 +145,43 @@ def _require_actor(): async def _h_home_timeline_content(**kw): from quart import g from shared.services.registry import services + from shared.sx.helpers import sx_call, SxExpr + from shared.sx.parser import serialize actor = _require_actor() items = await services.federation.get_home_timeline(g.s, actor.id) - from sx.sx_components import _timeline_content_sx - return _timeline_content_sx(items, "home", actor) + return sx_call("federation-timeline-content", + items=SxExpr(serialize([_serialize_timeline_item(i) for i in items])), + timeline_type="home", + actor=SxExpr(serialize(_serialize_actor(actor)))) async def _h_public_timeline_content(**kw): from quart import g from shared.services.registry import services + from shared.sx.helpers import sx_call, SxExpr + from shared.sx.parser import serialize actor = _get_actor() items = await services.federation.get_public_timeline(g.s) - from sx.sx_components import _timeline_content_sx - return _timeline_content_sx(items, "public", actor) + return sx_call("federation-timeline-content", + items=SxExpr(serialize([_serialize_timeline_item(i) for i in items])), + timeline_type="public", + actor=SxExpr(serialize(_serialize_actor(actor))) if actor else None) async def _h_compose_content(**kw): from quart import request - actor = _require_actor() - from sx.sx_components import _compose_content_sx + from shared.sx.helpers import sx_call + _require_actor() reply_to = request.args.get("reply_to") - return _compose_content_sx(actor, reply_to) + return sx_call("federation-compose-content", + reply_to=reply_to or None) async def _h_search_content(**kw): from quart import g, request from shared.services.registry import services + from shared.sx.helpers import sx_call, SxExpr + from shared.sx.parser import serialize actor = _get_actor() query = request.args.get("q", "").strip() actors_list = [] @@ -125,24 +194,34 @@ async def _h_search_content(**kw): g.s, actor.preferred_username, page=1, per_page=1000, ) followed_urls = {a.actor_url for a in following} - from sx.sx_components import _search_content_sx - return _search_content_sx(query, actors_list, total, 1, followed_urls, actor) + return sx_call("federation-search-content", + query=query, + actors=SxExpr(serialize([_serialize_remote_actor(a) for a in actors_list])), + total=total, + followed_urls=SxExpr(serialize(list(followed_urls))), + actor=SxExpr(serialize(_serialize_actor(actor))) if actor else None) async def _h_following_content(**kw): from quart import g from shared.services.registry import services + from shared.sx.helpers import sx_call, SxExpr + from shared.sx.parser import serialize actor = _require_actor() actors_list, total = await services.federation.get_following( g.s, actor.preferred_username, ) - from sx.sx_components import _following_content_sx - return _following_content_sx(actors_list, total, actor) + return sx_call("federation-following-content", + actors=SxExpr(serialize([_serialize_remote_actor(a) for a in actors_list])), + total=total, + actor=SxExpr(serialize(_serialize_actor(actor)))) async def _h_followers_content(**kw): from quart import g from shared.services.registry import services + from shared.sx.helpers import sx_call, SxExpr + from shared.sx.parser import serialize actor = _require_actor() actors_list, total = await services.federation.get_followers_paginated( g.s, actor.preferred_username, @@ -151,13 +230,18 @@ async def _h_followers_content(**kw): g.s, actor.preferred_username, page=1, per_page=1000, ) followed_urls = {a.actor_url for a in following} - from sx.sx_components import _followers_content_sx - return _followers_content_sx(actors_list, total, followed_urls, actor) + return sx_call("federation-followers-content", + actors=SxExpr(serialize([_serialize_remote_actor(a) for a in actors_list])), + total=total, + followed_urls=SxExpr(serialize(list(followed_urls))), + actor=SxExpr(serialize(_serialize_actor(actor)))) async def _h_actor_timeline_content(id=None, **kw): from quart import g, abort from shared.services.registry import services + from shared.sx.helpers import sx_call, SxExpr + from shared.sx.parser import serialize actor = _get_actor() actor_id = id from shared.models.federation import RemoteActor @@ -184,15 +268,35 @@ async def _h_actor_timeline_content(id=None, **kw): ) ).scalar_one_or_none() is_following = existing is not None - from sx.sx_components import _actor_timeline_content_sx - return _actor_timeline_content_sx(remote_dto, items, is_following, actor) + return sx_call("federation-actor-timeline-content", + remote_actor=SxExpr(serialize(_serialize_remote_actor(remote_dto))), + items=SxExpr(serialize([_serialize_timeline_item(i) for i in items])), + is_following=is_following, + actor=SxExpr(serialize(_serialize_actor(actor))) if actor else None) async def _h_notifications_content(**kw): from quart import g from shared.services.registry import services + from shared.sx.helpers import sx_call, SxExpr + from shared.sx.parser import serialize actor = _require_actor() items = await services.federation.get_notifications(g.s, actor.id) await services.federation.mark_notifications_read(g.s, actor.id) - from sx.sx_components import _notifications_content_sx - return _notifications_content_sx(items) + + notif_dicts = [] + for n in items: + created = getattr(n, "created_at", None) + notif_dicts.append({ + "from_actor_name": getattr(n, "from_actor_name", "?"), + "from_actor_username": getattr(n, "from_actor_username", ""), + "from_actor_domain": getattr(n, "from_actor_domain", ""), + "from_actor_icon": getattr(n, "from_actor_icon", None), + "notification_type": getattr(n, "notification_type", ""), + "target_content_preview": getattr(n, "target_content_preview", None), + "created_at_formatted": created.strftime("%b %d, %H:%M") if created else "", + "read": getattr(n, "read", True), + "app_domain": getattr(n, "app_domain", ""), + }) + return sx_call("federation-notifications-content", + notifications=SxExpr(serialize(notif_dicts))) diff --git a/orders/bp/orders/routes.py b/orders/bp/orders/routes.py index 2967d56..762d11a 100644 --- a/orders/bp/orders/routes.py +++ b/orders/bp/orders/routes.py @@ -73,11 +73,41 @@ def register(url_prefix: str) -> Blueprint: result = await g.s.execute(stmt) orders = result.scalars().all() - from sx.sx_components import _orders_rows_sx - from shared.sx.helpers import sx_response + from shared.sx.helpers import sx_response, sx_call, SxExpr + from shared.sx.parser import serialize + from shared.utils import route_prefix + pfx = route_prefix() + order_dicts = [] + for o in orders: + order_dicts.append({ + "id": o.id, + "status": o.status or "pending", + "created_at_formatted": o.created_at.strftime("%-d %b %Y, %H:%M") if o.created_at else "\u2014", + "description": o.description or "", + "currency": o.currency or "GBP", + "total_formatted": f"{o.total_amount or 0:.2f}", + }) + + detail_prefix = pfx + url_for("orders.defpage_order_detail", order_id=0).rsplit("0/", 1)[0] qs_fn = makeqs_factory() - sx_src = _orders_rows_sx(orders, page, total_pages, url_for, qs_fn) + rows_url = pfx + url_for("orders.orders_rows") + + # Build just the rows fragment (not full table) for infinite scroll + parts = [] + for od in order_dicts: + parts.append(sx_call("order-row-pair", + order=SxExpr(serialize(od)), + detail_url_prefix=detail_prefix)) + if page < total_pages: + parts.append(sx_call("infinite-scroll", + url=rows_url + qs_fn(page=page + 1), + page=page, total_pages=total_pages, + id_prefix="orders", colspan=5)) + else: + parts.append(sx_call("order-end-row")) + sx_src = "(<> " + " ".join(parts) + ")" + resp = sx_response(sx_src) resp.headers["Hx-Push-Url"] = _current_url_without_page() return _vary(resp) diff --git a/orders/sx/sx_components.py b/orders/sx/sx_components.py index d3615eb..d628723 100644 --- a/orders/sx/sx_components.py +++ b/orders/sx/sx_components.py @@ -1,9 +1,9 @@ """ Orders service s-expression page components. -Each function renders a complete page section (full page, OOB, or pagination) -using shared s-expression components. Called from route handlers in place -of ``render_template()``. +Checkout error/return pages are still rendered from Python because they +use ``full_page_sx()`` with custom layouts. All other order rendering +is now handled by .sx defcomps. """ from __future__ import annotations @@ -12,8 +12,8 @@ from typing import Any from shared.sx.jinja_bridge import load_service_components from shared.sx.helpers import ( - call_url, - sx_call, SxExpr, + call_url, sx_call, SxExpr, + root_header_sx, full_page_sx, header_child_sx, ) from shared.infrastructure.urls import market_product_url, cart_url @@ -22,260 +22,30 @@ load_service_components(os.path.dirname(os.path.dirname(__file__)), service_name="orders") -# --------------------------------------------------------------------------- -# Header helpers (shared auth + orders-specific) — sx-native -# --------------------------------------------------------------------------- - -def _auth_nav_sx(ctx: dict) -> str: - """Auth section desktop nav items as sx.""" - parts = [ - sx_call("nav-link", - href=call_url(ctx, "account_url", "/newsletters/"), - label="newsletters", - select_colours=ctx.get("select_colours", ""), - ), - ] - account_nav = ctx.get("account_nav") - if account_nav: - parts.append(str(account_nav)) - return "(<> " + " ".join(parts) + ")" - - -def _auth_header_sx(ctx: dict, *, oob: bool = False) -> str: - """Build the account section header row as sx.""" - return sx_call( - "menu-row-sx", - id="auth-row", level=1, colour="sky", - link_href=call_url(ctx, "account_url", "/"), - link_label="account", icon="fa-solid fa-user", - nav=SxExpr(_auth_nav_sx(ctx)), - child_id="auth-header-child", oob=oob, - ) - - -def _orders_header_sx(ctx: dict, list_url: str) -> str: - """Build the orders section header row as sx.""" - return sx_call( - "menu-row-sx", - id="orders-row", level=2, colour="sky", - link_href=list_url, link_label="Orders", icon="fa fa-gbp", - child_id="orders-header-child", - ) - - -# --------------------------------------------------------------------------- -# Orders list rendering -# --------------------------------------------------------------------------- - -def _status_pill_cls(status: str) -> str: - """Return Tailwind classes for order status pill.""" - sl = status.lower() - if sl == "paid": - return "border-emerald-300 bg-emerald-50 text-emerald-700" - if sl in ("failed", "cancelled"): - return "border-rose-300 bg-rose-50 text-rose-700" - return "border-stone-300 bg-stone-50 text-stone-700" - - -def _order_row_data(order: Any, detail_url: str) -> dict: - """Extract display data from an order model object.""" - status = order.status or "pending" - pill = _status_pill_cls(status) - created = order.created_at.strftime("%-d %b %Y, %H:%M") if order.created_at else "\u2014" - total = f"{order.currency or 'GBP'} {order.total_amount or 0:.2f}" - return dict( - oid=f"#{order.id}", created=created, - desc=order.description or "", total=total, - pill_desktop=f"inline-flex items-center rounded-full border px-2 py-0.5 text-[11px] sm:text-xs {pill}", - pill_mobile=f"inline-flex items-center rounded-full border px-2 py-0.5 text-[11px] {pill}", - status=status, url=detail_url, - ) - - - -def _orders_rows_sx(orders: list, page: int, total_pages: int, - url_for_fn: Any, qs_fn: Any) -> str: - """S-expression wire format for order rows (client renders).""" - from shared.utils import route_prefix - pfx = route_prefix() - - parts = [] - for o in orders: - d = _order_row_data(o, pfx + url_for_fn("orders.defpage_order_detail", order_id=o.id)) - parts.append(sx_call("order-row-desktop", - oid=d["oid"], created=d["created"], - desc=d["desc"], total=d["total"], - pill=d["pill_desktop"], status=d["status"], - url=d["url"])) - parts.append(sx_call("order-row-mobile", - oid=d["oid"], created=d["created"], - total=d["total"], pill=d["pill_mobile"], - status=d["status"], url=d["url"])) - - if page < total_pages: - next_url = pfx + url_for_fn("orders.orders_rows") + qs_fn(page=page + 1) - parts.append(sx_call("infinite-scroll", - url=next_url, page=page, - total_pages=total_pages, - id_prefix="orders", colspan=5)) - else: - parts.append(sx_call("order-end-row")) - - return "(<> " + " ".join(parts) + ")" - - -def _orders_main_panel_sx(orders: list, rows_sx: str) -> str: - """Main panel with table or empty state (sx).""" - if not orders: - return sx_call("order-empty-state") - return sx_call("order-table", rows=SxExpr(rows_sx)) - - -def _orders_summary_sx(ctx: dict) -> str: - """Filter section for orders list (sx).""" - return sx_call("order-list-header", search_mobile=SxExpr(search_mobile_sx(ctx))) - - - -# --------------------------------------------------------------------------- -# Public API: orders list -# --------------------------------------------------------------------------- - - - - - -# --------------------------------------------------------------------------- -# Single order detail -# --------------------------------------------------------------------------- - -def _order_items_sx(order: Any) -> str: - """Render order items list as sx.""" - if not order or not order.items: - return "" - items = [] - for item in order.items: - prod_url = market_product_url(item.product_slug) - if item.product_image: - img = sx_call( - "order-item-image", - src=item.product_image, alt=item.product_title or "Product image", - ) - else: - img = sx_call("order-item-no-image") - - items.append(sx_call( - "order-item-row", - href=prod_url, img=SxExpr(img), - title=item.product_title or "Unknown product", - pid=f"Product ID: {item.product_id}", - qty=f"Qty: {item.quantity}", - price=f"{item.currency or order.currency or 'GBP'} {item.unit_price or 0:.2f}", - )) - - items_sx = "(<> " + " ".join(items) + ")" - return sx_call("order-items-panel", items=SxExpr(items_sx)) - - -def _calendar_items_sx(calendar_entries: list | None) -> str: - """Render calendar bookings for an order as sx.""" - if not calendar_entries: - return "" - items = [] - for e in calendar_entries: - st = e.state or "" - pill = ( - "bg-emerald-100 text-emerald-800" if st == "confirmed" - else "bg-amber-100 text-amber-800" if st == "provisional" - else "bg-blue-100 text-blue-800" if st == "ordered" - else "bg-stone-100 text-stone-700" - ) - ds = e.start_at.strftime("%-d %b %Y, %H:%M") if e.start_at else "" - if e.end_at: - ds += f" \u2013 {e.end_at.strftime('%-d %b %Y, %H:%M')}" - items.append(sx_call( - "order-calendar-entry", - name=e.name, - pill=f"inline-flex items-center rounded-full px-2 py-0.5 text-[11px] font-medium {pill}", - status=st.capitalize(), date_str=ds, - cost=f"\u00a3{e.cost or 0:.2f}", - )) - - items_sx = "(<> " + " ".join(items) + ")" - return sx_call("order-calendar-section", items=SxExpr(items_sx)) - - -def _order_main_sx(order: Any, calendar_entries: list | None) -> str: - """Main panel for single order detail (sx).""" - summary = sx_call( - "order-summary-card", - order_id=order.id, - created_at=order.created_at.strftime("%-d %b %Y, %H:%M") if order.created_at else None, - description=order.description, status=order.status, currency=order.currency, - total_amount=f"{order.total_amount:.2f}" if order.total_amount else None, - ) - items = _order_items_sx(order) - calendar = _calendar_items_sx(calendar_entries) - return sx_call( - "order-detail-panel", - summary=SxExpr(summary), - items=SxExpr(items) if items else None, - calendar=SxExpr(calendar) if calendar else None, - ) - - -def _order_filter_sx(order: Any, list_url: str, recheck_url: str, - pay_url: str, csrf_token: str) -> str: - """Filter section for single order detail (sx).""" - created = order.created_at.strftime("%-d %b %Y, %H:%M") if order.created_at else "\u2014" - status = order.status or "pending" - - pay = "" - if status != "paid": - pay = sx_call("order-pay-btn", url=pay_url) - - return sx_call( - "order-detail-filter", - info=f"Placed {created} \u00b7 Status: {status}", - list_url=list_url, recheck_url=recheck_url, - csrf=csrf_token, - pay=SxExpr(pay) if pay else None, - ) - - - - - - # --------------------------------------------------------------------------- # Public API: Checkout error # --------------------------------------------------------------------------- -def _checkout_error_filter_sx() -> str: - return sx_call("checkout-error-header") +async def render_checkout_error_page(ctx: dict, error: str | None = None, order: Any | None = None) -> str: + """Full page: checkout error (sx wire format).""" + account_url = call_url(ctx, "account_url", "") + auth_hdr = sx_call("auth-header-row", account_url=account_url) + hdr = root_header_sx(ctx) + hdr = "(<> " + hdr + " " + header_child_sx(auth_hdr) + ")" + filt = sx_call("checkout-error-header") - -def _checkout_error_content_sx(error: str | None, order: Any | None) -> str: err_msg = error or "Unexpected error while creating the hosted checkout session." order_sx = "" if order: order_sx = sx_call("checkout-error-order-id", oid=f"#{order.id}") back_url = cart_url("/") - return sx_call( + content = sx_call( "checkout-error-content", msg=err_msg, order=SxExpr(order_sx) if order_sx else None, back_url=back_url, ) - -async def render_checkout_error_page(ctx: dict, error: str | None = None, order: Any | None = None) -> str: - """Full page: checkout error (sx wire format).""" - hdr = root_header_sx(ctx) - inner = _auth_header_sx(ctx) - hdr = "(<> " + hdr + " " + header_child_sx(inner) + ")" - filt = _checkout_error_filter_sx() - content = _checkout_error_content_sx(error, order) return full_page_sx(ctx, header_rows=hdr, filter=filt, content=content) @@ -283,67 +53,127 @@ async def render_checkout_error_page(ctx: dict, error: str | None = None, order: # Public API: Checkout return # --------------------------------------------------------------------------- -def _ticket_items_sx(order_tickets: list | None) -> str: - """Render ticket items for an order as sx.""" - if not order_tickets: - return "" - items = [] - for tk in order_tickets: - st = tk.state or "" - pill = ( - "bg-emerald-100 text-emerald-800" if st == "confirmed" - else "bg-amber-100 text-amber-800" if st == "reserved" - else "bg-blue-100 text-blue-800" if st == "checked_in" - else "bg-stone-100 text-stone-700" - ) - pill_cls = f"inline-flex items-center rounded-full px-2 py-0.5 text-[11px] font-medium {pill}" - ds = tk.entry_start_at.strftime("%-d %b %Y, %H:%M") if tk.entry_start_at else "" - if tk.entry_end_at: - ds += f" – {tk.entry_end_at.strftime('%-d %b %Y, %H:%M')}" - items.append(sx_call( - "checkout-return-ticket", - name=tk.entry_name, - pill=pill_cls, - state=st.replace("_", " ").capitalize(), - type_name=tk.ticket_type_name or None, - date_str=ds, - code=tk.code, - price=f"£{tk.price or 0:.2f}", - )) - items_sx = "(<> " + " ".join(items) + ")" - return sx_call("checkout-return-tickets", items=SxExpr(items_sx)) - - async def render_checkout_return_page(ctx: dict, order: Any | None, status: str, calendar_entries: list | None = None, order_tickets: list | None = None) -> str: """Full page: checkout return after SumUp payment (sx wire format).""" + from shared.sx.parser import serialize + filt = sx_call("checkout-return-header", status=status) if not order: content = sx_call("checkout-return-missing") else: - summary = sx_call( - "order-summary-card", + # Serialize order data for defcomp + order_dict = { + "id": order.id, + "status": order.status or "pending", + "created_at_formatted": order.created_at.strftime("%-d %b %Y, %H:%M") if order.created_at else None, + "description": order.description, + "currency": order.currency, + "total_formatted": f"{order.total_amount:.2f}" if order.total_amount else None, + "items": [ + { + "product_url": market_product_url(item.product_slug), + "product_image": item.product_image, + "product_title": item.product_title, + "product_id": item.product_id, + "quantity": item.quantity, + "currency": item.currency, + "unit_price_formatted": f"{item.unit_price or 0:.2f}", + } + for item in (order.items or []) + ], + } + + summary = sx_call("order-summary-card", order_id=order.id, - created_at=order.created_at.strftime("%-d %b %Y, %H:%M") if order.created_at else None, + created_at=order_dict["created_at_formatted"], description=order.description, status=order.status, currency=order.currency, - total_amount=f"{order.total_amount:.2f}" if order.total_amount else None, + total_amount=order_dict["total_formatted"], ) - items = _order_items_sx(order) - calendar = _calendar_items_sx(calendar_entries) - tickets = _ticket_items_sx(order_tickets) + # Items + items = "" + if order.items: + item_parts = [] + for item_d in order_dict["items"]: + if item_d["product_image"]: + img = sx_call("order-item-image", + src=item_d["product_image"], + alt=item_d["product_title"] or "Product image") + else: + img = sx_call("order-item-no-image") + item_parts.append(sx_call("order-item-row", + href=item_d["product_url"], img=SxExpr(img), + title=item_d["product_title"] or "Unknown product", + pid=f"Product ID: {item_d['product_id']}", + qty=f"Qty: {item_d['quantity']}", + price=f"{item_d['currency'] or order.currency or 'GBP'} {item_d['unit_price_formatted']}", + )) + items = sx_call("order-items-panel", + items=SxExpr("(<> " + " ".join(item_parts) + ")")) + + # Calendar entries + calendar = "" + if calendar_entries: + cal_parts = [] + for e in calendar_entries: + st = e.state or "" + pill = ( + "bg-emerald-100 text-emerald-800" if st == "confirmed" + else "bg-amber-100 text-amber-800" if st == "provisional" + else "bg-blue-100 text-blue-800" if st == "ordered" + else "bg-stone-100 text-stone-700" + ) + ds = e.start_at.strftime("%-d %b %Y, %H:%M") if e.start_at else "" + if e.end_at: + ds += f" \u2013 {e.end_at.strftime('%-d %b %Y, %H:%M')}" + cal_parts.append(sx_call("order-calendar-entry", + name=e.name, + pill=f"inline-flex items-center rounded-full px-2 py-0.5 text-[11px] font-medium {pill}", + status=st.capitalize(), date_str=ds, + cost=f"\u00a3{e.cost or 0:.2f}", + )) + calendar = sx_call("order-calendar-section", + items=SxExpr("(<> " + " ".join(cal_parts) + ")")) + + # Tickets + tickets = "" + if order_tickets: + tk_parts = [] + for tk in order_tickets: + st = tk.state or "" + pill = ( + "bg-emerald-100 text-emerald-800" if st == "confirmed" + else "bg-amber-100 text-amber-800" if st == "reserved" + else "bg-blue-100 text-blue-800" if st == "checked_in" + else "bg-stone-100 text-stone-700" + ) + pill_cls = f"inline-flex items-center rounded-full px-2 py-0.5 text-[11px] font-medium {pill}" + ds = tk.entry_start_at.strftime("%-d %b %Y, %H:%M") if tk.entry_start_at else "" + if tk.entry_end_at: + ds += f" \u2013 {tk.entry_end_at.strftime('%-d %b %Y, %H:%M')}" + tk_parts.append(sx_call("checkout-return-ticket", + name=tk.entry_name, pill=pill_cls, + state=st.replace("_", " ").capitalize(), + type_name=tk.ticket_type_name or None, + date_str=ds, code=tk.code, + price=f"\u00a3{tk.price or 0:.2f}", + )) + tickets = sx_call("checkout-return-tickets", + items=SxExpr("(<> " + " ".join(tk_parts) + ")")) + + # Status message status_msg = "" if order.status == "failed": status_msg = sx_call("checkout-return-failed", order_id=order.id) elif order.status == "paid": status_msg = sx_call("checkout-return-paid") - content = sx_call( - "checkout-return-content", + content = sx_call("checkout-return-content", summary=SxExpr(summary), items=SxExpr(items) if items else None, calendar=SxExpr(calendar) if calendar else None, @@ -351,8 +181,9 @@ async def render_checkout_return_page(ctx: dict, order: Any | None, status_message=SxExpr(status_msg) if status_msg else None, ) + account_url = call_url(ctx, "account_url", "") + auth_hdr = sx_call("auth-header-row", account_url=account_url) hdr = root_header_sx(ctx) - inner = _auth_header_sx(ctx) - hdr = "(<> " + hdr + " " + header_child_sx(inner) + ")" + hdr = "(<> " + hdr + " " + header_child_sx(auth_hdr) + ")" return full_page_sx(ctx, header_rows=hdr, filter=filt, content=content) diff --git a/orders/sxc/pages/__init__.py b/orders/sxc/pages/__init__.py index b1ea93b..4c57038 100644 --- a/orders/sxc/pages/__init__.py +++ b/orders/sxc/pages/__init__.py @@ -29,24 +29,35 @@ def _register_orders_layouts() -> None: def _orders_full(ctx: dict, **kw: Any) -> str: - from shared.sx.helpers import root_header_sx, header_child_sx - from sx.sx_components import _auth_header_sx, _orders_header_sx + from shared.sx.helpers import root_header_sx, header_child_sx, call_url, sx_call, SxExpr list_url = kw.get("list_url", "/") + account_url = call_url(ctx, "account_url", "") root_hdr = root_header_sx(ctx) - inner = "(<> " + _auth_header_sx(ctx) + " " + _orders_header_sx(ctx, list_url) + ")" + auth_hdr = sx_call("auth-header-row", + account_url=account_url, + select_colours=ctx.get("select_colours", ""), + account_nav=_as_sx_nav(ctx), + ) + orders_hdr = sx_call("orders-header-row", list_url=list_url) + inner = "(<> " + auth_hdr + " " + orders_hdr + ")" return "(<> " + root_hdr + " " + header_child_sx(inner) + ")" def _orders_oob(ctx: dict, **kw: Any) -> str: - from shared.sx.helpers import root_header_sx, sx_call, SxExpr - from sx.sx_components import _auth_header_sx, _orders_header_sx + from shared.sx.helpers import root_header_sx, sx_call, SxExpr, call_url list_url = kw.get("list_url", "/") - auth_hdr = _auth_header_sx(ctx, oob=True) + account_url = call_url(ctx, "account_url", "") + auth_hdr = sx_call("auth-header-row", + account_url=account_url, + select_colours=ctx.get("select_colours", ""), + account_nav=_as_sx_nav(ctx), + oob=True, + ) auth_child_oob = sx_call("oob-header-sx", parent_id="auth-header-child", - row=SxExpr(_orders_header_sx(ctx, list_url))) + row=SxExpr(sx_call("orders-header-row", list_url=list_url))) root_hdr = root_header_sx(ctx, oob=True) return "(<> " + auth_hdr + " " + auth_child_oob + " " + root_hdr + ")" @@ -57,21 +68,26 @@ def _orders_mobile(ctx: dict, **kw: Any) -> str: def _order_detail_full(ctx: dict, **kw: Any) -> str: - from shared.sx.helpers import root_header_sx, sx_call, SxExpr - from sx.sx_components import _auth_header_sx, _orders_header_sx + from shared.sx.helpers import root_header_sx, sx_call, SxExpr, call_url list_url = kw.get("list_url", "/") detail_url = kw.get("detail_url", "/") + account_url = call_url(ctx, "account_url", "") root_hdr = root_header_sx(ctx) order_row = sx_call( "menu-row-sx", id="order-row", level=3, colour="sky", link_href=detail_url, link_label="Order", icon="fa fa-gbp", ) + auth_hdr = sx_call("auth-header-row", + account_url=account_url, + select_colours=ctx.get("select_colours", ""), + account_nav=_as_sx_nav(ctx), + ) detail_header = sx_call( "order-detail-header-stack", - auth=SxExpr(_auth_header_sx(ctx)), - orders=SxExpr(_orders_header_sx(ctx, list_url)), + auth=SxExpr(auth_hdr), + orders=SxExpr(sx_call("orders-header-row", list_url=list_url)), order=SxExpr(order_row), ) return "(<> " + root_hdr + " " + detail_header + ")" @@ -98,6 +114,12 @@ def _order_detail_mobile(ctx: dict, **kw: Any) -> str: return mobile_menu_sx(mobile_root_nav_sx(ctx)) +def _as_sx_nav(ctx: dict) -> Any: + """Convert account_nav fragment to SxExpr for use in sx_call.""" + from shared.sx.helpers import _as_sx + return _as_sx(ctx.get("account_nav")) + + # --------------------------------------------------------------------------- # Page helpers — Python functions callable from defpage expressions # --------------------------------------------------------------------------- @@ -257,14 +279,38 @@ async def _ensure_order_detail(order_id): async def _h_orders_list_content(**kw): await _ensure_orders_list() from quart import g + from shared.sx.helpers import sx_call, SxExpr + from shared.sx.parser import serialize d = getattr(g, "orders_page_data", None) if not d: - from shared.sx.helpers import sx_call return sx_call("order-empty-state") - from sx.sx_components import _orders_rows_sx, _orders_main_panel_sx - rows = _orders_rows_sx(d["orders"], d["page"], d["total_pages"], - d["url_for_fn"], d["qs_fn"]) - return _orders_main_panel_sx(d["orders"], rows) + + orders = d["orders"] + url_for_fn = d["url_for_fn"] + pfx = d.get("list_url", "/").rsplit("/", 1)[0] if d.get("list_url") else "" + + order_dicts = [] + for o in orders: + order_dicts.append({ + "id": o.id, + "status": o.status or "pending", + "created_at_formatted": o.created_at.strftime("%-d %b %Y, %H:%M") if o.created_at else "\u2014", + "description": o.description or "", + "currency": o.currency or "GBP", + "total_formatted": f"{o.total_amount or 0:.2f}", + }) + + from shared.utils import route_prefix + rpfx = route_prefix() + detail_prefix = rpfx + url_for_fn("orders.defpage_order_detail", order_id=0).rsplit("0/", 1)[0] + rows_url = rpfx + url_for_fn("orders.orders_rows") + + return sx_call("orders-list-content", + orders=SxExpr(serialize(order_dicts)), + page=d["page"], + total_pages=d["total_pages"], + rows_url=rows_url, + detail_url_prefix=detail_prefix) async def _h_orders_list_filter(**kw): @@ -312,22 +358,76 @@ async def _h_orders_list_url(**kw): async def _h_order_detail_content(order_id=None, **kw): await _ensure_order_detail(order_id) from quart import g + from shared.sx.helpers import sx_call, SxExpr + from shared.sx.parser import serialize + from shared.infrastructure.urls import market_product_url d = getattr(g, "order_detail_data", None) if not d: return "" - from sx.sx_components import _order_main_sx - return _order_main_sx(d["order"], d["calendar_entries"]) + + order = d["order"] + order_dict = { + "id": order.id, + "status": order.status or "pending", + "created_at_formatted": order.created_at.strftime("%-d %b %Y, %H:%M") if order.created_at else None, + "description": order.description, + "currency": order.currency, + "total_formatted": f"{order.total_amount:.2f}" if order.total_amount else None, + "items": [ + { + "product_url": market_product_url(item.product_slug), + "product_image": item.product_image, + "product_title": item.product_title, + "product_id": item.product_id, + "quantity": item.quantity, + "currency": item.currency, + "unit_price_formatted": f"{item.unit_price or 0:.2f}", + } + for item in (order.items or []) + ], + } + + cal_entries = d["calendar_entries"] + cal_dicts = None + if cal_entries: + cal_dicts = [] + for e in cal_entries: + ds = e.start_at.strftime("%-d %b %Y, %H:%M") if e.start_at else "" + if e.end_at: + ds += f" \u2013 {e.end_at.strftime('%-d %b %Y, %H:%M')}" + cal_dicts.append({ + "name": e.name, + "state": e.state or "", + "date_str": ds, + "cost_formatted": f"{e.cost or 0:.2f}", + }) + + return sx_call("order-detail-content", + order=SxExpr(serialize(order_dict)), + calendar_entries=SxExpr(serialize(cal_dicts)) if cal_dicts else None) async def _h_order_detail_filter(order_id=None, **kw): await _ensure_order_detail(order_id) from quart import g + from shared.sx.helpers import sx_call, SxExpr + from shared.sx.parser import serialize d = getattr(g, "order_detail_data", None) if not d: return "" - from sx.sx_components import _order_filter_sx - return _order_filter_sx(d["order"], d["list_url"], d["recheck_url"], - d["pay_url"], d["csrf_token"]) + + order = d["order"] + order_dict = { + "status": order.status or "pending", + "created_at_formatted": order.created_at.strftime("%-d %b %Y, %H:%M") if order.created_at else "\u2014", + } + + return sx_call("order-detail-filter-content", + order=SxExpr(serialize(order_dict)), + list_url=d["list_url"], + recheck_url=d["recheck_url"], + pay_url=d["pay_url"], + csrf=d["csrf_token"]) async def _h_order_detail_url(order_id=None, **kw): diff --git a/shared/sx/primitives.py b/shared/sx/primitives.py index 559db89..68bceac 100644 --- a/shared/sx/primitives.py +++ b/shared/sx/primitives.py @@ -498,6 +498,15 @@ def prim_format_date(date_str: Any, fmt: str) -> str: return str(date_str) if date_str else "" +@register_primitive("format-decimal") +def prim_format_decimal(val: Any, places: Any = 2) -> str: + """``(format-decimal val places)`` → formatted decimal string.""" + try: + return f"{float(val):.{int(places)}f}" + except (ValueError, TypeError): + return "0." + "0" * int(places) + + @register_primitive("parse-int") def prim_parse_int(val: Any, default: Any = 0) -> int | Any: """``(parse-int val default?)`` → int(val) with fallback.""" @@ -516,3 +525,34 @@ def prim_assert(condition: Any, message: str = "Assertion failed") -> bool: if not condition: raise RuntimeError(f"Assertion error: {message}") return True + + +# --------------------------------------------------------------------------- +# Text helpers +# --------------------------------------------------------------------------- + +@register_primitive("pluralize") +def prim_pluralize(count: Any, singular: str = "", plural: str = "s") -> str: + """``(pluralize count)`` → "s" if count != 1, else "". + ``(pluralize count "item" "items")`` → "item" or "items".""" + try: + n = int(count) + except (ValueError, TypeError): + n = 0 + if singular or plural != "s": + return singular if n == 1 else plural + return "" if n == 1 else "s" + + +@register_primitive("escape") +def prim_escape(s: Any) -> str: + """``(escape val)`` → HTML-escaped string.""" + from markupsafe import escape as _escape + return str(_escape(str(s) if s is not None and s is not NIL else "")) + + +@register_primitive("route-prefix") +def prim_route_prefix() -> str: + """``(route-prefix)`` → service URL prefix for dev/prod routing.""" + from shared.utils import route_prefix + return route_prefix() diff --git a/shared/sx/templates/auth.sx b/shared/sx/templates/auth.sx index 9e012a9..084a2b9 100644 --- a/shared/sx/templates/auth.sx +++ b/shared/sx/templates/auth.sx @@ -1,5 +1,44 @@ -;; Shared auth components — login flow, check email -;; Used by account and federation services. +;; Shared auth components — login flow, check email, header rows +;; Used by account, orders, cart, and federation services. + +;; --------------------------------------------------------------------------- +;; Auth / orders header rows — DRY extraction from per-service Python +;; --------------------------------------------------------------------------- + +;; Auth section nav items (newsletters link + account_nav slot) +(defcomp ~auth-nav-items (&key account-url select-colours account-nav) + (<> + (~nav-link :href (str (or account-url "") "/newsletters/") + :label "newsletters" + :select-colours (or select-colours "")) + (when account-nav account-nav))) + +;; Auth header row — wraps ~menu-row-sx for account section +(defcomp ~auth-header-row (&key account-url select-colours account-nav oob) + (~menu-row-sx :id "auth-row" :level 1 :colour "sky" + :link-href (str (or account-url "") "/") + :link-label "account" :icon "fa-solid fa-user" + :nav (~auth-nav-items :account-url account-url + :select-colours select-colours + :account-nav account-nav) + :child-id "auth-header-child" :oob oob)) + +;; Auth header row without nav (for cart service) +(defcomp ~auth-header-row-simple (&key account-url oob) + (~menu-row-sx :id "auth-row" :level 1 :colour "sky" + :link-href (str (or account-url "") "/") + :link-label "account" :icon "fa-solid fa-user" + :child-id "auth-header-child" :oob oob)) + +;; Orders header row +(defcomp ~orders-header-row (&key list-url) + (~menu-row-sx :id "orders-row" :level 2 :colour "sky" + :link-href list-url :link-label "Orders" :icon "fa fa-gbp" + :child-id "orders-header-child")) + +;; --------------------------------------------------------------------------- +;; Auth forms — login flow, check email +;; --------------------------------------------------------------------------- (defcomp ~auth-error-banner (&key error) (when error diff --git a/shared/sx/templates/orders.sx b/shared/sx/templates/orders.sx index 3c43794..a05f97b 100644 --- a/shared/sx/templates/orders.sx +++ b/shared/sx/templates/orders.sx @@ -124,6 +124,155 @@ ;; Checkout error screens ;; --------------------------------------------------------------------------- +;; --------------------------------------------------------------------------- +;; Assembled order list content — replaces Python _orders_rows_sx / _orders_main_panel_sx +;; --------------------------------------------------------------------------- + +;; Status pill class mapping +(defcomp ~order-status-pill-cls (&key status) + (let* ((sl (lower (or status "")))) + (cond + ((= sl "paid") "border-emerald-300 bg-emerald-50 text-emerald-700") + ((or (= sl "failed") (= sl "cancelled")) "border-rose-300 bg-rose-50 text-rose-700") + (true "border-stone-300 bg-stone-50 text-stone-700")))) + +;; Single order row pair (desktop + mobile) — takes serialized order data dict +(defcomp ~order-row-pair (&key order detail-url-prefix) + (let* ((status (or (get order "status") "pending")) + (pill-base (~order-status-pill-cls :status status)) + (oid (str "#" (get order "id"))) + (created (or (get order "created_at_formatted") "\u2014")) + (desc (or (get order "description") "")) + (total (str (or (get order "currency") "GBP") " " (or (get order "total_formatted") "0.00"))) + (url (str detail-url-prefix (get order "id") "/"))) + (<> + (~order-row-desktop + :oid oid :created created :desc desc :total total + :pill (str "inline-flex items-center rounded-full border px-2 py-0.5 text-[11px] sm:text-xs " pill-base) + :status status :url url) + (~order-row-mobile + :oid oid :created created :total total + :pill (str "inline-flex items-center rounded-full border px-2 py-0.5 text-[11px] " pill-base) + :status status :url url)))) + +;; Assembled orders list content +(defcomp ~orders-list-content (&key orders page total-pages rows-url detail-url-prefix) + (if (empty? orders) + (~order-empty-state) + (~order-table + :rows (<> + (map (lambda (order) + (~order-row-pair :order order :detail-url-prefix detail-url-prefix)) + orders) + (if (< page total-pages) + (~infinite-scroll + :url (str rows-url "?page=" (inc page)) + :page page :total-pages total-pages + :id-prefix "orders" :colspan 5) + (~order-end-row)))))) + +;; Assembled order detail content — replaces Python _order_main_sx +(defcomp ~order-detail-content (&key order calendar-entries) + (let* ((items (get order "items"))) + (~order-detail-panel + :summary (~order-summary-card + :order-id (get order "id") + :created-at (get order "created_at_formatted") + :description (get order "description") + :status (get order "status") + :currency (get order "currency") + :total-amount (get order "total_formatted")) + :items (when (not (empty? (or items (list)))) + (~order-items-panel + :items (map (lambda (item) + (~order-item-row + :href (get item "product_url") + :img (if (get item "product_image") + (~order-item-image :src (get item "product_image") + :alt (or (get item "product_title") "Product image")) + (~order-item-no-image)) + :title (or (get item "product_title") "Unknown product") + :pid (str "Product ID: " (get item "product_id")) + :qty (str "Qty: " (get item "quantity")) + :price (str (or (get item "currency") (get order "currency") "GBP") " " (or (get item "unit_price_formatted") "0.00")))) + items))) + :calendar (when (not (empty? (or calendar-entries (list)))) + (~order-calendar-section + :items (map (lambda (e) + (let* ((st (or (get e "state") "")) + (pill (cond + ((= st "confirmed") "bg-emerald-100 text-emerald-800") + ((= st "provisional") "bg-amber-100 text-amber-800") + ((= st "ordered") "bg-blue-100 text-blue-800") + (true "bg-stone-100 text-stone-700")))) + (~order-calendar-entry + :name (get e "name") + :pill (str "inline-flex items-center rounded-full px-2 py-0.5 text-[11px] font-medium " pill) + :status (upper (slice st 0 1)) + :date-str (get e "date_str") + :cost (str "\u00a3" (or (get e "cost_formatted") "0.00"))))) + calendar-entries)))))) + +;; Assembled order detail filter — replaces Python _order_filter_sx +(defcomp ~order-detail-filter-content (&key order list-url recheck-url pay-url csrf) + (let* ((status (or (get order "status") "pending")) + (created (or (get order "created_at_formatted") "\u2014"))) + (~order-detail-filter + :info (str "Placed " created " \u00b7 Status: " status) + :list-url list-url + :recheck-url recheck-url + :csrf csrf + :pay (when (!= status "paid") + (~order-pay-btn :url pay-url))))) + +;; --------------------------------------------------------------------------- +;; Checkout return components +;; --------------------------------------------------------------------------- + +(defcomp ~checkout-return-header (&key status) + (header :class "mb-6 sm:mb-8" + (h1 :class "text-xl sm:text-2xl md:text-3xl font-semibold tracking-tight" "Payment complete") + (p :class "text-xs sm:text-sm text-stone-600" + (str "Your checkout session is " status ".")))) + +(defcomp ~checkout-return-missing () + (div :class "max-w-full px-3 py-3 space-y-4" + (p :class "text-sm text-stone-600" "Order not found."))) + +(defcomp ~checkout-return-ticket (&key name pill state type-name date-str code price) + (li :class "px-4 py-3 flex items-start justify-between text-sm" + (div + (div :class "font-medium flex items-center gap-2" + name (span :class pill state)) + (when type-name (div :class "text-xs text-stone-500" type-name)) + (div :class "text-xs text-stone-500" date-str) + (when code (div :class "font-mono text-xs text-stone-400" code))) + (div :class "ml-4 font-medium" price))) + +(defcomp ~checkout-return-tickets (&key items) + (section :class "mt-6 space-y-3" + (h2 :class "text-base sm:text-lg font-semibold" "Tickets") + (ul :class "divide-y divide-stone-200 rounded-2xl border border-stone-200 bg-white/80" items))) + +(defcomp ~checkout-return-failed (&key order-id) + (div :class "rounded-lg border border-rose-200 bg-rose-50 p-4 text-sm text-rose-900" + (p :class "font-medium" "Payment failed") + (p "Please try again or contact support." + (when order-id (span " Order #" (str order-id)))))) + +(defcomp ~checkout-return-paid () + (div :class "rounded-lg border border-emerald-200 bg-emerald-50 p-4 text-sm text-emerald-900" + (p :class "font-medium" "Payment successful!") + (p "Your order has been confirmed."))) + +(defcomp ~checkout-return-content (&key summary items calendar tickets status-message) + (div :class "max-w-full px-3 py-3 space-y-4" + status-message summary items calendar tickets)) + +;; --------------------------------------------------------------------------- +;; Checkout error screens +;; --------------------------------------------------------------------------- + (defcomp ~checkout-error-header () (header :class "mb-6 sm:mb-8" (h1 :class "text-xl sm:text-2xl md:text-3xl font-semibold tracking-tight" "Checkout error") From 4e5f9ff16ced6d9a2adf7ee4c601b4315e3827da Mon Sep 17 00:00:00 2001 From: giles Date: Tue, 3 Mar 2026 22:41:19 +0000 Subject: [PATCH 008/105] Remove dead render_profile_page from federation sx_components This function was replaced by defpage-based rendering but never deleted. Co-Authored-By: Claude Opus 4.6 --- federation/sx/sx_components.py | 41 ---------------------------------- 1 file changed, 41 deletions(-) diff --git a/federation/sx/sx_components.py b/federation/sx/sx_components.py index 9f9371d..feba1f2 100644 --- a/federation/sx/sx_components.py +++ b/federation/sx/sx_components.py @@ -144,47 +144,6 @@ async def render_choose_username_page(ctx: dict) -> str: title="Choose Username \u2014 Rose Ash") -async def render_profile_page(ctx: dict, actor: Any, activities: list, - total: int) -> str: - from shared.config import config - - ap_domain = config().get("ap_domain", "rose-ash.com") - display_name = actor.display_name or actor.preferred_username - summary_sx = sx_call( - "federation-profile-summary-text", text=str(escape(actor.summary)), - ) if actor.summary else "" - - activities_sx = "" - if activities: - parts = [] - for a in activities: - published = a.published.strftime("%Y-%m-%d %H:%M") if a.published else "" - obj_type_sx = sx_call( - "federation-activity-obj-type", obj_type=a.object_type, - ) if a.object_type else "" - parts.append(sx_call( - "federation-activity-card", - activity_type=a.activity_type, published=published, - obj_type=SxExpr(obj_type_sx) if obj_type_sx else None, - )) - items_sx = "(<> " + " ".join(parts) + ")" - activities_sx = sx_call("federation-activities-list", items=SxExpr(items_sx)) - else: - activities_sx = sx_call("federation-activities-empty") - - content = sx_call( - "federation-profile-page", - display_name=str(escape(display_name)), - username=str(escape(actor.preferred_username)), - domain=str(escape(ap_domain)), - summary=SxExpr(summary_sx) if summary_sx else None, - activities_heading=f"Activities ({total})", - activities=SxExpr(activities_sx), - ) - return _social_page(ctx, actor, content=content, - title=f"@{actor.preferred_username} \u2014 Rose Ash") - - # --------------------------------------------------------------------------- # Public API: Pagination fragment renderers # --------------------------------------------------------------------------- From 0554f8a1139939616bfb4b6fc0b28b3a9f39b5c1 Mon Sep 17 00:00:00 2001 From: giles Date: Tue, 3 Mar 2026 23:00:58 +0000 Subject: [PATCH 009/105] Refactor sx.js: extract string renderer, deduplicate helpers, remove dead code Extract Node-only string renderer (renderToString, renderStr, etc.) to sx-test.js. Add shared helpers (_processOOBSwaps, _postSwap, _processBindings, _evalCond, _logParseError) replacing duplicated logic. Remove dead isTruthy and _sxCssKnown class-list fallback. Compress section banners. sx.js goes from 2652 to 2279 lines (-14%) with zero browser-side behavior change. Co-Authored-By: Claude Opus 4.6 --- shared/static/scripts/sx-test.js | 292 +++++++++++++++ shared/static/scripts/sx.js | 609 ++++++------------------------- shared/sx/tests/test_sx_js.py | 4 +- sx/content/pages.py | 2 +- 4 files changed, 414 insertions(+), 493 deletions(-) create mode 100644 shared/static/scripts/sx-test.js diff --git a/shared/static/scripts/sx-test.js b/shared/static/scripts/sx-test.js new file mode 100644 index 0000000..859cb20 --- /dev/null +++ b/shared/static/scripts/sx-test.js @@ -0,0 +1,292 @@ +/** + * sx-test.js — String renderer for sx.js (Node-only, used by test harness). + * + * Provides Sx.renderToString() for server-side / test rendering. + * Assumes sx.js is loaded first and Sx global is available. + */ +;(function (Sx) { + "use strict"; + + // Pull references from Sx internals + var NIL = Sx.NIL; + var _eval = Sx._eval; + var _types = Sx._types; + var RawHTML = _types.RawHTML; + + function isNil(x) { return x === NIL || x === null || x === undefined; } + function isSym(x) { return x && x._sym === true; } + function isKw(x) { return x && x._kw === true; } + function isLambda(x) { return x && x._lambda === true; } + function isComponent(x) { return x && x._component === true; } + function isMacro(x) { return x && x._macro === true; } + function isRaw(x) { return x && x._raw === true; } + function isSxTruthy(x) { return x !== false && !isNil(x); } + + function merge(target) { + for (var i = 1; i < arguments.length; i++) { + var src = arguments[i]; + if (src) for (var k in src) target[k] = src[k]; + } + return target; + } + + // Use the same tag/attr sets as sx.js + var HTML_TAGS = Sx._renderDOM ? null : null; // We'll use a local copy + var _HTML_TAGS_STR = + "html head body title meta link style script base noscript " + + "header footer main nav aside section article address hgroup " + + "h1 h2 h3 h4 h5 h6 " + + "div p blockquote pre figure figcaption ul ol li dl dt dd hr " + + "a span em strong small s cite q abbr code var samp kbd sub sup " + + "i b u mark ruby rt rp bdi bdo br wbr time data " + + "ins del " + + "img picture source iframe embed object param video audio track canvas map area " + + "table caption colgroup col thead tbody tfoot tr td th " + + "form input textarea button select option optgroup label fieldset legend " + + "details summary dialog " + + "svg path circle rect line ellipse polyline polygon text g defs use " + + "clippath lineargradient radialgradient stop pattern mask " + + "tspan textpath foreignobject"; + var _VOID_STR = "area base br col embed hr img input link meta param source track wbr"; + var _BOOL_STR = "disabled checked readonly required selected autofocus autoplay " + + "controls loop muted multiple hidden open novalidate"; + + function makeSet(str) { + var s = {}, parts = str.split(/\s+/); + for (var i = 0; i < parts.length; i++) if (parts[i]) s[parts[i]] = true; + return s; + } + + HTML_TAGS = makeSet(_HTML_TAGS_STR); + var VOID_ELEMENTS = makeSet(_VOID_STR); + var BOOLEAN_ATTRS = makeSet(_BOOL_STR); + + // Access expandMacro via Sx._eval on a defmacro — we need to replicate macro expansion + // Actually, we need the internal expandMacro. Let's check if Sx exposes it. + // Sx._eval handles macro expansion internally, so we can call sxEval for macro forms. + var sxEval = _eval; + + // _isRenderExpr — check if an expression is a render-only form + function _isRenderExpr(v) { + if (!Array.isArray(v) || !v.length) return false; + var h = v[0]; + if (!isSym(h)) return false; + var n = h.name; + if (n === "<>" || n === "raw!" || n === "if" || n === "when" || n === "cond" || + n === "case" || n === "let" || n === "let*" || n === "begin" || n === "do" || + n === "map" || n === "map-indexed" || n === "filter" || n === "for-each") return true; + if (n.charAt(0) === "~") return true; + if (HTML_TAGS[n]) return true; + return false; + } + + // --- String Renderer --- + + function escapeText(s) { return s.replace(/&/g, "&").replace(//g, ">"); } + function escapeAttr(s) { return s.replace(/&/g, "&").replace(/"/g, """).replace(//g, ">"); } + + function renderStr(expr, env) { + if (isNil(expr) || expr === false || expr === true) return ""; + if (isRaw(expr)) return expr.html; + if (typeof expr === "string") return escapeText(expr); + if (typeof expr === "number") return escapeText(String(expr)); + if (isSym(expr)) return renderStr(sxEval(expr, env), env); + if (isKw(expr)) return escapeText(expr.name); + if (Array.isArray(expr)) { if (!expr.length) return ""; return renderStrList(expr, env); } + if (expr && typeof expr === "object") return ""; + return escapeText(String(expr)); + } + + function renderStrList(expr, env) { + var head = expr[0]; + if (!isSym(head)) { + var parts = []; + for (var i = 0; i < expr.length; i++) parts.push(renderStr(expr[i], env)); + return parts.join(""); + } + var name = head.name; + + if (name === "raw!") { + var ps = []; + for (var ri = 1; ri < expr.length; ri++) { + var v = sxEval(expr[ri], env); + if (isRaw(v)) ps.push(v.html); + else if (typeof v === "string") ps.push(v); + else if (!isNil(v)) ps.push(String(v)); + } + return ps.join(""); + } + if (name === "<>") { + var fs = []; + for (var fi = 1; fi < expr.length; fi++) fs.push(renderStr(expr[fi], env)); + return fs.join(""); + } + if (name === "if") { + return isSxTruthy(sxEval(expr[1], env)) + ? renderStr(expr[2], env) + : (expr.length > 3 ? renderStr(expr[3], env) : ""); + } + if (name === "when") { + if (!isSxTruthy(sxEval(expr[1], env))) return ""; + var ws = []; + for (var wi = 2; wi < expr.length; wi++) ws.push(renderStr(expr[wi], env)); + return ws.join(""); + } + if (name === "let" || name === "let*") { + var bindings = expr[1], local = merge({}, env); + if (Array.isArray(bindings)) { + if (bindings.length && Array.isArray(bindings[0])) { + for (var li = 0; li < bindings.length; li++) { + local[isSym(bindings[li][0]) ? bindings[li][0].name : bindings[li][0]] = sxEval(bindings[li][1], local); + } + } else { + for (var lj = 0; lj < bindings.length; lj += 2) { + local[isSym(bindings[lj]) ? bindings[lj].name : bindings[lj]] = sxEval(bindings[lj + 1], local); + } + } + } + var ls = []; + for (var lk = 2; lk < expr.length; lk++) ls.push(renderStr(expr[lk], local)); + return ls.join(""); + } + if (name === "begin" || name === "do") { + var bs = []; + for (var bi = 1; bi < expr.length; bi++) bs.push(renderStr(expr[bi], env)); + return bs.join(""); + } + if (name === "define" || name === "defcomp" || name === "defmacro" || name === "defhandler") { sxEval(expr, env); return ""; } + + // Macro expansion in string renderer + if (name in env && isMacro(env[name])) { + var smExp = Sx._expandMacro(env[name], expr.slice(1), env); + return renderStr(smExp, env); + } + + // Higher-order forms — render-aware + if (name === "map") { + var mapFn = sxEval(expr[1], env), mapColl = sxEval(expr[2], env); + if (!Array.isArray(mapColl)) return ""; + var mapParts = []; + for (var mi = 0; mi < mapColl.length; mi++) { + if (isLambda(mapFn)) mapParts.push(renderLambdaStr(mapFn, [mapColl[mi]], env)); + else mapParts.push(renderStr(mapFn(mapColl[mi]), env)); + } + return mapParts.join(""); + } + if (name === "map-indexed") { + var mixFn = sxEval(expr[1], env), mixColl = sxEval(expr[2], env); + if (!Array.isArray(mixColl)) return ""; + var mixParts = []; + for (var mxi = 0; mxi < mixColl.length; mxi++) { + if (isLambda(mixFn)) mixParts.push(renderLambdaStr(mixFn, [mxi, mixColl[mxi]], env)); + else mixParts.push(renderStr(mixFn(mxi, mixColl[mxi]), env)); + } + return mixParts.join(""); + } + if (name === "filter") { + var filtFn = sxEval(expr[1], env), filtColl = sxEval(expr[2], env); + if (!Array.isArray(filtColl)) return ""; + var filtParts = []; + for (var fli = 0; fli < filtColl.length; fli++) { + var keep = isLambda(filtFn) ? Sx._callLambda(filtFn, [filtColl[fli]], env) : filtFn(filtColl[fli]); + if (isSxTruthy(keep)) filtParts.push(renderStr(filtColl[fli], env)); + } + return filtParts.join(""); + } + + if (HTML_TAGS[name]) return renderStrElement(name, expr.slice(1), env); + + if (name.charAt(0) === "~") { + var comp = env[name]; + if (isComponent(comp)) return renderStrComponent(comp, expr.slice(1), env); + console.warn("sx.js: unknown component " + name); + return '
' + + 'Unknown component: ' + escapeText(name) + '
'; + } + + return renderStr(sxEval(expr, env), env); + } + + function renderStrElement(tag, args, env) { + var attrs = [], children = []; + var i = 0; + while (i < args.length) { + if (isKw(args[i]) && i + 1 < args.length) { + var aname = args[i].name, aval = sxEval(args[i + 1], env); + i += 2; + if (isNil(aval) || aval === false) continue; + if (BOOLEAN_ATTRS[aname]) { if (aval) attrs.push(" " + aname); } + else if (aval === true) attrs.push(" " + aname); + else attrs.push(" " + aname + '="' + escapeAttr(String(aval)) + '"'); + } else { + children.push(args[i]); + i++; + } + } + var open = "<" + tag + attrs.join("") + ">"; + if (VOID_ELEMENTS[tag]) return open; + var isRawText = (tag === "script" || tag === "style"); + var inner = []; + for (var ci = 0; ci < children.length; ci++) { + var child = children[ci]; + if (isRawText && typeof child === "string") inner.push(child); + else if (isRawText && isSym(child)) inner.push(String(sxEval(child, env))); + else inner.push(renderStr(child, env)); + } + return open + inner.join("") + ""; + } + + function renderLambdaStr(fn, args, env) { + var local = merge({}, fn.closure, env); + for (var i = 0; i < fn.params.length; i++) local[fn.params[i]] = args[i]; + return renderStr(fn.body, local); + } + + function renderStrComponent(comp, args, env) { + var kwargs = {}, children = []; + var i = 0; + while (i < args.length) { + if (isKw(args[i]) && i + 1 < args.length) { + var v = args[i + 1]; + if (typeof v === "string" || typeof v === "number" || + typeof v === "boolean" || isNil(v) || isKw(v)) { + kwargs[args[i].name] = v; + } else if (isSym(v)) { + kwargs[args[i].name] = sxEval(v, env); + } else if (Array.isArray(v) && v.length && isSym(v[0])) { + if (_isRenderExpr(v)) { + kwargs[args[i].name] = new RawHTML(renderStr(v, env)); + } else { + kwargs[args[i].name] = sxEval(v, env); + } + } else { + kwargs[args[i].name] = v; + } + i += 2; + } else { children.push(args[i]); i++; } + } + var local = merge({}, comp.closure, env); + for (var pi = 0; pi < comp.params.length; pi++) { + var p = comp.params[pi]; + local[p] = (p in kwargs) ? kwargs[p] : NIL; + } + if (comp.hasChildren) { + var cs = []; + for (var ci = 0; ci < children.length; ci++) cs.push(renderStr(children[ci], env)); + local["children"] = new RawHTML(cs.join("")); + } + return renderStr(comp.body, local); + } + + // --- Public API --- + + Sx.renderToString = function (exprOrText, extraEnv) { + var expr = typeof exprOrText === "string" ? Sx.parse(exprOrText) : exprOrText; + var env = extraEnv ? merge({}, Sx.getEnv(), extraEnv) : Sx.getEnv(); + return renderStr(expr, env); + }; + + Sx._renderStr = renderStr; + +})(Sx); diff --git a/shared/static/scripts/sx.js b/shared/static/scripts/sx.js index c6769e7..3cb1758 100644 --- a/shared/static/scripts/sx.js +++ b/shared/static/scripts/sx.js @@ -12,17 +12,12 @@ ;(function (global) { "use strict"; - // ========================================================================= - // Types - // ========================================================================= + // --- Types --- /** Singleton nil — falsy placeholder. */ var NIL = Object.freeze({ _nil: true, toString: function () { return "nil"; } }); function isNil(x) { return x === NIL || x === null || x === undefined; } - function isTruthy(x) { return x !== false && !isNil(x) && x !== 0 && x !== ""; } - // Note: 0 and "" are falsy in sx but we match Python semantics where - // only nil/false/None are falsy for control flow. Revisit if needed. function isSxTruthy(x) { return x !== false && !isNil(x); } function Symbol(name) { this.name = name; } @@ -70,9 +65,7 @@ function isMacro(x) { return x && x._macro === true; } function isRaw(x) { return x && x._raw === true; } - // ========================================================================= - // Parser - // ========================================================================= + // --- Parser --- var RE_WS = /\s+/y; var RE_COMMENT = /;[^\n]*/y; @@ -242,9 +235,7 @@ return results; } - // ========================================================================= - // Primitives - // ========================================================================= + // --- Primitives --- var PRIMITIVES = {}; @@ -345,9 +336,7 @@ return r; }; - // ========================================================================= - // Evaluator - // ========================================================================= + // --- Evaluator --- function sxEval(expr, env) { // Literals @@ -444,6 +433,68 @@ return sxEval(comp.body, local); } + // --- Shared helpers for special/render forms --- + + function _processBindings(bindings, env) { + var local = merge({}, env); + if (Array.isArray(bindings)) { + if (bindings.length && Array.isArray(bindings[0])) { + for (var i = 0; i < bindings.length; i++) { + var vname = isSym(bindings[i][0]) ? bindings[i][0].name : bindings[i][0]; + local[vname] = sxEval(bindings[i][1], local); + } + } else { + for (var j = 0; j < bindings.length; j += 2) { + var vn = isSym(bindings[j]) ? bindings[j].name : bindings[j]; + local[vn] = sxEval(bindings[j + 1], local); + } + } + } + return local; + } + + function _evalCond(clauses, env) { + if (!clauses.length) return null; + if (Array.isArray(clauses[0]) && clauses[0].length === 2) { + for (var i = 0; i < clauses.length; i++) { + var test = clauses[i][0]; + if ((isSym(test) && (test.name === "else" || test.name === ":else")) || + (isKw(test) && test.name === "else")) return clauses[i][1]; + if (isSxTruthy(sxEval(test, env))) return clauses[i][1]; + } + } else { + for (var j = 0; j < clauses.length - 1; j += 2) { + var t = clauses[j]; + if ((isKw(t) && t.name === "else") || (isSym(t) && (t.name === ":else" || t.name === "else"))) + return clauses[j + 1]; + if (isSxTruthy(sxEval(t, env))) return clauses[j + 1]; + } + } + return null; + } + + function _logParseError(label, text, err, windowSize) { + var colMatch = err.message && err.message.match(/col (\d+)/); + var lineMatch = err.message && err.message.match(/line (\d+)/); + if (colMatch && text) { + var errLine = lineMatch ? parseInt(lineMatch[1]) : 1; + var errCol = parseInt(colMatch[1]); + var lines = text.split("\n"); + var pos = 0; + for (var i = 0; i < errLine - 1 && i < lines.length; i++) pos += lines[i].length + 1; + pos += errCol; + var start = Math.max(0, pos - windowSize); + var end = Math.min(text.length, pos + windowSize); + console.error("sx.js " + label + ":", err.message, + "\n total length:", text.length, "lines:", lines.length, + "\n error line " + errLine + ":", lines[errLine - 1] ? lines[errLine - 1].substring(0, 200) : "(no such line)", + "\n around error (pos ~" + pos + "):", + "\n «" + text.substring(start, pos) + "⛔" + text.substring(pos, end) + "»"); + } else { + console.error("sx.js " + label + ":", err.message || err); + } + } + // --- Special forms ------------------------------------------------------- var SPECIAL_FORMS = {}; @@ -462,26 +513,8 @@ }; SPECIAL_FORMS["cond"] = function (expr, env) { - var clauses = expr.slice(1); - if (!clauses.length) return NIL; - // Scheme-style - if (Array.isArray(clauses[0]) && clauses[0].length === 2) { - for (var i = 0; i < clauses.length; i++) { - var test = clauses[i][0]; - if ((isSym(test) && (test.name === "else" || test.name === ":else")) || - (isKw(test) && test.name === "else")) return sxEval(clauses[i][1], env); - if (isSxTruthy(sxEval(test, env))) return sxEval(clauses[i][1], env); - } - } else { - // Clojure-style - for (var j = 0; j < clauses.length - 1; j += 2) { - var t = clauses[j]; - if ((isKw(t) && t.name === "else") || (isSym(t) && (t.name === ":else" || t.name === "else"))) - return sxEval(clauses[j + 1], env); - if (isSxTruthy(sxEval(t, env))) return sxEval(clauses[j + 1], env); - } - } - return NIL; + var branch = _evalCond(expr.slice(1), env); + return branch ? sxEval(branch, env) : NIL; }; SPECIAL_FORMS["case"] = function (expr, env) { @@ -514,22 +547,7 @@ }; SPECIAL_FORMS["let"] = SPECIAL_FORMS["let*"] = function (expr, env) { - var bindings = expr[1], local = merge({}, env); - if (Array.isArray(bindings)) { - if (bindings.length && Array.isArray(bindings[0])) { - // Scheme-style - for (var i = 0; i < bindings.length; i++) { - var vname = isSym(bindings[i][0]) ? bindings[i][0].name : bindings[i][0]; - local[vname] = sxEval(bindings[i][1], local); - } - } else { - // Clojure-style - for (var j = 0; j < bindings.length; j += 2) { - var vn = isSym(bindings[j]) ? bindings[j].name : bindings[j]; - local[vn] = sxEval(bindings[j + 1], local); - } - } - } + var local = _processBindings(expr[1], env); var result = NIL; for (var k = 2; k < expr.length; k++) result = sxEval(expr[k], local); return result; @@ -714,9 +732,7 @@ return NIL; }; - // ========================================================================= - // DOM Renderer - // ========================================================================= + // --- DOM Renderer --- var HTML_TAGS = makeSet( "html head body title meta link style script base noscript " + @@ -813,39 +829,12 @@ }; RENDER_FORMS["cond"] = function (expr, env) { - var clauses = expr.slice(1); - if (!clauses.length) return document.createDocumentFragment(); - if (Array.isArray(clauses[0]) && clauses[0].length === 2) { - for (var i = 0; i < clauses.length; i++) { - var test = clauses[i][0]; - if ((isSym(test) && (test.name === "else" || test.name === ":else")) || - (isKw(test) && test.name === "else")) return renderDOM(clauses[i][1], env); - if (isSxTruthy(sxEval(test, env))) return renderDOM(clauses[i][1], env); - } - } else { - for (var j = 0; j < clauses.length - 1; j += 2) { - var t = clauses[j]; - if ((isKw(t) && t.name === "else") || (isSym(t) && (t.name === ":else" || t.name === "else"))) - return renderDOM(clauses[j + 1], env); - if (isSxTruthy(sxEval(t, env))) return renderDOM(clauses[j + 1], env); - } - } - return document.createDocumentFragment(); + var branch = _evalCond(expr.slice(1), env); + return branch ? renderDOM(branch, env) : document.createDocumentFragment(); }; RENDER_FORMS["let"] = RENDER_FORMS["let*"] = function (expr, env) { - var bindings = expr[1], local = merge({}, env); - if (Array.isArray(bindings)) { - if (bindings.length && Array.isArray(bindings[0])) { - for (var i = 0; i < bindings.length; i++) { - local[isSym(bindings[i][0]) ? bindings[i][0].name : bindings[i][0]] = sxEval(bindings[i][1], local); - } - } else { - for (var j = 0; j < bindings.length; j += 2) { - local[isSym(bindings[j]) ? bindings[j].name : bindings[j]] = sxEval(bindings[j + 1], local); - } - } - } + var local = _processBindings(expr[1], env); var frag = document.createDocumentFragment(); for (var k = 2; k < expr.length; k++) frag.appendChild(renderDOM(expr[k], local)); return frag; @@ -1070,216 +1059,7 @@ return el; } - // ========================================================================= - // String Renderer (for SSR parity / testing) - // ========================================================================= - - function escapeText(s) { return s.replace(/&/g, "&").replace(//g, ">"); } - function escapeAttr(s) { return s.replace(/&/g, "&").replace(/"/g, """).replace(//g, ">"); } - - function renderStr(expr, env) { - if (isNil(expr) || expr === false || expr === true) return ""; - if (isRaw(expr)) return expr.html; - if (typeof expr === "string") return escapeText(expr); - if (typeof expr === "number") return escapeText(String(expr)); - if (isSym(expr)) return renderStr(sxEval(expr, env), env); - if (isKw(expr)) return escapeText(expr.name); - if (Array.isArray(expr)) { if (!expr.length) return ""; return renderStrList(expr, env); } - if (expr && typeof expr === "object") return ""; - return escapeText(String(expr)); - } - - function renderStrList(expr, env) { - var head = expr[0]; - if (!isSym(head)) { - var parts = []; - for (var i = 0; i < expr.length; i++) parts.push(renderStr(expr[i], env)); - return parts.join(""); - } - var name = head.name; - - if (name === "raw!") { - var ps = []; - for (var ri = 1; ri < expr.length; ri++) { - var v = sxEval(expr[ri], env); - if (isRaw(v)) ps.push(v.html); - else if (typeof v === "string") ps.push(v); - else if (!isNil(v)) ps.push(String(v)); - } - return ps.join(""); - } - if (name === "<>") { - var fs = []; - for (var fi = 1; fi < expr.length; fi++) fs.push(renderStr(expr[fi], env)); - return fs.join(""); - } - if (name === "if") { - return isSxTruthy(sxEval(expr[1], env)) - ? renderStr(expr[2], env) - : (expr.length > 3 ? renderStr(expr[3], env) : ""); - } - if (name === "when") { - if (!isSxTruthy(sxEval(expr[1], env))) return ""; - var ws = []; - for (var wi = 2; wi < expr.length; wi++) ws.push(renderStr(expr[wi], env)); - return ws.join(""); - } - if (name === "let" || name === "let*") { - var bindings = expr[1], local = merge({}, env); - if (Array.isArray(bindings)) { - if (bindings.length && Array.isArray(bindings[0])) { - for (var li = 0; li < bindings.length; li++) { - local[isSym(bindings[li][0]) ? bindings[li][0].name : bindings[li][0]] = sxEval(bindings[li][1], local); - } - } else { - for (var lj = 0; lj < bindings.length; lj += 2) { - local[isSym(bindings[lj]) ? bindings[lj].name : bindings[lj]] = sxEval(bindings[lj + 1], local); - } - } - } - var ls = []; - for (var lk = 2; lk < expr.length; lk++) ls.push(renderStr(expr[lk], local)); - return ls.join(""); - } - if (name === "begin" || name === "do") { - var bs = []; - for (var bi = 1; bi < expr.length; bi++) bs.push(renderStr(expr[bi], env)); - return bs.join(""); - } - if (name === "define" || name === "defcomp" || name === "defmacro" || name === "defhandler") { sxEval(expr, env); return ""; } - - // Macro expansion in string renderer - if (name in env && isMacro(env[name])) { - var smExp = expandMacro(env[name], expr.slice(1), env); - return renderStr(smExp, env); - } - - // Higher-order forms — render-aware (lambda bodies may contain HTML/components) - if (name === "map") { - var mapFn = sxEval(expr[1], env), mapColl = sxEval(expr[2], env); - if (!Array.isArray(mapColl)) return ""; - var mapParts = []; - for (var mi = 0; mi < mapColl.length; mi++) { - if (isLambda(mapFn)) mapParts.push(renderLambdaStr(mapFn, [mapColl[mi]], env)); - else mapParts.push(renderStr(mapFn(mapColl[mi]), env)); - } - return mapParts.join(""); - } - if (name === "map-indexed") { - var mixFn = sxEval(expr[1], env), mixColl = sxEval(expr[2], env); - if (!Array.isArray(mixColl)) return ""; - var mixParts = []; - for (var mxi = 0; mxi < mixColl.length; mxi++) { - if (isLambda(mixFn)) mixParts.push(renderLambdaStr(mixFn, [mxi, mixColl[mxi]], env)); - else mixParts.push(renderStr(mixFn(mxi, mixColl[mxi]), env)); - } - return mixParts.join(""); - } - if (name === "filter") { - var filtFn = sxEval(expr[1], env), filtColl = sxEval(expr[2], env); - if (!Array.isArray(filtColl)) return ""; - var filtParts = []; - for (var fli = 0; fli < filtColl.length; fli++) { - var keep = isLambda(filtFn) ? callLambda(filtFn, [filtColl[fli]], env) : filtFn(filtColl[fli]); - if (isSxTruthy(keep)) filtParts.push(renderStr(filtColl[fli], env)); - } - return filtParts.join(""); - } - - if (HTML_TAGS[name]) return renderStrElement(name, expr.slice(1), env); - - if (name.charAt(0) === "~") { - var comp = env[name]; - if (isComponent(comp)) return renderStrComponent(comp, expr.slice(1), env); - // Unknown component — return visible warning - console.warn("sx.js: unknown component " + name); - return '
' + - 'Unknown component: ' + escapeText(name) + '
'; - } - - return renderStr(sxEval(expr, env), env); - } - - function renderStrElement(tag, args, env) { - var attrs = [], children = []; - var i = 0; - while (i < args.length) { - if (isKw(args[i]) && i + 1 < args.length) { - var aname = args[i].name, aval = sxEval(args[i + 1], env); - i += 2; - if (isNil(aval) || aval === false) continue; - if (BOOLEAN_ATTRS[aname]) { if (aval) attrs.push(" " + aname); } - else if (aval === true) attrs.push(" " + aname); - else attrs.push(" " + aname + '="' + escapeAttr(String(aval)) + '"'); - } else { - children.push(args[i]); - i++; - } - } - var open = "<" + tag + attrs.join("") + ">"; - if (VOID_ELEMENTS[tag]) return open; - var isRawText = (tag === "script" || tag === "style"); - var inner = []; - for (var ci = 0; ci < children.length; ci++) { - var child = children[ci]; - if (isRawText && typeof child === "string") inner.push(child); - else if (isRawText && isSym(child)) inner.push(String(sxEval(child, env))); - else inner.push(renderStr(child, env)); - } - return open + inner.join("") + ""; - } - - function renderLambdaStr(fn, args, env) { - var local = merge({}, fn.closure, env); - for (var i = 0; i < fn.params.length; i++) local[fn.params[i]] = args[i]; - return renderStr(fn.body, local); - } - - function renderStrComponent(comp, args, env) { - var kwargs = {}, children = []; - var i = 0; - while (i < args.length) { - if (isKw(args[i]) && i + 1 < args.length) { - // Evaluate kwarg values eagerly in the caller's env so expressions - // like (get t "src") resolve while lambda params are still bound. - var v = args[i + 1]; - if (typeof v === "string" || typeof v === "number" || - typeof v === "boolean" || isNil(v) || isKw(v)) { - kwargs[args[i].name] = v; - } else if (isSym(v)) { - kwargs[args[i].name] = sxEval(v, env); - } else if (Array.isArray(v) && v.length && isSym(v[0])) { - // Expression with Symbol head — evaluate in caller's env. - // Render-only forms go through renderStr; data exprs through sxEval. - if (_isRenderExpr(v)) { - kwargs[args[i].name] = new RawHTML(renderStr(v, env)); - } else { - kwargs[args[i].name] = sxEval(v, env); - } - } else { - // Data arrays, dicts, etc — pass through as-is - kwargs[args[i].name] = v; - } - i += 2; - } else { children.push(args[i]); i++; } - } - var local = merge({}, comp.closure, env); - for (var pi = 0; pi < comp.params.length; pi++) { - var p = comp.params[pi]; - local[p] = (p in kwargs) ? kwargs[p] : NIL; - } - if (comp.hasChildren) { - var cs = []; - for (var ci = 0; ci < children.length; ci++) cs.push(renderStr(children[ci], env)); - local["children"] = new RawHTML(cs.join("")); - } - return renderStr(comp.body, local); - } - - // ========================================================================= - // Helpers - // ========================================================================= + // --- Helpers --- function merge(target) { for (var i = 1; i < arguments.length; i++) { @@ -1298,15 +1078,11 @@ /** Convert snake_case kwargs to kebab-case for sx conventions. */ function toKebab(s) { return s.replace(/_/g, "-"); } - // ========================================================================= - // Public API - // ========================================================================= + // --- Public API --- var _componentEnv = {}; - // ========================================================================= - // Head auto-hoist: move meta/title/link/script[ld+json] from body to - // ========================================================================= + // --- Head auto-hoist --- var HEAD_HOIST_SELECTOR = "meta, title, link[rel='canonical'], script[type='application/ld+json']"; @@ -1382,13 +1158,6 @@ return renderDOM(exprOrText, env); }, - // String Renderer (matches Python html.render output) - renderToString: function (exprOrText, extraEnv) { - var expr = typeof exprOrText === "string" ? parse(exprOrText) : exprOrText; - var env = extraEnv ? merge({}, _componentEnv, extraEnv) : _componentEnv; - return renderStr(expr, env); - }, - /** * Render a named component with keyword args (Python-style API). * Sx.renderComponent("card", {title: "Hi"}) @@ -1415,26 +1184,7 @@ var exprs = parseAll(text); for (var i = 0; i < exprs.length; i++) sxEval(exprs[i], _componentEnv); } catch (err) { - // Enhanced error logging: show context around parse failure - var colMatch = err.message && err.message.match(/col (\d+)/); - var lineMatch = err.message && err.message.match(/line (\d+)/); - if (colMatch && text) { - var errLine = lineMatch ? parseInt(lineMatch[1]) : 1; - var errCol = parseInt(colMatch[1]); - var lines = text.split("\n"); - var pos = 0; - for (var li = 0; li < errLine - 1 && li < lines.length; li++) pos += lines[li].length + 1; - pos += errCol; - var start = Math.max(0, pos - 120); - var end = Math.min(text.length, pos + 120); - console.error("sx.js loadComponents PARSE ERROR:", err.message, - "\n total length:", text.length, "lines:", lines.length, - "\n error line " + errLine + ":", lines[errLine - 1] ? lines[errLine - 1].substring(0, 200) : "(no such line)", - "\n around error (pos ~" + pos + "):", - "\n «" + text.substring(start, pos) + "⛔" + text.substring(pos, end) + "»"); - } else { - console.error("sx.js loadComponents error:", err, "\ntext first 500:", text ? text.substring(0, 500) : "(empty)"); - } + _logParseError("loadComponents PARSE ERROR", text, err, 120); throw err; } }, @@ -1458,28 +1208,7 @@ try { node = Sx.render(exprOrText, extraEnv); } catch (e) { - if (typeof exprOrText === "string") { - var src = exprOrText; - // Find approx position from error message - var colMatch = e.message && e.message.match(/col (\d+)/); - var lineMatch = e.message && e.message.match(/line (\d+)/); - if (colMatch) { - var errLine = lineMatch ? parseInt(lineMatch[1]) : 1; - var errCol = parseInt(colMatch[1]); - var lines = src.split("\n"); - var pos = 0; - for (var li = 0; li < errLine - 1 && li < lines.length; li++) pos += lines[li].length + 1; - pos += errCol; - var start = Math.max(0, pos - 80); - var end = Math.min(src.length, pos + 80); - console.error("sx.js MOUNT PARSE ERROR:", e.message, - "\n source length:", src.length, - "\n around error (pos ~" + pos + "):", - "\n «" + src.substring(start, pos) + "⛔" + src.substring(pos, end) + "»"); - } else { - console.error("sx.js MOUNT PARSE ERROR:", e.message, "\n first 500:", src.substring(0, 500)); - } - } + if (typeof exprOrText === "string") _logParseError("MOUNT PARSE ERROR", exprOrText, e, 80); throw e; } el.textContent = ""; @@ -1619,18 +1348,17 @@ } }, - // For testing + // For testing / sx-test.js _types: { NIL: NIL, Symbol: Symbol, Keyword: Keyword, Lambda: Lambda, Component: Component, RawHTML: RawHTML }, _eval: sxEval, - _renderStr: renderStr, + _expandMacro: expandMacro, + _callLambda: callLambda, _renderDOM: renderDOM, }; global.Sx = Sx; - // ========================================================================= - // SxEngine — native fetch/swap/history engine (replaces HTMX) - // ========================================================================= + // --- SxEngine — native fetch/swap/history engine --- var SxEngine = (function () { if (typeof document === "undefined") return {}; @@ -1889,24 +1617,7 @@ container.appendChild(sxDom); // OOB processing on live DOM nodes - var oobs = container.querySelectorAll("[sx-swap-oob]"); - oobs.forEach(function (oob) { - var oobSwap = oob.getAttribute("sx-swap-oob") || "outerHTML"; - var oobTarget = document.getElementById(oob.id); - oob.removeAttribute("sx-swap-oob"); - oob.parentNode.removeChild(oob); - if (oobTarget) _swapDOM(oobTarget, oob, oobSwap); - }); - - // hx-swap-oob compat - var hxOobs = container.querySelectorAll("[hx-swap-oob]"); - hxOobs.forEach(function (oob) { - var oobSwap = oob.getAttribute("hx-swap-oob") || "outerHTML"; - var oobTarget = document.getElementById(oob.id); - oob.removeAttribute("hx-swap-oob"); - oob.parentNode.removeChild(oob); - if (oobTarget) _swapDOM(oobTarget, oob, oobSwap); - }); + _processOOBSwaps(container, _swapDOM); // sx-select filtering var selectedDOM; @@ -1941,27 +1652,7 @@ Sx.processScripts(doc); // OOB processing - var oobs = doc.querySelectorAll("[sx-swap-oob]"); - oobs.forEach(function (oob) { - var oobSwap = oob.getAttribute("sx-swap-oob") || "outerHTML"; - var oobTarget = document.getElementById(oob.id); - oob.removeAttribute("sx-swap-oob"); - if (oobTarget) { - _swapContent(oobTarget, oob.outerHTML, oobSwap); - } - oob.parentNode.removeChild(oob); - }); - - var hxOobs = doc.querySelectorAll("[hx-swap-oob]"); - hxOobs.forEach(function (oob) { - var oobSwap = oob.getAttribute("hx-swap-oob") || "outerHTML"; - var oobTarget = document.getElementById(oob.id); - oob.removeAttribute("hx-swap-oob"); - if (oobTarget) { - _swapContent(oobTarget, oob.outerHTML, oobSwap); - } - oob.parentNode.removeChild(oob); - }); + _processOOBSwaps(doc, function (t, o, s) { _swapContent(t, o.outerHTML, s); }); // Build final content var content; @@ -2150,10 +1841,7 @@ } else { _morphDOM(target, newNodes); } - _activateScripts(parent); - Sx.processScripts(parent); - Sx.hydrate(parent); - SxEngine.process(parent); + _postSwap(parent); return; // early return like existing outerHTML case "afterend": target.parentNode.insertBefore(newNodes, target.nextSibling); @@ -2179,14 +1867,26 @@ _morphChildren(target, wrapper); } } - _activateScripts(target); - Sx.processScripts(target); - Sx.hydrate(target); - SxEngine.process(target); + _postSwap(target); } // ---- Swap engine (string-based, kept as fallback) ---------------------- + function _processOOBSwaps(container, swapFn, postSwapFn) { + ["sx-swap-oob", "hx-swap-oob"].forEach(function (attr) { + container.querySelectorAll("[" + attr + "]").forEach(function (oob) { + var swapType = oob.getAttribute(attr) || "outerHTML"; + var target = document.getElementById(oob.id); + oob.removeAttribute(attr); + if (oob.parentNode) oob.parentNode.removeChild(oob); + if (target) { + swapFn(target, oob, swapType); + if (postSwapFn) postSwapFn(target); + } + }); + }); + } + /** Scripts inserted via innerHTML/insertAdjacentHTML don't execute. * Recreate them as live elements so the browser fetches & runs them. */ function _activateScripts(root) { @@ -2201,6 +1901,13 @@ } } + function _postSwap(root) { + _activateScripts(root); + Sx.processScripts(root); + Sx.hydrate(root); + SxEngine.process(root); + } + function _swapContent(target, html, strategy) { switch (strategy) { case "innerHTML": @@ -2211,11 +1918,7 @@ var parent = tgt.parentNode; tgt.insertAdjacentHTML("afterend", html); parent.removeChild(tgt); - // Process parent to catch all newly inserted siblings - _activateScripts(parent); - Sx.processScripts(parent); - Sx.hydrate(parent); - SxEngine.process(parent); + _postSwap(parent); return; // early return — afterSwap handling done inline case "afterend": target.insertAdjacentHTML("afterend", html); @@ -2235,10 +1938,7 @@ default: target.innerHTML = html; } - _activateScripts(target); - Sx.processScripts(target); - Sx.hydrate(target); - SxEngine.process(target); + _postSwap(target); } // ---- Retry system ----------------------------------------------------- @@ -2413,37 +2113,11 @@ popContainer.appendChild(popDom); // Process OOB swaps (sidebar, filter, menu, headers) - var oobs = popContainer.querySelectorAll("[sx-swap-oob]"); - oobs.forEach(function (oob) { - var oobSwap = oob.getAttribute("sx-swap-oob") || "outerHTML"; - var oobTarget = document.getElementById(oob.id); - oob.removeAttribute("sx-swap-oob"); - oob.parentNode.removeChild(oob); - if (oobTarget) { - _swapDOM(oobTarget, oob, oobSwap); - Sx.hydrate(oobTarget); - SxEngine.process(oobTarget); - } - }); - var hxOobs = popContainer.querySelectorAll("[hx-swap-oob]"); - hxOobs.forEach(function (oob) { - var oobSwap = oob.getAttribute("hx-swap-oob") || "outerHTML"; - var oobTarget = document.getElementById(oob.id); - oob.removeAttribute("hx-swap-oob"); - oob.parentNode.removeChild(oob); - if (oobTarget) { - _swapDOM(oobTarget, oob, oobSwap); - Sx.hydrate(oobTarget); - SxEngine.process(oobTarget); - } - }); + _processOOBSwaps(popContainer, _swapDOM, function (t) { Sx.hydrate(t); SxEngine.process(t); }); var newMain = popContainer.querySelector("#main-panel"); _morphChildren(main, newMain || popContainer); - _activateScripts(main); - Sx.processScripts(main); - Sx.hydrate(main); - SxEngine.process(main); + _postSwap(main); dispatch(document.body, "sx:afterSettle", { target: main }); window.scrollTo(0, e.state && e.state.scrollY || 0); } catch (err) { @@ -2456,34 +2130,12 @@ var doc = parser.parseFromString(text, "text/html"); // Process OOB swaps from HTML response - var hOobs = doc.querySelectorAll("[sx-swap-oob]"); - hOobs.forEach(function (oob) { - var oobSwap = oob.getAttribute("sx-swap-oob") || "outerHTML"; - var oobTarget = document.getElementById(oob.id); - oob.removeAttribute("sx-swap-oob"); - if (oobTarget) { - _swapContent(oobTarget, oob.outerHTML, oobSwap); - } - oob.parentNode.removeChild(oob); - }); - var hhOobs = doc.querySelectorAll("[hx-swap-oob]"); - hhOobs.forEach(function (oob) { - var oobSwap = oob.getAttribute("hx-swap-oob") || "outerHTML"; - var oobTarget = document.getElementById(oob.id); - oob.removeAttribute("hx-swap-oob"); - if (oobTarget) { - _swapContent(oobTarget, oob.outerHTML, oobSwap); - } - oob.parentNode.removeChild(oob); - }); + _processOOBSwaps(doc, function (t, o, s) { _swapContent(t, o.outerHTML, s); }); var newMain = doc.getElementById("main-panel"); if (newMain) { _morphChildren(main, newMain); - _activateScripts(main); - Sx.processScripts(main); - Sx.hydrate(main); - SxEngine.process(main); + _postSwap(main); dispatch(document.body, "sx:afterSettle", { target: main }); window.scrollTo(0, e.state && e.state.scrollY || 0); } else { @@ -2561,52 +2213,29 @@ global.SxEngine = SxEngine; - // ========================================================================= - // Auto-init in browser - // ========================================================================= + // --- Auto-init in browser --- Sx.VERSION = "2026-03-01c-cssx"; // CSS class tracking for on-demand CSS delivery - var _sxCssKnown = {}; var _sxCssHash = ""; // 8-char hex hash from server function _initCssTracking() { var meta = document.querySelector('meta[name="sx-css-classes"]'); if (meta) { var content = meta.getAttribute("content"); - if (content) { - // If content is short (≤16 chars), it's a hash from the server - if (content.length <= 16) { - _sxCssHash = content; - } else { - content.split(",").forEach(function (c) { - if (c) _sxCssKnown[c] = true; - }); - } - } + if (content) _sxCssHash = content; } } function _getSxCssHeader() { - // Prefer sending the hash (compact) over the full class list - if (_sxCssHash) return _sxCssHash; - var names = Object.keys(_sxCssKnown); - return names.length ? names.join(",") : ""; + return _sxCssHash; } function _processCssResponse(text, resp) { - // Read SX-Css-Hash response header — replaces local hash var hashHeader = resp.headers.get("SX-Css-Hash"); if (hashHeader) _sxCssHash = hashHeader; - // Merge SX-Css-Add header into known set (kept for debugging/fallback) - var addHeader = resp.headers.get("SX-Css-Add"); - if (addHeader) { - addHeader.split(",").forEach(function (c) { - if (c) _sxCssKnown[c] = true; - }); - } // Extract blocks and inject into ` inline in response + `SX-Css-Hash` response header with updated cumulative hash +3. **Client accumulates**: `sx.js` extracts `') + # Pretty-print the sx source for readable display + try: + from shared.sx.parser import parse as _parse, serialize as _serialize + parts.append(_serialize(_parse(sx_src), pretty=True)) + except Exception: + parts.append(sx_src) + return "\n\n".join(parts) + + +# --------------------------------------------------------------------------- +# Navigation helpers +# --------------------------------------------------------------------------- + +async def _nav_items_sx(items: list[tuple[str, str]], current: str | None = None) -> str: + """Build nav link items as sx.""" + parts = [] + for label, href in items: + parts.append(await render_to_sx("nav-link", + href=href, label=label, + is_selected="true" if current == label else None, + select_colours="aria-selected:bg-violet-200 aria-selected:text-violet-900", + )) + return "(<> " + " ".join(parts) + ")" + + +async def _sx_header_sx(nav: str | None = None, *, child: str | None = None) -> str: + """Build the sx docs menu-row.""" + return await render_to_sx("menu-row-sx", + id="sx-row", level=1, colour="violet", + link_href="/", link_label="sx", + link_label_content=SxExpr('(span :class "font-mono" "()")'), + nav=SxExpr(nav) if nav else None, + child_id="sx-header-child", + child=SxExpr(child) if child else None, + ) + + +async def _docs_nav_sx(current: str | None = None) -> str: + from content.pages import DOCS_NAV + return await _nav_items_sx(DOCS_NAV, current) + + +async def _reference_nav_sx(current: str | None = None) -> str: + from content.pages import REFERENCE_NAV + return await _nav_items_sx(REFERENCE_NAV, current) + + +async def _protocols_nav_sx(current: str | None = None) -> str: + from content.pages import PROTOCOLS_NAV + return await _nav_items_sx(PROTOCOLS_NAV, current) + + +async def _examples_nav_sx(current: str | None = None) -> str: + from content.pages import EXAMPLES_NAV + return await _nav_items_sx(EXAMPLES_NAV, current) + + +async def _essays_nav_sx(current: str | None = None) -> str: + from content.pages import ESSAYS_NAV + return await _nav_items_sx(ESSAYS_NAV, current) + + +async def _main_nav_sx(current_section: str | None = None) -> str: + from content.pages import MAIN_NAV + return await _nav_items_sx(MAIN_NAV, current_section) + + +async def _sub_row_sx(sub_label: str, sub_href: str, sub_nav: str, + selected: str = "") -> str: + """Build the level-2 sub-section menu-row.""" + return await render_to_sx("menu-row-sx", + id="sx-sub-row", level=2, colour="violet", + link_href=sub_href, link_label=sub_label, + selected=selected or None, + nav=SxExpr(sub_nav), + ) + + + +# --------------------------------------------------------------------------- +# Content builders — return sx source strings +# --------------------------------------------------------------------------- + +async def _doc_nav_sx(items: list[tuple[str, str]], current: str) -> str: + """Build the in-page doc navigation pills.""" + items_sx = " ".join( + f'(list "{label}" "{href}")' + for label, href in items + ) + return await render_to_sx("doc-nav", items=SxExpr(f"(list {items_sx})"), current=current) + + +async def _attr_table_sx(title: str, attrs: list[tuple[str, str, bool]]) -> str: + """Build an attribute reference table.""" + from content.pages import ATTR_DETAILS + rows = [] + for attr, desc, exists in attrs: + href = f"/reference/attributes/{attr}" if exists and attr in ATTR_DETAILS else None + rows.append(await render_to_sx("doc-attr-row", attr=attr, description=desc, + exists="true" if exists else None, + href=href)) + return ( + f'(div :class "space-y-3"' + f' (h3 :class "text-xl font-semibold text-stone-700" "{title}")' + f' (div :class "overflow-x-auto rounded border border-stone-200"' + f' (table :class "w-full text-left text-sm"' + f' (thead (tr :class "border-b border-stone-200 bg-stone-50"' + f' (th :class "px-3 py-2 font-medium text-stone-600" "Attribute")' + f' (th :class "px-3 py-2 font-medium text-stone-600" "Description")' + f' (th :class "px-3 py-2 font-medium text-stone-600 text-center w-20" "In sx?")))' + f' (tbody {" ".join(rows)}))))' + ) + + +async def _primitives_section_sx() -> str: + """Build the primitives section.""" + from content.pages import PRIMITIVES + parts = [] + for category, prims in PRIMITIVES.items(): + prims_sx = " ".join(f'"{p}"' for p in prims) + parts.append(await render_to_sx("doc-primitives-table", + category=category, + primitives=SxExpr(f"(list {prims_sx})"))) + return " ".join(parts) + + +def _headers_table_sx(title: str, headers: list[tuple[str, str, str]]) -> str: + """Build a headers reference table.""" + rows = [] + for name, value, desc in headers: + rows.append( + f'(tr :class "border-b border-stone-100"' + f' (td :class "px-3 py-2 font-mono text-sm text-violet-700 whitespace-nowrap" "{name}")' + f' (td :class "px-3 py-2 font-mono text-sm text-stone-500" "{value}")' + f' (td :class "px-3 py-2 text-stone-700 text-sm" "{desc}"))' + ) + return ( + f'(div :class "space-y-3"' + f' (h3 :class "text-xl font-semibold text-stone-700" "{title}")' + f' (div :class "overflow-x-auto rounded border border-stone-200"' + f' (table :class "w-full text-left text-sm"' + f' (thead (tr :class "border-b border-stone-200 bg-stone-50"' + f' (th :class "px-3 py-2 font-medium text-stone-600" "Header")' + f' (th :class "px-3 py-2 font-medium text-stone-600" "Value")' + f' (th :class "px-3 py-2 font-medium text-stone-600" "Description")))' + f' (tbody {" ".join(rows)}))))' + ) + + + +async def _docs_content_sx(slug: str) -> str: + """Route to the right docs content builder.""" + import inspect + builders = { + "introduction": _docs_introduction_sx, + "getting-started": _docs_getting_started_sx, + "components": _docs_components_sx, + "evaluator": _docs_evaluator_sx, + "primitives": _docs_primitives_sx, + "css": _docs_css_sx, + "server-rendering": _docs_server_rendering_sx, + } + builder = builders.get(slug, _docs_introduction_sx) + result = builder() + return await result if inspect.isawaitable(result) else result + + +def _docs_introduction_sx() -> str: + return ( + '(~doc-page :title "Introduction"' + ' (~doc-section :title "What is sx?" :id "what"' + ' (p :class "text-stone-600"' + ' "sx is an s-expression language for building web UIs. ' + 'It combines htmx\'s server-first hypermedia approach with React\'s component model. ' + 'The server sends s-expression source code over the wire. The client parses, evaluates, and renders it to DOM.")' + ' (p :class "text-stone-600"' + ' "The same evaluator runs on both server (Python) and client (JavaScript). ' + 'Components defined once render identically in both environments."))' + ' (~doc-section :title "Design decisions" :id "design"' + ' (p :class "text-stone-600"' + ' "HTML elements are first-class: (div :class \\"card\\" (p \\"hello\\")) renders exactly what you\'d expect. ' + 'Components use defcomp with keyword parameters and optional children. ' + 'The evaluator supports let bindings, conditionals, lambda, map/filter/reduce, and ~80 primitives.")' + ' (p :class "text-stone-600"' + ' "sx replaces the pattern of ' + 'shipping a JS framework + build step + client-side router + state management library ' + 'just to render some server data. For most applications, sx eliminates the need for ' + 'JavaScript entirely — htmx attributes handle interactivity, hyperscript handles small behaviours, ' + 'and the server handles everything else."))' + ' (~doc-section :title "What sx is not" :id "not"' + ' (ul :class "space-y-2 text-stone-600"' + ' (li "Not a general-purpose programming language — it\'s a UI rendering language")' + ' (li "Not a full Lisp — it has macros and TCO, but no continuations or call/cc")' + ' (li "Not production-hardened at scale — it runs one website"))))' + ) + + +def _docs_getting_started_sx() -> str: + c1 = _code('(div :class "p-4 bg-white rounded"\n (h1 :class "text-2xl font-bold" "Hello, world!")\n (p "This is rendered from an s-expression."))') + c2 = _code('(button\n :sx-get "/api/data"\n :sx-target "#result"\n :sx-swap "innerHTML"\n "Load data")') + return ( + f'(~doc-page :title "Getting Started"' + f' (~doc-section :title "Minimal example" :id "minimal"' + f' (p :class "text-stone-600"' + f' "An sx response is s-expression source code with content type text/sx:")' + f' {c1}' + f' (p :class "text-stone-600"' + f' "Add sx-get to any element to make it fetch and render sx:"))' + f' (~doc-section :title "Hypermedia attributes" :id "attrs"' + f' (p :class "text-stone-600"' + f' "Like htmx, sx adds attributes to HTML elements to trigger HTTP requests:")' + f' {c2}' + f' (p :class "text-stone-600"' + f' "sx-get, sx-post, sx-put, sx-delete, sx-patch — all work the same way. ' + f'The response is parsed as sx and rendered into the target element.")))' + ) + + +def _docs_components_sx() -> str: + c1 = _code('(defcomp ~card (&key title subtitle &rest children)\n' + ' (div :class "border rounded p-4"\n' + ' (h2 :class "font-bold" title)\n' + ' (when subtitle (p :class "text-stone-500" subtitle))\n' + ' (div :class "mt-3" children)))') + c2 = _code('(~card :title "My Card" :subtitle "A description"\n' + ' (p "First child")\n' + ' (p "Second child"))') + return ( + f'(~doc-page :title "Components"' + f' (~doc-section :title "defcomp" :id "defcomp"' + f' (p :class "text-stone-600"' + f' "Components are defined with defcomp. They take keyword parameters and optional children:")' + f' {c1}' + f' (p :class "text-stone-600"' + f' "Use components with the ~ prefix:")' + f' {c2})' + f' (~doc-section :title "Component caching" :id "caching"' + f' (p :class "text-stone-600"' + f' "Component definitions are sent in a ') - parts.append('') - # Pretty-print the sx source for readable display - try: - from shared.sx.parser import parse as _parse, serialize as _serialize - parts.append(_serialize(_parse(sx_src), pretty=True)) - except Exception: - parts.append(sx_src) - return "\n\n".join(parts) - - -# --------------------------------------------------------------------------- -# Navigation helpers -# --------------------------------------------------------------------------- - -async def _nav_items_sx(items: list[tuple[str, str]], current: str | None = None) -> str: - """Build nav link items as sx.""" - parts = [] - for label, href in items: - parts.append(await render_to_sx("nav-link", - href=href, label=label, - is_selected="true" if current == label else None, - select_colours="aria-selected:bg-violet-200 aria-selected:text-violet-900", - )) - return "(<> " + " ".join(parts) + ")" - - -async def _sx_header_sx(nav: str | None = None, *, child: str | None = None) -> str: - """Build the sx docs menu-row.""" - return await render_to_sx("menu-row-sx", - id="sx-row", level=1, colour="violet", - link_href="/", link_label="sx", - link_label_content=SxExpr('(span :class "font-mono" "()")'), - nav=SxExpr(nav) if nav else None, - child_id="sx-header-child", - child=SxExpr(child) if child else None, - ) - - -async def _docs_nav_sx(current: str | None = None) -> str: - from content.pages import DOCS_NAV - return await _nav_items_sx(DOCS_NAV, current) - - -async def _reference_nav_sx(current: str | None = None) -> str: - from content.pages import REFERENCE_NAV - return await _nav_items_sx(REFERENCE_NAV, current) - - -async def _protocols_nav_sx(current: str | None = None) -> str: - from content.pages import PROTOCOLS_NAV - return await _nav_items_sx(PROTOCOLS_NAV, current) - - -async def _examples_nav_sx(current: str | None = None) -> str: - from content.pages import EXAMPLES_NAV - return await _nav_items_sx(EXAMPLES_NAV, current) - - -async def _essays_nav_sx(current: str | None = None) -> str: - from content.pages import ESSAYS_NAV - return await _nav_items_sx(ESSAYS_NAV, current) - - -async def _main_nav_sx(current_section: str | None = None) -> str: - from content.pages import MAIN_NAV - return await _nav_items_sx(MAIN_NAV, current_section) - - -async def _sub_row_sx(sub_label: str, sub_href: str, sub_nav: str, - selected: str = "") -> str: - """Build the level-2 sub-section menu-row.""" - return await render_to_sx("menu-row-sx", - id="sx-sub-row", level=2, colour="violet", - link_href=sub_href, link_label=sub_label, - selected=selected or None, - nav=SxExpr(sub_nav), - ) - - - -# --------------------------------------------------------------------------- -# Content builders — return sx source strings -# --------------------------------------------------------------------------- - -async def _doc_nav_sx(items: list[tuple[str, str]], current: str) -> str: - """Build the in-page doc navigation pills.""" - items_sx = " ".join( - f'(list "{label}" "{href}")' - for label, href in items - ) - return await render_to_sx("doc-nav", items=SxExpr(f"(list {items_sx})"), current=current) - - -async def _attr_table_sx(title: str, attrs: list[tuple[str, str, bool]]) -> str: - """Build an attribute reference table.""" - from content.pages import ATTR_DETAILS - rows = [] - for attr, desc, exists in attrs: - href = f"/reference/attributes/{attr}" if exists and attr in ATTR_DETAILS else None - rows.append(await render_to_sx("doc-attr-row", attr=attr, description=desc, - exists="true" if exists else None, - href=href)) - return ( - f'(div :class "space-y-3"' - f' (h3 :class "text-xl font-semibold text-stone-700" "{title}")' - f' (div :class "overflow-x-auto rounded border border-stone-200"' - f' (table :class "w-full text-left text-sm"' - f' (thead (tr :class "border-b border-stone-200 bg-stone-50"' - f' (th :class "px-3 py-2 font-medium text-stone-600" "Attribute")' - f' (th :class "px-3 py-2 font-medium text-stone-600" "Description")' - f' (th :class "px-3 py-2 font-medium text-stone-600 text-center w-20" "In sx?")))' - f' (tbody {" ".join(rows)}))))' - ) - - -async def _primitives_section_sx() -> str: - """Build the primitives section.""" - from content.pages import PRIMITIVES - parts = [] - for category, prims in PRIMITIVES.items(): - prims_sx = " ".join(f'"{p}"' for p in prims) - parts.append(await render_to_sx("doc-primitives-table", - category=category, - primitives=SxExpr(f"(list {prims_sx})"))) - return " ".join(parts) - - -def _headers_table_sx(title: str, headers: list[tuple[str, str, str]]) -> str: - """Build a headers reference table.""" - rows = [] - for name, value, desc in headers: - rows.append( - f'(tr :class "border-b border-stone-100"' - f' (td :class "px-3 py-2 font-mono text-sm text-violet-700 whitespace-nowrap" "{name}")' - f' (td :class "px-3 py-2 font-mono text-sm text-stone-500" "{value}")' - f' (td :class "px-3 py-2 text-stone-700 text-sm" "{desc}"))' - ) - return ( - f'(div :class "space-y-3"' - f' (h3 :class "text-xl font-semibold text-stone-700" "{title}")' - f' (div :class "overflow-x-auto rounded border border-stone-200"' - f' (table :class "w-full text-left text-sm"' - f' (thead (tr :class "border-b border-stone-200 bg-stone-50"' - f' (th :class "px-3 py-2 font-medium text-stone-600" "Header")' - f' (th :class "px-3 py-2 font-medium text-stone-600" "Value")' - f' (th :class "px-3 py-2 font-medium text-stone-600" "Description")))' - f' (tbody {" ".join(rows)}))))' - ) - - - -async def _docs_content_sx(slug: str) -> str: - """Route to the right docs content builder.""" - import inspect - builders = { - "introduction": _docs_introduction_sx, - "getting-started": _docs_getting_started_sx, - "components": _docs_components_sx, - "evaluator": _docs_evaluator_sx, - "primitives": _docs_primitives_sx, - "css": _docs_css_sx, - "server-rendering": _docs_server_rendering_sx, - } - builder = builders.get(slug, _docs_introduction_sx) - result = builder() - return await result if inspect.isawaitable(result) else result - - -def _docs_introduction_sx() -> str: - return ( - '(~doc-page :title "Introduction"' - ' (~doc-section :title "What is sx?" :id "what"' - ' (p :class "text-stone-600"' - ' "sx is an s-expression language for building web UIs. ' - 'It combines htmx\'s server-first hypermedia approach with React\'s component model. ' - 'The server sends s-expression source code over the wire. The client parses, evaluates, and renders it to DOM.")' - ' (p :class "text-stone-600"' - ' "The same evaluator runs on both server (Python) and client (JavaScript). ' - 'Components defined once render identically in both environments."))' - ' (~doc-section :title "Design decisions" :id "design"' - ' (p :class "text-stone-600"' - ' "HTML elements are first-class: (div :class \\"card\\" (p \\"hello\\")) renders exactly what you\'d expect. ' - 'Components use defcomp with keyword parameters and optional children. ' - 'The evaluator supports let bindings, conditionals, lambda, map/filter/reduce, and ~80 primitives.")' - ' (p :class "text-stone-600"' - ' "sx replaces the pattern of ' - 'shipping a JS framework + build step + client-side router + state management library ' - 'just to render some server data. For most applications, sx eliminates the need for ' - 'JavaScript entirely — htmx attributes handle interactivity, hyperscript handles small behaviours, ' - 'and the server handles everything else."))' - ' (~doc-section :title "What sx is not" :id "not"' - ' (ul :class "space-y-2 text-stone-600"' - ' (li "Not a general-purpose programming language — it\'s a UI rendering language")' - ' (li "Not a full Lisp — it has macros and TCO, but no continuations or call/cc")' - ' (li "Not production-hardened at scale — it runs one website"))))' - ) - - -def _docs_getting_started_sx() -> str: - c1 = _code('(div :class "p-4 bg-white rounded"\n (h1 :class "text-2xl font-bold" "Hello, world!")\n (p "This is rendered from an s-expression."))') - c2 = _code('(button\n :sx-get "/api/data"\n :sx-target "#result"\n :sx-swap "innerHTML"\n "Load data")') - return ( - f'(~doc-page :title "Getting Started"' - f' (~doc-section :title "Minimal example" :id "minimal"' - f' (p :class "text-stone-600"' - f' "An sx response is s-expression source code with content type text/sx:")' - f' {c1}' - f' (p :class "text-stone-600"' - f' "Add sx-get to any element to make it fetch and render sx:"))' - f' (~doc-section :title "Hypermedia attributes" :id "attrs"' - f' (p :class "text-stone-600"' - f' "Like htmx, sx adds attributes to HTML elements to trigger HTTP requests:")' - f' {c2}' - f' (p :class "text-stone-600"' - f' "sx-get, sx-post, sx-put, sx-delete, sx-patch — all work the same way. ' - f'The response is parsed as sx and rendered into the target element.")))' - ) - - -def _docs_components_sx() -> str: - c1 = _code('(defcomp ~card (&key title subtitle &rest children)\n' - ' (div :class "border rounded p-4"\n' - ' (h2 :class "font-bold" title)\n' - ' (when subtitle (p :class "text-stone-500" subtitle))\n' - ' (div :class "mt-3" children)))') - c2 = _code('(~card :title "My Card" :subtitle "A description"\n' - ' (p "First child")\n' - ' (p "Second child"))') - return ( - f'(~doc-page :title "Components"' - f' (~doc-section :title "defcomp" :id "defcomp"' - f' (p :class "text-stone-600"' - f' "Components are defined with defcomp. They take keyword parameters and optional children:")' - f' {c1}' - f' (p :class "text-stone-600"' - f' "Use components with the ~ prefix:")' - f' {c2})' - f' (~doc-section :title "Component caching" :id "caching"' - f' (p :class "text-stone-600"' - f' "Component definitions are sent in a @@ -681,6 +682,14 @@ def sx_page(ctx: dict, page_sx: str, *, except Exception: pass + # Style dictionary for client-side css primitive + styles_hash = _get_style_dict_hash() + client_styles_hash = _get_sx_styles_cookie() + if client_styles_hash and client_styles_hash == styles_hash: + styles_json = "" # Client has cached version + else: + styles_json = _build_style_dict_json() + return _SX_PAGE_TEMPLATE.format( title=_html_escape(title), asset_url=asset_url, @@ -688,6 +697,8 @@ def sx_page(ctx: dict, page_sx: str, *, csrf=_html_escape(csrf), component_hash=component_hash, component_defs=component_defs, + styles_hash=styles_hash, + styles_json=styles_json, page_sx=page_sx, sx_css=sx_css, sx_css_classes=sx_css_classes, @@ -697,6 +708,58 @@ def sx_page(ctx: dict, page_sx: str, *, _SCRIPT_HASH_CACHE: dict[str, str] = {} +_STYLE_DICT_JSON: str = "" +_STYLE_DICT_HASH: str = "" + + +def _build_style_dict_json() -> str: + """Build compact JSON style dictionary for client-side css primitive.""" + global _STYLE_DICT_JSON, _STYLE_DICT_HASH + if _STYLE_DICT_JSON: + return _STYLE_DICT_JSON + + import json + from .style_dict import ( + STYLE_ATOMS, PSEUDO_VARIANTS, RESPONSIVE_BREAKPOINTS, + KEYFRAMES, ARBITRARY_PATTERNS, CHILD_SELECTOR_ATOMS, + ) + + # Derive child selector prefixes from CHILD_SELECTOR_ATOMS + prefixes = set() + for atom in CHILD_SELECTOR_ATOMS: + # "space-y-4" → "space-y-", "divide-y" → "divide-" + for sep in ("space-x-", "space-y-", "divide-x", "divide-y"): + if atom.startswith(sep): + prefixes.add(sep) + break + + data = { + "a": STYLE_ATOMS, + "v": PSEUDO_VARIANTS, + "b": RESPONSIVE_BREAKPOINTS, + "k": KEYFRAMES, + "p": ARBITRARY_PATTERNS, + "c": sorted(prefixes), + } + _STYLE_DICT_JSON = json.dumps(data, separators=(",", ":")) + _STYLE_DICT_HASH = hashlib.md5(_STYLE_DICT_JSON.encode()).hexdigest()[:8] + return _STYLE_DICT_JSON + + +def _get_style_dict_hash() -> str: + """Get the hash of the style dictionary JSON.""" + if not _STYLE_DICT_HASH: + _build_style_dict_json() + return _STYLE_DICT_HASH + + +def _get_sx_styles_cookie() -> str: + """Read the sx-styles-hash cookie from the current request.""" + try: + from quart import request + return request.cookies.get("sx-styles-hash", "") + except Exception: + return "" def _script_hash(filename: str) -> str: diff --git a/shared/sx/html.py b/shared/sx/html.py index 3857dbb..510499a 100644 --- a/shared/sx/html.py +++ b/shared/sx/html.py @@ -27,7 +27,7 @@ from __future__ import annotations import contextvars from typing import Any -from .types import Component, Keyword, Lambda, Macro, NIL, Symbol +from .types import Component, Keyword, Lambda, Macro, NIL, StyleValue, Symbol from .evaluator import _eval as _raw_eval, _call_component as _raw_call_component, _expand_macro, _trampoline def _eval(expr, env): @@ -479,6 +479,19 @@ def _render_element(tag: str, args: list, env: dict[str, Any]) -> str: children.append(arg) i += 1 + # Handle :style StyleValue — convert to class and register CSS rule + style_val = attrs.get("style") + if isinstance(style_val, StyleValue): + from .css_registry import register_generated_rule + register_generated_rule(style_val) + # Merge into :class + existing_class = attrs.get("class") + if existing_class and existing_class is not NIL and existing_class is not False: + attrs["class"] = f"{existing_class} {style_val.class_name}" + else: + attrs["class"] = style_val.class_name + del attrs["style"] + # Collect CSS classes if collector is active class_val = attrs.get("class") if class_val is not None and class_val is not NIL and class_val is not False: diff --git a/shared/sx/parser.py b/shared/sx/parser.py index d5ed399..15c9a7c 100644 --- a/shared/sx/parser.py +++ b/shared/sx/parser.py @@ -336,6 +336,11 @@ def serialize(expr: Any, indent: int = 0, pretty: bool = False) -> str: items.append(serialize(v, indent, pretty)) return "{" + " ".join(items) + "}" + # StyleValue — serialize as class name string + from .types import StyleValue + if isinstance(expr, StyleValue): + return f'"{expr.class_name}"' + # _RawHTML — pre-rendered HTML; wrap as (raw! "...") for SX wire format from .html import _RawHTML if isinstance(expr, _RawHTML): diff --git a/shared/sx/primitives.py b/shared/sx/primitives.py index d903587..9dd202b 100644 --- a/shared/sx/primitives.py +++ b/shared/sx/primitives.py @@ -573,3 +573,41 @@ def prim_route_prefix() -> str: """``(route-prefix)`` → service URL prefix for dev/prod routing.""" from shared.utils import route_prefix return route_prefix() + + +# --------------------------------------------------------------------------- +# Style primitives +# --------------------------------------------------------------------------- + +@register_primitive("css") +def prim_css(*args: Any) -> Any: + """``(css :flex :gap-4 :hover:bg-sky-200)`` → StyleValue. + + Accepts keyword atoms (strings without colon prefix) and runtime + strings. Returns a StyleValue with a content-addressed class name + and all resolved CSS declarations. + """ + from .style_resolver import resolve_style + atoms = tuple( + (a.name if isinstance(a, Keyword) else str(a)) + for a in args if a is not None and a is not NIL and a is not False + ) + if not atoms: + return NIL + return resolve_style(atoms) + + +@register_primitive("merge-styles") +def prim_merge_styles(*styles: Any) -> Any: + """``(merge-styles style1 style2)`` → merged StyleValue. + + Merges multiple StyleValues; later declarations win. + """ + from .types import StyleValue + from .style_resolver import merge_styles + valid = [s for s in styles if isinstance(s, StyleValue)] + if not valid: + return NIL + if len(valid) == 1: + return valid[0] + return merge_styles(valid) diff --git a/shared/sx/style_dict.py b/shared/sx/style_dict.py new file mode 100644 index 0000000..199676f --- /dev/null +++ b/shared/sx/style_dict.py @@ -0,0 +1,735 @@ +""" +Style dictionary — maps keyword atoms to CSS declarations. + +Pure data. Each key is a Tailwind-compatible class name (used as an sx keyword +atom in ``(css :flex :gap-4 :p-2)``), and each value is the CSS declaration(s) +that class produces. Declarations are self-contained — no ``--tw-*`` custom +properties needed. + +Generated from the codebase's tw.css via ``css_registry.py`` then simplified +to remove Tailwind v3 variable indirection. + +Used by: + - ``style_resolver.py`` (server) — resolves ``(css ...)`` to StyleValue + - ``sx.js`` (client) — same resolution, cached in localStorage +""" +from __future__ import annotations + + +# ═══════════════════════════════════════════════════════════════════════════ +# Base atoms — keyword → CSS declarations +# ═══════════════════════════════════════════════════════════════════════════ +# +# ~466 atoms covering all utilities used across the codebase. +# Variants (hover:*, sm:*, focus:*, etc.) are NOT stored here — the +# resolver splits "hover:bg-sky-200" into variant="hover" + atom="bg-sky-200" +# and wraps the declaration in the appropriate pseudo/media rule. + +STYLE_ATOMS: dict[str, str] = { + # ── Display ────────────────────────────────────────────────────────── + "block": "display:block", + "inline-block": "display:inline-block", + "inline": "display:inline", + "flex": "display:flex", + "inline-flex": "display:inline-flex", + "table": "display:table", + "grid": "display:grid", + "contents": "display:contents", + "hidden": "display:none", + + # ── Position ───────────────────────────────────────────────────────── + "static": "position:static", + "fixed": "position:fixed", + "absolute": "position:absolute", + "relative": "position:relative", + "inset-0": "inset:0", + "top-0": "top:0", + "top-1/2": "top:50%", + "top-2": "top:.5rem", + "top-20": "top:5rem", + "top-[8px]": "top:8px", + "top-full": "top:100%", + "right-2": "right:.5rem", + "right-[8px]": "right:8px", + "bottom-full": "bottom:100%", + "left-1/2": "left:50%", + "left-2": "left:.5rem", + "-right-2": "right:-.5rem", + "-right-3": "right:-.75rem", + "-top-1.5": "top:-.375rem", + "-top-2": "top:-.5rem", + + # ── Z-Index ────────────────────────────────────────────────────────── + "z-10": "z-index:10", + "z-40": "z-index:40", + "z-50": "z-index:50", + + # ── Grid ───────────────────────────────────────────────────────────── + "grid-cols-1": "grid-template-columns:repeat(1,minmax(0,1fr))", + "grid-cols-2": "grid-template-columns:repeat(2,minmax(0,1fr))", + "grid-cols-3": "grid-template-columns:repeat(3,minmax(0,1fr))", + "grid-cols-4": "grid-template-columns:repeat(4,minmax(0,1fr))", + "grid-cols-5": "grid-template-columns:repeat(5,minmax(0,1fr))", + "grid-cols-6": "grid-template-columns:repeat(6,minmax(0,1fr))", + "grid-cols-7": "grid-template-columns:repeat(7,minmax(0,1fr))", + "grid-cols-12": "grid-template-columns:repeat(12,minmax(0,1fr))", + "col-span-2": "grid-column:span 2/span 2", + "col-span-3": "grid-column:span 3/span 3", + "col-span-4": "grid-column:span 4/span 4", + "col-span-5": "grid-column:span 5/span 5", + "col-span-12": "grid-column:span 12/span 12", + "col-span-full": "grid-column:1/-1", + + # ── Flexbox ────────────────────────────────────────────────────────── + "flex-row": "flex-direction:row", + "flex-col": "flex-direction:column", + "flex-wrap": "flex-wrap:wrap", + "flex-1": "flex:1 1 0%", + "flex-shrink-0": "flex-shrink:0", + "shrink-0": "flex-shrink:0", + "flex-shrink": "flex-shrink:1", + + # ── Alignment ──────────────────────────────────────────────────────── + "items-start": "align-items:flex-start", + "items-end": "align-items:flex-end", + "items-center": "align-items:center", + "items-baseline": "align-items:baseline", + "justify-start": "justify-content:flex-start", + "justify-end": "justify-content:flex-end", + "justify-center": "justify-content:center", + "justify-between": "justify-content:space-between", + "self-start": "align-self:flex-start", + "self-center": "align-self:center", + "place-items-center": "place-items:center", + + # ── Gap ─────────────────────────────────────────────────────────────── + "gap-px": "gap:1px", + "gap-0.5": "gap:.125rem", + "gap-1": "gap:.25rem", + "gap-1.5": "gap:.375rem", + "gap-2": "gap:.5rem", + "gap-3": "gap:.75rem", + "gap-4": "gap:1rem", + "gap-5": "gap:1.25rem", + "gap-6": "gap:1.5rem", + "gap-8": "gap:2rem", + "gap-[4px]": "gap:4px", + "gap-[8px]": "gap:8px", + "gap-[16px]": "gap:16px", + "gap-x-3": "column-gap:.75rem", + "gap-y-1": "row-gap:.25rem", + + # ── Margin ─────────────────────────────────────────────────────────── + "m-0": "margin:0", + "m-2": "margin:.5rem", + "mx-1": "margin-left:.25rem;margin-right:.25rem", + "mx-2": "margin-left:.5rem;margin-right:.5rem", + "mx-4": "margin-left:1rem;margin-right:1rem", + "mx-auto": "margin-left:auto;margin-right:auto", + "my-3": "margin-top:.75rem;margin-bottom:.75rem", + "-mb-px": "margin-bottom:-1px", + "mb-1": "margin-bottom:.25rem", + "mb-2": "margin-bottom:.5rem", + "mb-3": "margin-bottom:.75rem", + "mb-4": "margin-bottom:1rem", + "mb-6": "margin-bottom:1.5rem", + "mb-8": "margin-bottom:2rem", + "mb-12": "margin-bottom:3rem", + "mb-[8px]": "margin-bottom:8px", + "mb-[24px]": "margin-bottom:24px", + "ml-1": "margin-left:.25rem", + "ml-2": "margin-left:.5rem", + "ml-4": "margin-left:1rem", + "ml-auto": "margin-left:auto", + "mr-1": "margin-right:.25rem", + "mr-2": "margin-right:.5rem", + "mr-3": "margin-right:.75rem", + "mt-0.5": "margin-top:.125rem", + "mt-1": "margin-top:.25rem", + "mt-2": "margin-top:.5rem", + "mt-3": "margin-top:.75rem", + "mt-4": "margin-top:1rem", + "mt-6": "margin-top:1.5rem", + "mt-8": "margin-top:2rem", + "mt-[8px]": "margin-top:8px", + "mt-[16px]": "margin-top:16px", + "mt-[32px]": "margin-top:32px", + + # ── Padding ────────────────────────────────────────────────────────── + "p-0": "padding:0", + "p-1": "padding:.25rem", + "p-1.5": "padding:.375rem", + "p-2": "padding:.5rem", + "p-3": "padding:.75rem", + "p-4": "padding:1rem", + "p-5": "padding:1.25rem", + "p-6": "padding:1.5rem", + "p-8": "padding:2rem", + "px-1": "padding-left:.25rem;padding-right:.25rem", + "px-1.5": "padding-left:.375rem;padding-right:.375rem", + "px-2": "padding-left:.5rem;padding-right:.5rem", + "px-2.5": "padding-left:.625rem;padding-right:.625rem", + "px-3": "padding-left:.75rem;padding-right:.75rem", + "px-4": "padding-left:1rem;padding-right:1rem", + "px-6": "padding-left:1.5rem;padding-right:1.5rem", + "px-[8px]": "padding-left:8px;padding-right:8px", + "px-[12px]": "padding-left:12px;padding-right:12px", + "px-[16px]": "padding-left:16px;padding-right:16px", + "px-[20px]": "padding-left:20px;padding-right:20px", + "py-0.5": "padding-top:.125rem;padding-bottom:.125rem", + "py-1": "padding-top:.25rem;padding-bottom:.25rem", + "py-1.5": "padding-top:.375rem;padding-bottom:.375rem", + "py-2": "padding-top:.5rem;padding-bottom:.5rem", + "py-3": "padding-top:.75rem;padding-bottom:.75rem", + "py-4": "padding-top:1rem;padding-bottom:1rem", + "py-6": "padding-top:1.5rem;padding-bottom:1.5rem", + "py-8": "padding-top:2rem;padding-bottom:2rem", + "py-12": "padding-top:3rem;padding-bottom:3rem", + "py-16": "padding-top:4rem;padding-bottom:4rem", + "py-[6px]": "padding-top:6px;padding-bottom:6px", + "py-[12px]": "padding-top:12px;padding-bottom:12px", + "pb-1": "padding-bottom:.25rem", + "pb-2": "padding-bottom:.5rem", + "pb-3": "padding-bottom:.75rem", + "pb-4": "padding-bottom:1rem", + "pb-6": "padding-bottom:1.5rem", + "pb-8": "padding-bottom:2rem", + "pb-[48px]": "padding-bottom:48px", + "pl-2": "padding-left:.5rem", + "pl-5": "padding-left:1.25rem", + "pl-6": "padding-left:1.5rem", + "pr-1": "padding-right:.25rem", + "pr-2": "padding-right:.5rem", + "pr-4": "padding-right:1rem", + "pt-2": "padding-top:.5rem", + "pt-3": "padding-top:.75rem", + "pt-4": "padding-top:1rem", + "pt-[16px]": "padding-top:16px", + + # ── Width ──────────────────────────────────────────────────────────── + "w-1": "width:.25rem", + "w-2": "width:.5rem", + "w-4": "width:1rem", + "w-5": "width:1.25rem", + "w-6": "width:1.5rem", + "w-8": "width:2rem", + "w-10": "width:2.5rem", + "w-11": "width:2.75rem", + "w-12": "width:3rem", + "w-16": "width:4rem", + "w-20": "width:5rem", + "w-24": "width:6rem", + "w-28": "width:7rem", + "w-48": "width:12rem", + "w-1/2": "width:50%", + "w-1/3": "width:33.333333%", + "w-1/4": "width:25%", + "w-1/6": "width:16.666667%", + "w-2/6": "width:33.333333%", + "w-3/4": "width:75%", + "w-full": "width:100%", + "w-auto": "width:auto", + "w-[1em]": "width:1em", + "w-[32px]": "width:32px", + + # ── Height ─────────────────────────────────────────────────────────── + "h-2": "height:.5rem", + "h-4": "height:1rem", + "h-5": "height:1.25rem", + "h-6": "height:1.5rem", + "h-8": "height:2rem", + "h-10": "height:2.5rem", + "h-12": "height:3rem", + "h-14": "height:3.5rem", + "h-16": "height:4rem", + "h-24": "height:6rem", + "h-28": "height:7rem", + "h-48": "height:12rem", + "h-64": "height:16rem", + "h-full": "height:100%", + "h-[1em]": "height:1em", + "h-[30vh]": "height:30vh", + "h-[32px]": "height:32px", + "h-[60vh]": "height:60vh", + + # ── Min/Max Dimensions ─────────────────────────────────────────────── + "min-w-0": "min-width:0", + "min-w-full": "min-width:100%", + "min-w-[1.25rem]": "min-width:1.25rem", + "min-w-[180px]": "min-width:180px", + "min-h-0": "min-height:0", + "min-h-20": "min-height:5rem", + "min-h-[3rem]": "min-height:3rem", + "min-h-[50vh]": "min-height:50vh", + "max-w-xs": "max-width:20rem", + "max-w-md": "max-width:28rem", + "max-w-lg": "max-width:32rem", + "max-w-2xl": "max-width:42rem", + "max-w-3xl": "max-width:48rem", + "max-w-4xl": "max-width:56rem", + "max-w-full": "max-width:100%", + "max-w-none": "max-width:none", + "max-w-screen-2xl": "max-width:1536px", + "max-w-[360px]": "max-width:360px", + "max-w-[768px]": "max-width:768px", + "max-h-64": "max-height:16rem", + "max-h-96": "max-height:24rem", + "max-h-none": "max-height:none", + "max-h-[448px]": "max-height:448px", + "max-h-[50vh]": "max-height:50vh", + + # ── Typography ─────────────────────────────────────────────────────── + "text-xs": "font-size:.75rem;line-height:1rem", + "text-sm": "font-size:.875rem;line-height:1.25rem", + "text-base": "font-size:1rem;line-height:1.5rem", + "text-lg": "font-size:1.125rem;line-height:1.75rem", + "text-xl": "font-size:1.25rem;line-height:1.75rem", + "text-2xl": "font-size:1.5rem;line-height:2rem", + "text-3xl": "font-size:1.875rem;line-height:2.25rem", + "text-4xl": "font-size:2.25rem;line-height:2.5rem", + "text-5xl": "font-size:3rem;line-height:1", + "text-6xl": "font-size:3.75rem;line-height:1", + "text-8xl": "font-size:6rem;line-height:1", + "text-[8px]": "font-size:8px", + "text-[9px]": "font-size:9px", + "text-[10px]": "font-size:10px", + "text-[11px]": "font-size:11px", + "text-[13px]": "font-size:13px", + "text-[14px]": "font-size:14px", + "text-[16px]": "font-size:16px", + "text-[18px]": "font-size:18px", + "text-[36px]": "font-size:36px", + "text-[40px]": "font-size:40px", + "text-[0.6rem]": "font-size:.6rem", + "text-[0.65rem]": "font-size:.65rem", + "text-[0.7rem]": "font-size:.7rem", + "font-normal": "font-weight:400", + "font-medium": "font-weight:500", + "font-semibold": "font-weight:600", + "font-bold": "font-weight:700", + "font-mono": "font-family:ui-monospace,SFMono-Regular,Menlo,Monaco,Consolas,Liberation Mono,Courier New,monospace", + "italic": "font-style:italic", + "uppercase": "text-transform:uppercase", + "capitalize": "text-transform:capitalize", + "tabular-nums": "font-variant-numeric:tabular-nums", + "leading-none": "line-height:1", + "leading-tight": "line-height:1.25", + "leading-snug": "line-height:1.375", + "leading-relaxed": "line-height:1.625", + "tracking-tight": "letter-spacing:-.025em", + "tracking-wide": "letter-spacing:.025em", + "tracking-widest": "letter-spacing:.1em", + "text-left": "text-align:left", + "text-center": "text-align:center", + "text-right": "text-align:right", + "align-top": "vertical-align:top", + + # ── Text Colors ────────────────────────────────────────────────────── + "text-white": "color:rgb(255 255 255)", + "text-white/80": "color:rgba(255,255,255,.8)", + "text-black": "color:rgb(0 0 0)", + "text-stone-300": "color:rgb(214 211 209)", + "text-stone-400": "color:rgb(168 162 158)", + "text-stone-500": "color:rgb(120 113 108)", + "text-stone-600": "color:rgb(87 83 78)", + "text-stone-700": "color:rgb(68 64 60)", + "text-stone-800": "color:rgb(41 37 36)", + "text-stone-900": "color:rgb(28 25 23)", + "text-slate-400": "color:rgb(148 163 184)", + "text-gray-500": "color:rgb(107 114 128)", + "text-gray-600": "color:rgb(75 85 99)", + "text-red-500": "color:rgb(239 68 68)", + "text-red-600": "color:rgb(220 38 38)", + "text-red-700": "color:rgb(185 28 28)", + "text-red-800": "color:rgb(153 27 27)", + "text-rose-500": "color:rgb(244 63 94)", + "text-rose-600": "color:rgb(225 29 72)", + "text-rose-700": "color:rgb(190 18 60)", + "text-rose-800/80": "color:rgba(159,18,57,.8)", + "text-rose-900": "color:rgb(136 19 55)", + "text-orange-600": "color:rgb(234 88 12)", + "text-amber-500": "color:rgb(245 158 11)", + "text-amber-600": "color:rgb(217 119 6)", + "text-amber-700": "color:rgb(180 83 9)", + "text-amber-800": "color:rgb(146 64 14)", + "text-yellow-700": "color:rgb(161 98 7)", + "text-green-600": "color:rgb(22 163 74)", + "text-green-800": "color:rgb(22 101 52)", + "text-emerald-500": "color:rgb(16 185 129)", + "text-emerald-600": "color:rgb(5 150 105)", + "text-emerald-700": "color:rgb(4 120 87)", + "text-emerald-800": "color:rgb(6 95 70)", + "text-emerald-900": "color:rgb(6 78 59)", + "text-sky-600": "color:rgb(2 132 199)", + "text-sky-700": "color:rgb(3 105 161)", + "text-sky-800": "color:rgb(7 89 133)", + "text-blue-500": "color:rgb(59 130 246)", + "text-blue-600": "color:rgb(37 99 235)", + "text-blue-700": "color:rgb(29 78 216)", + "text-blue-800": "color:rgb(30 64 175)", + "text-purple-600": "color:rgb(147 51 234)", + "text-violet-600": "color:rgb(124 58 237)", + "text-violet-700": "color:rgb(109 40 217)", + "text-violet-800": "color:rgb(91 33 182)", + + # ── Background Colors ──────────────────────────────────────────────── + "bg-transparent": "background-color:transparent", + "bg-white": "background-color:rgb(255 255 255)", + "bg-white/60": "background-color:rgba(255,255,255,.6)", + "bg-white/70": "background-color:rgba(255,255,255,.7)", + "bg-white/80": "background-color:rgba(255,255,255,.8)", + "bg-white/90": "background-color:rgba(255,255,255,.9)", + "bg-black": "background-color:rgb(0 0 0)", + "bg-black/50": "background-color:rgba(0,0,0,.5)", + "bg-stone-50": "background-color:rgb(250 250 249)", + "bg-stone-100": "background-color:rgb(245 245 244)", + "bg-stone-200": "background-color:rgb(231 229 228)", + "bg-stone-300": "background-color:rgb(214 211 209)", + "bg-stone-400": "background-color:rgb(168 162 158)", + "bg-stone-500": "background-color:rgb(120 113 108)", + "bg-stone-600": "background-color:rgb(87 83 78)", + "bg-stone-700": "background-color:rgb(68 64 60)", + "bg-stone-800": "background-color:rgb(41 37 36)", + "bg-stone-900": "background-color:rgb(28 25 23)", + "bg-slate-100": "background-color:rgb(241 245 249)", + "bg-slate-200": "background-color:rgb(226 232 240)", + "bg-gray-100": "background-color:rgb(243 244 246)", + "bg-red-50": "background-color:rgb(254 242 242)", + "bg-red-100": "background-color:rgb(254 226 226)", + "bg-red-200": "background-color:rgb(254 202 202)", + "bg-red-500": "background-color:rgb(239 68 68)", + "bg-red-600": "background-color:rgb(220 38 38)", + "bg-rose-50": "background-color:rgb(255 241 242)", + "bg-rose-50/80": "background-color:rgba(255,241,242,.8)", + "bg-orange-100": "background-color:rgb(255 237 213)", + "bg-amber-50": "background-color:rgb(255 251 235)", + "bg-amber-50/60": "background-color:rgba(255,251,235,.6)", + "bg-amber-100": "background-color:rgb(254 243 199)", + "bg-amber-500": "background-color:rgb(245 158 11)", + "bg-amber-600": "background-color:rgb(217 119 6)", + "bg-yellow-50": "background-color:rgb(254 252 232)", + "bg-yellow-100": "background-color:rgb(254 249 195)", + "bg-yellow-200": "background-color:rgb(254 240 138)", + "bg-yellow-300": "background-color:rgb(253 224 71)", + "bg-green-50": "background-color:rgb(240 253 244)", + "bg-green-100": "background-color:rgb(220 252 231)", + "bg-emerald-50": "background-color:rgb(236 253 245)", + "bg-emerald-50/80": "background-color:rgba(236,253,245,.8)", + "bg-emerald-100": "background-color:rgb(209 250 229)", + "bg-emerald-200": "background-color:rgb(167 243 208)", + "bg-emerald-500": "background-color:rgb(16 185 129)", + "bg-emerald-600": "background-color:rgb(5 150 105)", + "bg-sky-100": "background-color:rgb(224 242 254)", + "bg-sky-200": "background-color:rgb(186 230 253)", + "bg-sky-300": "background-color:rgb(125 211 252)", + "bg-sky-400": "background-color:rgb(56 189 248)", + "bg-sky-500": "background-color:rgb(14 165 233)", + "bg-blue-50": "background-color:rgb(239 246 255)", + "bg-blue-100": "background-color:rgb(219 234 254)", + "bg-blue-600": "background-color:rgb(37 99 235)", + "bg-purple-600": "background-color:rgb(147 51 234)", + "bg-violet-50": "background-color:rgb(245 243 255)", + "bg-violet-100": "background-color:rgb(237 233 254)", + "bg-violet-200": "background-color:rgb(221 214 254)", + "bg-violet-300": "background-color:rgb(196 181 253)", + "bg-violet-400": "background-color:rgb(167 139 250)", + "bg-violet-500": "background-color:rgb(139 92 246)", + "bg-violet-600": "background-color:rgb(124 58 237)", + + # ── Border ─────────────────────────────────────────────────────────── + "border": "border-width:1px", + "border-2": "border-width:2px", + "border-4": "border-width:4px", + "border-t": "border-top-width:1px", + "border-t-0": "border-top-width:0", + "border-b": "border-bottom-width:1px", + "border-b-2": "border-bottom-width:2px", + "border-r": "border-right-width:1px", + "border-l-4": "border-left-width:4px", + "border-dashed": "border-style:dashed", + "border-none": "border-style:none", + "border-transparent": "border-color:transparent", + "border-white": "border-color:rgb(255 255 255)", + "border-white/30": "border-color:rgba(255,255,255,.3)", + "border-stone-100": "border-color:rgb(245 245 244)", + "border-stone-200": "border-color:rgb(231 229 228)", + "border-stone-300": "border-color:rgb(214 211 209)", + "border-stone-700": "border-color:rgb(68 64 60)", + "border-red-200": "border-color:rgb(254 202 202)", + "border-red-300": "border-color:rgb(252 165 165)", + "border-rose-200": "border-color:rgb(254 205 211)", + "border-rose-300": "border-color:rgb(253 164 175)", + "border-amber-200": "border-color:rgb(253 230 138)", + "border-amber-300": "border-color:rgb(252 211 77)", + "border-yellow-200": "border-color:rgb(254 240 138)", + "border-green-300": "border-color:rgb(134 239 172)", + "border-emerald-100": "border-color:rgb(209 250 229)", + "border-emerald-200": "border-color:rgb(167 243 208)", + "border-emerald-300": "border-color:rgb(110 231 183)", + "border-emerald-600": "border-color:rgb(5 150 105)", + "border-blue-200": "border-color:rgb(191 219 254)", + "border-blue-300": "border-color:rgb(147 197 253)", + "border-violet-200": "border-color:rgb(221 214 254)", + "border-violet-300": "border-color:rgb(196 181 253)", + "border-violet-400": "border-color:rgb(167 139 250)", + "border-t-white": "border-top-color:rgb(255 255 255)", + "border-t-stone-600": "border-top-color:rgb(87 83 78)", + "border-l-stone-400": "border-left-color:rgb(168 162 158)", + + # ── Border Radius ──────────────────────────────────────────────────── + "rounded": "border-radius:.25rem", + "rounded-md": "border-radius:.375rem", + "rounded-lg": "border-radius:.5rem", + "rounded-xl": "border-radius:.75rem", + "rounded-2xl": "border-radius:1rem", + "rounded-full": "border-radius:9999px", + "rounded-t": "border-top-left-radius:.25rem;border-top-right-radius:.25rem", + "rounded-b": "border-bottom-left-radius:.25rem;border-bottom-right-radius:.25rem", + "rounded-[4px]": "border-radius:4px", + "rounded-[8px]": "border-radius:8px", + + # ── Shadow ─────────────────────────────────────────────────────────── + "shadow-sm": "box-shadow:0 1px 2px 0 rgba(0,0,0,.05)", + "shadow": "box-shadow:0 1px 3px 0 rgba(0,0,0,.1),0 1px 2px -1px rgba(0,0,0,.1)", + "shadow-md": "box-shadow:0 4px 6px -1px rgba(0,0,0,.1),0 2px 4px -2px rgba(0,0,0,.1)", + "shadow-lg": "box-shadow:0 10px 15px -3px rgba(0,0,0,.1),0 4px 6px -4px rgba(0,0,0,.1)", + "shadow-xl": "box-shadow:0 20px 25px -5px rgba(0,0,0,.1),0 8px 10px -6px rgba(0,0,0,.1)", + + # ── Opacity ────────────────────────────────────────────────────────── + "opacity-0": "opacity:0", + "opacity-40": "opacity:.4", + "opacity-50": "opacity:.5", + "opacity-100": "opacity:1", + + # ── Ring / Outline ─────────────────────────────────────────────────── + "outline-none": "outline:2px solid transparent;outline-offset:2px", + "ring-2": "box-shadow:0 0 0 2px var(--tw-ring-color,rgb(59 130 246))", + "ring-offset-2": "box-shadow:0 0 0 2px rgb(255 255 255),0 0 0 4px var(--tw-ring-color,rgb(59 130 246))", + + # ── Overflow ───────────────────────────────────────────────────────── + "overflow-hidden": "overflow:hidden", + "overflow-x-auto": "overflow-x:auto", + "overflow-y-auto": "overflow-y:auto", + "overscroll-contain": "overscroll-behavior:contain", + + # ── Text Decoration ────────────────────────────────────────────────── + "underline": "text-decoration-line:underline", + "line-through": "text-decoration-line:line-through", + "no-underline": "text-decoration-line:none", + + # ── Text Overflow ──────────────────────────────────────────────────── + "truncate": "overflow:hidden;text-overflow:ellipsis;white-space:nowrap", + "line-clamp-2": "display:-webkit-box;-webkit-line-clamp:2;-webkit-box-orient:vertical;overflow:hidden", + "line-clamp-3": "display:-webkit-box;-webkit-line-clamp:3;-webkit-box-orient:vertical;overflow:hidden", + + # ── Whitespace / Word Break ────────────────────────────────────────── + "whitespace-normal": "white-space:normal", + "whitespace-nowrap": "white-space:nowrap", + "whitespace-pre-line": "white-space:pre-line", + "whitespace-pre-wrap": "white-space:pre-wrap", + "break-words": "overflow-wrap:break-word", + "break-all": "word-break:break-all", + + # ── Transform ──────────────────────────────────────────────────────── + "rotate-180": "transform:rotate(180deg)", + "-translate-x-1/2": "transform:translateX(-50%)", + "-translate-y-1/2": "transform:translateY(-50%)", + + # ── Transition ─────────────────────────────────────────────────────── + "transition": "transition-property:color,background-color,border-color,text-decoration-color,fill,stroke,opacity,box-shadow,transform,filter,backdrop-filter;transition-timing-function:cubic-bezier(.4,0,.2,1);transition-duration:.15s", + "transition-all": "transition-property:all;transition-timing-function:cubic-bezier(.4,0,.2,1);transition-duration:.15s", + "transition-colors": "transition-property:color,background-color,border-color,text-decoration-color,fill,stroke;transition-timing-function:cubic-bezier(.4,0,.2,1);transition-duration:.15s", + "transition-opacity": "transition-property:opacity;transition-timing-function:cubic-bezier(.4,0,.2,1);transition-duration:.15s", + "transition-transform": "transition-property:transform;transition-timing-function:cubic-bezier(.4,0,.2,1);transition-duration:.15s", + "duration-75": "transition-duration:75ms", + "duration-100": "transition-duration:100ms", + "duration-150": "transition-duration:150ms", + "duration-200": "transition-duration:200ms", + "duration-300": "transition-duration:300ms", + "duration-500": "transition-duration:500ms", + "duration-700": "transition-duration:700ms", + + # ── Animation ──────────────────────────────────────────────────────── + "animate-spin": "animation:spin 1s linear infinite", + "animate-ping": "animation:ping 1s cubic-bezier(0,0,0.2,1) infinite", + "animate-pulse": "animation:pulse 2s cubic-bezier(0.4,0,0.6,1) infinite", + "animate-bounce": "animation:bounce 1s infinite", + "animate-none": "animation:none", + + # ── Aspect Ratio ───────────────────────────────────────────────────── + "aspect-square": "aspect-ratio:1/1", + "aspect-video": "aspect-ratio:16/9", + + # ── Object Fit / Position ──────────────────────────────────────────── + "object-contain": "object-fit:contain", + "object-cover": "object-fit:cover", + "object-center": "object-position:center", + "object-top": "object-position:top", + + # ── Cursor ─────────────────────────────────────────────────────────── + "cursor-pointer": "cursor:pointer", + "cursor-move": "cursor:move", + + # ── User Select ────────────────────────────────────────────────────── + "select-none": "user-select:none", + "select-all": "user-select:all", + + # ── Pointer Events ─────────────────────────────────────────────────── + "pointer-events-none": "pointer-events:none", + + # ── Resize ─────────────────────────────────────────────────────────── + "resize": "resize:both", + "resize-none": "resize:none", + + # ── Scroll Snap ────────────────────────────────────────────────────── + "snap-y": "scroll-snap-type:y mandatory", + "snap-start": "scroll-snap-align:start", + "snap-mandatory": "scroll-snap-type:y mandatory", + + # ── List Style ─────────────────────────────────────────────────────── + "list-disc": "list-style-type:disc", + "list-decimal": "list-style-type:decimal", + "list-inside": "list-style-position:inside", + + # ── Table ──────────────────────────────────────────────────────────── + "table-fixed": "table-layout:fixed", + + # ── Backdrop ───────────────────────────────────────────────────────── + "backdrop-blur": "backdrop-filter:blur(8px)", + "backdrop-blur-sm": "backdrop-filter:blur(4px)", + "backdrop-blur-md": "backdrop-filter:blur(12px)", + + # ── Filter ─────────────────────────────────────────────────────────── + "saturate-0": "filter:saturate(0)", + + # ── Space Between (child selector atoms) ───────────────────────────── + # These generate `.atom > :not(:first-child)` rules + "space-y-0": "margin-top:0", + "space-y-0.5": "margin-top:.125rem", + "space-y-1": "margin-top:.25rem", + "space-y-2": "margin-top:.5rem", + "space-y-3": "margin-top:.75rem", + "space-y-4": "margin-top:1rem", + "space-y-6": "margin-top:1.5rem", + "space-y-8": "margin-top:2rem", + "space-y-10": "margin-top:2.5rem", + "space-x-1": "margin-left:.25rem", + "space-x-2": "margin-left:.5rem", + + # ── Divide (child selector atoms) ──────────────────────────────────── + # These generate `.atom > :not(:first-child)` rules + "divide-y": "border-top-width:1px", + "divide-stone-100": "border-color:rgb(245 245 244)", + "divide-stone-200": "border-color:rgb(231 229 228)", + + # ── Important modifiers ────────────────────────────────────────────── + "!bg-stone-500": "background-color:rgb(120 113 108)!important", + "!text-white": "color:rgb(255 255 255)!important", +} + +# Atoms that need a child selector: `.atom > :not(:first-child)` instead of `.atom` +CHILD_SELECTOR_ATOMS: frozenset[str] = frozenset({ + k for k in STYLE_ATOMS + if k.startswith(("space-x-", "space-y-", "divide-y", "divide-x")) + and not k.startswith("divide-stone") +}) + + +# ═══════════════════════════════════════════════════════════════════════════ +# Pseudo-class / pseudo-element variants +# ═══════════════════════════════════════════════════════════════════════════ + +PSEUDO_VARIANTS: dict[str, str] = { + "hover": ":hover", + "focus": ":focus", + "focus-within": ":focus-within", + "focus-visible": ":focus-visible", + "active": ":active", + "disabled": ":disabled", + "first": ":first-child", + "last": ":last-child", + "odd": ":nth-child(odd)", + "even": ":nth-child(even)", + "empty": ":empty", + "open": "[open]", + "placeholder": "::placeholder", + "file": "::file-selector-button", + "aria-selected": "[aria-selected=true]", + "group-hover": ":is(.group:hover) &", + "group-open": ":is(.group[open]) &", +} + + +# ═══════════════════════════════════════════════════════════════════════════ +# Responsive breakpoints +# ═══════════════════════════════════════════════════════════════════════════ + +RESPONSIVE_BREAKPOINTS: dict[str, str] = { + "sm": "(min-width:640px)", + "md": "(min-width:768px)", + "lg": "(min-width:1024px)", + "xl": "(min-width:1280px)", + "2xl": "(min-width:1536px)", +} + + +# ═══════════════════════════════════════════════════════════════════════════ +# Keyframes — built-in animation definitions +# ═══════════════════════════════════════════════════════════════════════════ + +KEYFRAMES: dict[str, str] = { + "spin": "@keyframes spin{to{transform:rotate(360deg)}}", + "ping": "@keyframes ping{75%,100%{transform:scale(2);opacity:0}}", + "pulse": "@keyframes pulse{50%{opacity:.5}}", + "bounce": "@keyframes bounce{0%,100%{transform:translateY(-25%);animation-timing-function:cubic-bezier(0.8,0,1,1)}50%{transform:none;animation-timing-function:cubic-bezier(0,0,0.2,1)}}", +} + + +# ═══════════════════════════════════════════════════════════════════════════ +# Arbitrary value patterns — fallback when atom not in STYLE_ATOMS +# ═══════════════════════════════════════════════════════════════════════════ +# +# Each tuple is (regex_pattern, css_template). +# The regex captures value groups; the template uses {0}, {1}, etc. + +ARBITRARY_PATTERNS: list[tuple[str, str]] = [ + # Width / Height + (r"w-\[(.+)\]", "width:{0}"), + (r"h-\[(.+)\]", "height:{0}"), + (r"min-w-\[(.+)\]", "min-width:{0}"), + (r"min-h-\[(.+)\]", "min-height:{0}"), + (r"max-w-\[(.+)\]", "max-width:{0}"), + (r"max-h-\[(.+)\]", "max-height:{0}"), + # Spacing + (r"p-\[(.+)\]", "padding:{0}"), + (r"px-\[(.+)\]", "padding-left:{0};padding-right:{0}"), + (r"py-\[(.+)\]", "padding-top:{0};padding-bottom:{0}"), + (r"pt-\[(.+)\]", "padding-top:{0}"), + (r"pb-\[(.+)\]", "padding-bottom:{0}"), + (r"pl-\[(.+)\]", "padding-left:{0}"), + (r"pr-\[(.+)\]", "padding-right:{0}"), + (r"m-\[(.+)\]", "margin:{0}"), + (r"mx-\[(.+)\]", "margin-left:{0};margin-right:{0}"), + (r"my-\[(.+)\]", "margin-top:{0};margin-bottom:{0}"), + (r"mt-\[(.+)\]", "margin-top:{0}"), + (r"mb-\[(.+)\]", "margin-bottom:{0}"), + (r"ml-\[(.+)\]", "margin-left:{0}"), + (r"mr-\[(.+)\]", "margin-right:{0}"), + # Gap + (r"gap-\[(.+)\]", "gap:{0}"), + (r"gap-x-\[(.+)\]", "column-gap:{0}"), + (r"gap-y-\[(.+)\]", "row-gap:{0}"), + # Position + (r"top-\[(.+)\]", "top:{0}"), + (r"right-\[(.+)\]", "right:{0}"), + (r"bottom-\[(.+)\]", "bottom:{0}"), + (r"left-\[(.+)\]", "left:{0}"), + # Border radius + (r"rounded-\[(.+)\]", "border-radius:{0}"), + # Background / Text color + (r"bg-\[(.+)\]", "background-color:{0}"), + (r"text-\[(.+)\]", "font-size:{0}"), + # Grid + (r"grid-cols-\[(.+)\]", "grid-template-columns:{0}"), + (r"col-span-(\d+)", "grid-column:span {0}/span {0}"), +] diff --git a/shared/sx/style_resolver.py b/shared/sx/style_resolver.py new file mode 100644 index 0000000..eb4ffdd --- /dev/null +++ b/shared/sx/style_resolver.py @@ -0,0 +1,254 @@ +""" +Style resolver — ``(css :flex :gap-4 :hover:bg-sky-200)`` → StyleValue. + +Resolves a tuple of atom strings into a ``StyleValue`` with: +- A content-addressed class name (``sx-{hash[:6]}``) +- Base CSS declarations +- Pseudo-class rules (hover, focus, etc.) +- Media-query rules (responsive breakpoints) +- Referenced @keyframes definitions + +Resolution order per atom: + 1. Dictionary lookup in ``STYLE_ATOMS`` + 2. Arbitrary value pattern match (``w-[347px]`` → ``width:347px``) + 3. Ignored (unknown atoms are silently skipped) + +Results are memoized by input tuple for zero-cost repeat calls. +""" +from __future__ import annotations + +import hashlib +import re +from functools import lru_cache +from typing import Sequence + +from .style_dict import ( + ARBITRARY_PATTERNS, + CHILD_SELECTOR_ATOMS, + KEYFRAMES, + PSEUDO_VARIANTS, + RESPONSIVE_BREAKPOINTS, + STYLE_ATOMS, +) +from .types import StyleValue + + +# --------------------------------------------------------------------------- +# Compiled arbitrary-value patterns +# --------------------------------------------------------------------------- + +_COMPILED_PATTERNS: list[tuple[re.Pattern, str]] = [ + (re.compile(f"^{pat}$"), tmpl) + for pat, tmpl in ARBITRARY_PATTERNS +] + + +# --------------------------------------------------------------------------- +# Public API +# --------------------------------------------------------------------------- + +def resolve_style(atoms: tuple[str, ...]) -> StyleValue: + """Resolve a tuple of keyword atoms into a StyleValue. + + Each atom is a Tailwind-compatible keyword (``flex``, ``gap-4``, + ``hover:bg-sky-200``, ``sm:flex-row``, etc.). Both keywords + (without leading colon) and runtime strings are accepted. + """ + return _resolve_cached(atoms) + + +def merge_styles(styles: Sequence[StyleValue]) -> StyleValue: + """Merge multiple StyleValues into one. + + Later declarations win for the same CSS property. Class name is + recomputed from the merged declarations. + """ + if len(styles) == 1: + return styles[0] + + all_decls: list[str] = [] + all_media: list[tuple[str, str]] = [] + all_pseudo: list[tuple[str, str]] = [] + all_kf: list[tuple[str, str]] = [] + + for sv in styles: + if sv.declarations: + all_decls.append(sv.declarations) + all_media.extend(sv.media_rules) + all_pseudo.extend(sv.pseudo_rules) + all_kf.extend(sv.keyframes) + + merged_decls = ";".join(all_decls) + return _build_style_value( + merged_decls, + tuple(all_media), + tuple(all_pseudo), + tuple(dict(all_kf).items()), # dedupe keyframes by name + ) + + +# --------------------------------------------------------------------------- +# Internal resolution +# --------------------------------------------------------------------------- + +@lru_cache(maxsize=4096) +def _resolve_cached(atoms: tuple[str, ...]) -> StyleValue: + """Memoized resolver.""" + base_decls: list[str] = [] + media_rules: list[tuple[str, str]] = [] # (query, decls) + pseudo_rules: list[tuple[str, str]] = [] # (selector_suffix, decls) + keyframes_needed: list[tuple[str, str]] = [] + + for atom in atoms: + if not atom: + continue + # Strip leading colon if keyword form (":flex" → "flex") + a = atom.lstrip(":") + + # Split variant prefix(es): "hover:bg-sky-200" → ["hover", "bg-sky-200"] + # "sm:hover:bg-sky-200" → ["sm", "hover", "bg-sky-200"] + variant, base = _split_variant(a) + + # Resolve the base atom to CSS declarations + decls = _resolve_atom(base) + if not decls: + continue + + # Check if this atom references a keyframe + _check_keyframes(base, keyframes_needed) + + # Route to the appropriate bucket + if variant is None: + base_decls.append(decls) + elif variant in RESPONSIVE_BREAKPOINTS: + query = RESPONSIVE_BREAKPOINTS[variant] + media_rules.append((query, decls)) + elif variant in PSEUDO_VARIANTS: + pseudo_sel = PSEUDO_VARIANTS[variant] + pseudo_rules.append((pseudo_sel, decls)) + else: + # Compound variant: "sm:hover:..." → media + pseudo + parts = variant.split(":") + media_part = None + pseudo_part = None + for p in parts: + if p in RESPONSIVE_BREAKPOINTS: + media_part = RESPONSIVE_BREAKPOINTS[p] + elif p in PSEUDO_VARIANTS: + pseudo_part = PSEUDO_VARIANTS[p] + if media_part and pseudo_part: + # Both media and pseudo — store as pseudo within media + # For now, put in pseudo_rules with media annotation + pseudo_rules.append((pseudo_part, decls)) + media_rules.append((media_part, decls)) + elif media_part: + media_rules.append((media_part, decls)) + elif pseudo_part: + pseudo_rules.append((pseudo_part, decls)) + else: + # Unknown variant — treat as base + base_decls.append(decls) + + return _build_style_value( + ";".join(base_decls), + tuple(media_rules), + tuple(pseudo_rules), + tuple(keyframes_needed), + ) + + +def _split_variant(atom: str) -> tuple[str | None, str]: + """Split a potentially variant-prefixed atom. + + Returns (variant, base) where variant is None for non-prefixed atoms. + Examples: + "flex" → (None, "flex") + "hover:bg-sky-200" → ("hover", "bg-sky-200") + "sm:flex-row" → ("sm", "flex-row") + "sm:hover:bg-sky-200" → ("sm:hover", "bg-sky-200") + """ + # Check for responsive prefix first (always outermost) + for bp in RESPONSIVE_BREAKPOINTS: + prefix = bp + ":" + if atom.startswith(prefix): + rest = atom[len(prefix):] + # Check for nested pseudo variant + for pv in PSEUDO_VARIANTS: + inner_prefix = pv + ":" + if rest.startswith(inner_prefix): + return (bp + ":" + pv, rest[len(inner_prefix):]) + return (bp, rest) + + # Check for pseudo variant + for pv in PSEUDO_VARIANTS: + prefix = pv + ":" + if atom.startswith(prefix): + return (pv, atom[len(prefix):]) + + return (None, atom) + + +def _resolve_atom(atom: str) -> str | None: + """Look up CSS declarations for a single base atom. + + Returns None if the atom is unknown. + """ + # 1. Dictionary lookup + decls = STYLE_ATOMS.get(atom) + if decls is not None: + return decls + + # 2. Dynamic keyframes: animate-{name} → animation-name:{name} + if atom.startswith("animate-"): + name = atom[len("animate-"):] + if name in KEYFRAMES: + return f"animation-name:{name}" + + # 3. Arbitrary value pattern match + for pattern, template in _COMPILED_PATTERNS: + m = pattern.match(atom) + if m: + groups = m.groups() + result = template + for i, g in enumerate(groups): + result = result.replace(f"{{{i}}}", g) + return result + + # 4. Unknown atom — silently skip + return None + + +def _check_keyframes(atom: str, kf_list: list[tuple[str, str]]) -> None: + """If the atom references a built-in animation, add its @keyframes.""" + if atom.startswith("animate-"): + name = atom[len("animate-"):] + if name in KEYFRAMES: + kf_list.append((name, KEYFRAMES[name])) + + +def _build_style_value( + declarations: str, + media_rules: tuple, + pseudo_rules: tuple, + keyframes: tuple, +) -> StyleValue: + """Build a StyleValue with a content-addressed class name.""" + # Build hash from all rules for deterministic class name + hash_input = declarations + for query, decls in media_rules: + hash_input += f"@{query}{{{decls}}}" + for sel, decls in pseudo_rules: + hash_input += f"{sel}{{{decls}}}" + for name, rule in keyframes: + hash_input += rule + + h = hashlib.sha256(hash_input.encode()).hexdigest()[:6] + class_name = f"sx-{h}" + + return StyleValue( + class_name=class_name, + declarations=declarations, + media_rules=media_rules, + pseudo_rules=pseudo_rules, + keyframes=keyframes, + ) diff --git a/shared/sx/types.py b/shared/sx/types.py index 1613c08..dbe1e19 100644 --- a/shared/sx/types.py +++ b/shared/sx/types.py @@ -278,9 +278,33 @@ class ActionDef: return f"" +# --------------------------------------------------------------------------- +# StyleValue +# --------------------------------------------------------------------------- + +@dataclass(frozen=True) +class StyleValue: + """A resolved CSS style produced by ``(css :flex :gap-4 :hover:bg-sky-200)``. + + Generated by the style resolver. The renderer emits ``class_name`` as a + CSS class and registers the CSS rule for on-demand delivery. + """ + class_name: str # "sx-a3f2c1" + declarations: str # "display:flex;gap:1rem" + media_rules: tuple = () # ((query, decls), ...) + pseudo_rules: tuple = () # ((selector, decls), ...) + keyframes: tuple = () # (("spin", "@keyframes spin{...}"), ...) + + def __repr__(self): + return f"" + + def __str__(self): + return self.class_name + + # --------------------------------------------------------------------------- # Type alias # --------------------------------------------------------------------------- # An s-expression value after evaluation -SExp = int | float | str | bool | Symbol | Keyword | Lambda | Macro | Component | HandlerDef | RelationDef | PageDef | QueryDef | ActionDef | list | dict | _Nil | None +SExp = int | float | str | bool | Symbol | Keyword | Lambda | Macro | Component | HandlerDef | RelationDef | PageDef | QueryDef | ActionDef | StyleValue | list | dict | _Nil | None From 0f82294dc118f10375f56399c12e84906d699856 Mon Sep 17 00:00:00 2001 From: giles Date: Wed, 4 Mar 2026 12:50:07 +0000 Subject: [PATCH 058/105] Update /docs/css page with CSSX native style primitives docs Document css primitive, style atoms, variants, defstyle, defkeyframes, and on-demand delivery protocol. Co-Authored-By: Claude Opus 4.6 --- sx/sxc/pages/__init__.py | 129 ++++++++++++++++++++++++++++----------- 1 file changed, 94 insertions(+), 35 deletions(-) diff --git a/sx/sxc/pages/__init__.py b/sx/sxc/pages/__init__.py index 1380c13..7a19bc7 100644 --- a/sx/sxc/pages/__init__.py +++ b/sx/sxc/pages/__init__.py @@ -413,45 +413,104 @@ async def _docs_primitives_sx() -> str: def _docs_css_sx() -> str: - c1 = _code('# First page load:\n' - 'GET / HTTP/1.1\n\n' - 'HTTP/1.1 200 OK\n' - 'Content-Type: text/html\n' - '# Full CSS in ') - # Pretty-print the sx source for readable display - try: - from shared.sx.parser import parse as _parse, serialize as _serialize - parts.append(_serialize(_parse(sx_src), pretty=True)) - except Exception: - parts.append(sx_src) - return "\n\n".join(parts) - - -# --------------------------------------------------------------------------- -# Navigation helpers -# --------------------------------------------------------------------------- - -async def _nav_items_sx(items: list[tuple[str, str]], current: str | None = None) -> str: - """Build nav link items as sx.""" - parts = [] - for label, href in items: - parts.append(await render_to_sx("nav-link", - href=href, label=label, - is_selected="true" if current == label else None, - select_colours="aria-selected:bg-violet-200 aria-selected:text-violet-900", - )) - return "(<> " + " ".join(parts) + ")" - - -async def _sx_header_sx(nav: str | None = None, *, child: str | None = None) -> str: - """Build the sx docs menu-row.""" - return await render_to_sx("menu-row-sx", - id="sx-row", level=1, colour="violet", - link_href="/", link_label="sx", - link_label_content=SxExpr('(span :class "font-mono" "()")'), - nav=SxExpr(nav) if nav else None, - child_id="sx-header-child", - child=SxExpr(child) if child else None, - ) - - -async def _docs_nav_sx(current: str | None = None) -> str: - from content.pages import DOCS_NAV - return await _nav_items_sx(DOCS_NAV, current) - - -async def _reference_nav_sx(current: str | None = None) -> str: - from content.pages import REFERENCE_NAV - return await _nav_items_sx(REFERENCE_NAV, current) - - -async def _protocols_nav_sx(current: str | None = None) -> str: - from content.pages import PROTOCOLS_NAV - return await _nav_items_sx(PROTOCOLS_NAV, current) - - -async def _examples_nav_sx(current: str | None = None) -> str: - from content.pages import EXAMPLES_NAV - return await _nav_items_sx(EXAMPLES_NAV, current) - - -async def _essays_nav_sx(current: str | None = None) -> str: - from content.pages import ESSAYS_NAV - return await _nav_items_sx(ESSAYS_NAV, current) - - -async def _main_nav_sx(current_section: str | None = None) -> str: - from content.pages import MAIN_NAV - return await _nav_items_sx(MAIN_NAV, current_section) - - -async def _sub_row_sx(sub_label: str, sub_href: str, sub_nav: str, - selected: str = "") -> str: - """Build the level-2 sub-section menu-row.""" - return await render_to_sx("menu-row-sx", - id="sx-sub-row", level=2, colour="violet", - link_href=sub_href, link_label=sub_label, - selected=selected or None, - nav=SxExpr(sub_nav), - ) - - - -# --------------------------------------------------------------------------- -# Content builders — return sx source strings -# --------------------------------------------------------------------------- - -async def _doc_nav_sx(items: list[tuple[str, str]], current: str) -> str: - """Build the in-page doc navigation pills.""" - items_sx = " ".join( - f'(list "{label}" "{href}")' - for label, href in items - ) - return await render_to_sx("doc-nav", items=SxExpr(f"(list {items_sx})"), current=current) - - -async def _attr_table_sx(title: str, attrs: list[tuple[str, str, bool]]) -> str: - """Build an attribute reference table.""" - from content.pages import ATTR_DETAILS - rows = [] - for attr, desc, exists in attrs: - href = f"/reference/attributes/{attr}" if exists and attr in ATTR_DETAILS else None - rows.append(await render_to_sx("doc-attr-row", attr=attr, description=desc, - exists="true" if exists else None, - href=href)) - return ( - f'(div :class "space-y-3"' - f' (h3 :class "text-xl font-semibold text-stone-700" "{title}")' - f' (div :class "overflow-x-auto rounded border border-stone-200"' - f' (table :class "w-full text-left text-sm"' - f' (thead (tr :class "border-b border-stone-200 bg-stone-50"' - f' (th :class "px-3 py-2 font-medium text-stone-600" "Attribute")' - f' (th :class "px-3 py-2 font-medium text-stone-600" "Description")' - f' (th :class "px-3 py-2 font-medium text-stone-600 text-center w-20" "In sx?")))' - f' (tbody {" ".join(rows)}))))' - ) - - -async def _primitives_section_sx() -> str: - """Build the primitives section.""" - from content.pages import PRIMITIVES - parts = [] - for category, prims in PRIMITIVES.items(): - prims_sx = " ".join(f'"{p}"' for p in prims) - parts.append(await render_to_sx("doc-primitives-table", - category=category, - primitives=SxExpr(f"(list {prims_sx})"))) - return " ".join(parts) - - -def _headers_table_sx(title: str, headers: list[tuple[str, str, str]]) -> str: - """Build a headers reference table.""" - rows = [] - for name, value, desc in headers: - rows.append( - f'(tr :class "border-b border-stone-100"' - f' (td :class "px-3 py-2 font-mono text-sm text-violet-700 whitespace-nowrap" "{name}")' - f' (td :class "px-3 py-2 font-mono text-sm text-stone-500" "{value}")' - f' (td :class "px-3 py-2 text-stone-700 text-sm" "{desc}"))' - ) - return ( - f'(div :class "space-y-3"' - f' (h3 :class "text-xl font-semibold text-stone-700" "{title}")' - f' (div :class "overflow-x-auto rounded border border-stone-200"' - f' (table :class "w-full text-left text-sm"' - f' (thead (tr :class "border-b border-stone-200 bg-stone-50"' - f' (th :class "px-3 py-2 font-medium text-stone-600" "Header")' - f' (th :class "px-3 py-2 font-medium text-stone-600" "Value")' - f' (th :class "px-3 py-2 font-medium text-stone-600" "Description")))' - f' (tbody {" ".join(rows)}))))' - ) - - - -async def _docs_content_sx(slug: str) -> str: - """Route to the right docs content builder.""" - import inspect - builders = { - "introduction": _docs_introduction_sx, - "getting-started": _docs_getting_started_sx, - "components": _docs_components_sx, - "evaluator": _docs_evaluator_sx, - "primitives": _docs_primitives_sx, - "css": _docs_css_sx, - "server-rendering": _docs_server_rendering_sx, - } - builder = builders.get(slug, _docs_introduction_sx) - result = builder() - return await result if inspect.isawaitable(result) else result - - -def _docs_introduction_sx() -> str: - return ( - '(~doc-page :title "Introduction"' - ' (~doc-section :title "What is sx?" :id "what"' - ' (p :class "text-stone-600"' - ' "sx is an s-expression language for building web UIs. ' - 'It combines htmx\'s server-first hypermedia approach with React\'s component model. ' - 'The server sends s-expression source code over the wire. The client parses, evaluates, and renders it to DOM.")' - ' (p :class "text-stone-600"' - ' "The same evaluator runs on both server (Python) and client (JavaScript). ' - 'Components defined once render identically in both environments."))' - ' (~doc-section :title "Design decisions" :id "design"' - ' (p :class "text-stone-600"' - ' "HTML elements are first-class: (div :class \\"card\\" (p \\"hello\\")) renders exactly what you\'d expect. ' - 'Components use defcomp with keyword parameters and optional children. ' - 'The evaluator supports let bindings, conditionals, lambda, map/filter/reduce, and ~80 primitives.")' - ' (p :class "text-stone-600"' - ' "sx replaces the pattern of ' - 'shipping a JS framework + build step + client-side router + state management library ' - 'just to render some server data. For most applications, sx eliminates the need for ' - 'JavaScript entirely — htmx attributes handle interactivity, hyperscript handles small behaviours, ' - 'and the server handles everything else."))' - ' (~doc-section :title "What sx is not" :id "not"' - ' (ul :class "space-y-2 text-stone-600"' - ' (li "Not a general-purpose programming language — it\'s a UI rendering language")' - ' (li "Not a full Lisp — it has macros and TCO, but no continuations or call/cc")' - ' (li "Not production-hardened at scale — it runs one website"))))' - ) - - -def _docs_getting_started_sx() -> str: - c1 = _code('(div :class "p-4 bg-white rounded"\n (h1 :class "text-2xl font-bold" "Hello, world!")\n (p "This is rendered from an s-expression."))') - c2 = _code('(button\n :sx-get "/api/data"\n :sx-target "#result"\n :sx-swap "innerHTML"\n "Load data")') - return ( - f'(~doc-page :title "Getting Started"' - f' (~doc-section :title "Minimal example" :id "minimal"' - f' (p :class "text-stone-600"' - f' "An sx response is s-expression source code with content type text/sx:")' - f' {c1}' - f' (p :class "text-stone-600"' - f' "Add sx-get to any element to make it fetch and render sx:"))' - f' (~doc-section :title "Hypermedia attributes" :id "attrs"' - f' (p :class "text-stone-600"' - f' "Like htmx, sx adds attributes to HTML elements to trigger HTTP requests:")' - f' {c2}' - f' (p :class "text-stone-600"' - f' "sx-get, sx-post, sx-put, sx-delete, sx-patch — all work the same way. ' - f'The response is parsed as sx and rendered into the target element.")))' - ) - - -def _docs_components_sx() -> str: - c1 = _code('(defcomp ~card (&key title subtitle &rest children)\n' - ' (div :class "border rounded p-4"\n' - ' (h2 :class "font-bold" title)\n' - ' (when subtitle (p :class "text-stone-500" subtitle))\n' - ' (div :class "mt-3" children)))') - c2 = _code('(~card :title "My Card" :subtitle "A description"\n' - ' (p "First child")\n' - ' (p "Second child"))') - return ( - f'(~doc-page :title "Components"' - f' (~doc-section :title "defcomp" :id "defcomp"' - f' (p :class "text-stone-600"' - f' "Components are defined with defcomp. They take keyword parameters and optional children:")' - f' {c1}' - f' (p :class "text-stone-600"' - f' "Use components with the ~ prefix:")' - f' {c2})' - f' (~doc-section :title "Component caching" :id "caching"' - f' (p :class "text-stone-600"' - f' "Component definitions are sent in a ') + parts.append('') + # Pretty-print the sx source for readable display + try: + from shared.sx.parser import parse as _parse, serialize as _serialize + parts.append(_serialize(_parse(sx_src), pretty=True)) + except Exception: + parts.append(sx_src) + return "\n\n".join(parts) diff --git a/sx/sxc/pages/utils.py b/sx/sxc/pages/utils.py new file mode 100644 index 0000000..7853078 --- /dev/null +++ b/sx/sxc/pages/utils.py @@ -0,0 +1,137 @@ +"""Shared utility functions for sx docs pages.""" +from __future__ import annotations + +from shared.sx.helpers import ( + render_to_sx, SxExpr, +) + + +async def _nav_items_sx(items: list[tuple[str, str]], current: str | None = None) -> str: + """Build nav link items as sx.""" + parts = [] + for label, href in items: + parts.append(await render_to_sx("nav-link", + href=href, label=label, + is_selected="true" if current == label else None, + select_colours="aria-selected:bg-violet-200 aria-selected:text-violet-900", + )) + return "(<> " + " ".join(parts) + ")" + + +async def _doc_nav_sx(items: list[tuple[str, str]], current: str) -> str: + """Build the in-page doc navigation pills.""" + items_sx = " ".join( + f'(list "{label}" "{href}")' + for label, href in items + ) + return await render_to_sx("doc-nav", items=SxExpr(f"(list {items_sx})"), current=current) + + +async def _attr_table_sx(title: str, attrs: list[tuple[str, str, bool]]) -> str: + """Build an attribute reference table.""" + from content.pages import ATTR_DETAILS + rows = [] + for attr, desc, exists in attrs: + href = f"/reference/attributes/{attr}" if exists and attr in ATTR_DETAILS else None + rows.append(await render_to_sx("doc-attr-row", attr=attr, description=desc, + exists="true" if exists else None, + href=href)) + return ( + f'(div :class "space-y-3"' + f' (h3 :class "text-xl font-semibold text-stone-700" "{title}")' + f' (div :class "overflow-x-auto rounded border border-stone-200"' + f' (table :class "w-full text-left text-sm"' + f' (thead (tr :class "border-b border-stone-200 bg-stone-50"' + f' (th :class "px-3 py-2 font-medium text-stone-600" "Attribute")' + f' (th :class "px-3 py-2 font-medium text-stone-600" "Description")' + f' (th :class "px-3 py-2 font-medium text-stone-600 text-center w-20" "In sx?")))' + f' (tbody {" ".join(rows)}))))' + ) + + +def _headers_table_sx(title: str, headers: list[tuple[str, str, str]]) -> str: + """Build a headers reference table.""" + rows = [] + for name, value, desc in headers: + rows.append( + f'(tr :class "border-b border-stone-100"' + f' (td :class "px-3 py-2 font-mono text-sm text-violet-700 whitespace-nowrap" "{name}")' + f' (td :class "px-3 py-2 font-mono text-sm text-stone-500" "{value}")' + f' (td :class "px-3 py-2 text-stone-700 text-sm" "{desc}"))' + ) + return ( + f'(div :class "space-y-3"' + f' (h3 :class "text-xl font-semibold text-stone-700" "{title}")' + f' (div :class "overflow-x-auto rounded border border-stone-200"' + f' (table :class "w-full text-left text-sm"' + f' (thead (tr :class "border-b border-stone-200 bg-stone-50"' + f' (th :class "px-3 py-2 font-medium text-stone-600" "Header")' + f' (th :class "px-3 py-2 font-medium text-stone-600" "Value")' + f' (th :class "px-3 py-2 font-medium text-stone-600" "Description")))' + f' (tbody {" ".join(rows)}))))' + ) + + +async def _primitives_section_sx() -> str: + """Build the primitives section.""" + from content.pages import PRIMITIVES + parts = [] + for category, prims in PRIMITIVES.items(): + prims_sx = " ".join(f'"{p}"' for p in prims) + parts.append(await render_to_sx("doc-primitives-table", + category=category, + primitives=SxExpr(f"(list {prims_sx})"))) + return " ".join(parts) + + +async def _sx_header_sx(nav: str | None = None, *, child: str | None = None) -> str: + """Build the sx docs menu-row.""" + return await render_to_sx("menu-row-sx", + id="sx-row", level=1, colour="violet", + link_href="/", link_label="sx", + link_label_content=SxExpr('(span :class "font-mono" "()")'), + nav=SxExpr(nav) if nav else None, + child_id="sx-header-child", + child=SxExpr(child) if child else None, + ) + + +async def _docs_nav_sx(current: str | None = None) -> str: + from content.pages import DOCS_NAV + return await _nav_items_sx(DOCS_NAV, current) + + +async def _reference_nav_sx(current: str | None = None) -> str: + from content.pages import REFERENCE_NAV + return await _nav_items_sx(REFERENCE_NAV, current) + + +async def _protocols_nav_sx(current: str | None = None) -> str: + from content.pages import PROTOCOLS_NAV + return await _nav_items_sx(PROTOCOLS_NAV, current) + + +async def _examples_nav_sx(current: str | None = None) -> str: + from content.pages import EXAMPLES_NAV + return await _nav_items_sx(EXAMPLES_NAV, current) + + +async def _essays_nav_sx(current: str | None = None) -> str: + from content.pages import ESSAYS_NAV + return await _nav_items_sx(ESSAYS_NAV, current) + + +async def _main_nav_sx(current_section: str | None = None) -> str: + from content.pages import MAIN_NAV + return await _nav_items_sx(MAIN_NAV, current_section) + + +async def _sub_row_sx(sub_label: str, sub_href: str, sub_nav: str, + selected: str = "") -> str: + """Build the level-2 sub-section menu-row.""" + return await render_to_sx("menu-row-sx", + id="sx-sub-row", level=2, colour="violet", + link_href=sub_href, link_label=sub_label, + selected=selected or None, + nav=SxExpr(sub_nav), + ) From 8772d59d84092f1244988b35c8151aab26b7500c Mon Sep 17 00:00:00 2001 From: giles Date: Wed, 4 Mar 2026 17:12:17 +0000 Subject: [PATCH 082/105] Fix _aser_call list serialization causing EvalError on re-parse Plain Python lists (e.g. from map) were serialized as ((item1) (item2)) which re-parses as a function application, causing "Not callable: _RawHTML" when the head gets fully evaluated. Keyword list values now wrap as (<> item1 item2) fragments; positional list children are flattened. Co-Authored-By: Claude Opus 4.6 --- shared/sx/async_eval.py | 21 +++++++++++++++++++-- 1 file changed, 19 insertions(+), 2 deletions(-) diff --git a/shared/sx/async_eval.py b/shared/sx/async_eval.py index cba8e65..86856f6 100644 --- a/shared/sx/async_eval.py +++ b/shared/sx/async_eval.py @@ -1249,12 +1249,29 @@ async def _aser_call( extra_class = val.class_name else: parts.append(f":{arg.name}") - parts.append(serialize(val)) + # Plain list (e.g. from map) → wrap as fragment to + # avoid ambiguity with function application on re-parse + if isinstance(val, list): + items = [serialize(v) for v in val + if v is not NIL and v is not None] + parts.append( + "(<> " + " ".join(items) + ")" if items + else "nil" + ) + else: + parts.append(serialize(val)) i += 2 else: result = await _aser(arg, env, ctx) if result is not NIL and result is not None: - parts.append(serialize(result)) + # Flatten list results (e.g. from map) into individual + # children, matching _aser_fragment behaviour + if isinstance(result, list): + for item in result: + if item is not NIL and item is not None: + parts.append(serialize(item)) + else: + parts.append(serialize(result)) i += 1 # If we converted a :style to a class, merge into existing :class or add it if extra_class: From ad6a8ecb170e6d6b4cafe829b8c25ccc2735e47d Mon Sep 17 00:00:00 2001 From: giles Date: Wed, 4 Mar 2026 17:13:45 +0000 Subject: [PATCH 083/105] Refine events + sx sub-module imports from background agents Events: route imports now point to specific sub-modules (entries, tickets, slots) instead of all going through renders.py. Merged layouts into helpers.py. __init__.py now 20 lines. SX Docs: moved dispatchers from helpers.py into essays.py, cleaned up __init__.py to 24 lines. Co-Authored-By: Claude Opus 4.6 --- events/bp/all_events/routes.py | 2 +- events/bp/calendar/routes.py | 2 +- events/bp/calendar_entries/routes.py | 4 +- events/bp/calendar_entry/routes.py | 20 +- events/bp/calendars/routes.py | 2 +- events/bp/page/routes.py | 2 +- events/bp/slot/routes.py | 8 +- events/bp/slots/routes.py | 6 +- events/bp/ticket_admin/routes.py | 6 +- events/bp/ticket_type/routes.py | 8 +- events/bp/ticket_types/routes.py | 6 +- events/bp/tickets/routes.py | 4 +- events/sxc/pages/__init__.py | 7 +- events/sxc/pages/entries.py | 2 +- events/sxc/pages/helpers.py | 315 +++++++++++++++++++++++++-- sx/sxc/pages/__init__.py | 9 +- sx/sxc/pages/essays.py | 100 ++++++++- sx/sxc/pages/helpers.py | 111 +--------- 18 files changed, 446 insertions(+), 168 deletions(-) diff --git a/events/bp/all_events/routes.py b/events/bp/all_events/routes.py index 6ca913c..1fbc60b 100644 --- a/events/bp/all_events/routes.py +++ b/events/bp/all_events/routes.py @@ -125,7 +125,7 @@ def register() -> Blueprint: if ident["session_id"] is not None: frag_params["session_id"] = ident["session_id"] - from sxc.pages.renders import render_ticket_widget + from sxc.pages.tickets import render_ticket_widget widget_html = await render_ticket_widget(entry, qty, "/all-tickets/adjust") mini_html = await fetch_fragment("cart", "cart-mini", params=frag_params, required=False) return sx_response(widget_html + (mini_html or "")) diff --git a/events/bp/calendar/routes.py b/events/bp/calendar/routes.py index 51e2900..2d3e661 100644 --- a/events/bp/calendar/routes.py +++ b/events/bp/calendar/routes.py @@ -224,7 +224,7 @@ def register(): if post_data: from shared.services.entry_associations import get_associated_entries - from sxc.pages.renders import render_post_nav_entries_oob + from sxc.pages.entries import render_post_nav_entries_oob post_id = (post_data.get("post") or {}).get("id") cals = ( diff --git a/events/bp/calendar_entries/routes.py b/events/bp/calendar_entries/routes.py index ddc3008..b211bef 100644 --- a/events/bp/calendar_entries/routes.py +++ b/events/bp/calendar_entries/routes.py @@ -279,12 +279,12 @@ def register(): result = await g.s.execute(stmt) day_slots = list(result.scalars()) - from sxc.pages.renders import render_entry_add_form + from sxc.pages.entries import render_entry_add_form return sx_response(await render_entry_add_form(g.calendar, day, month, year, day_slots)) @bp.get("/add-button/") async def add_button(day: int, month: int, year: int, **kwargs): - from sxc.pages.renders import render_entry_add_button + from sxc.pages.entries import render_entry_add_button return sx_response(await render_entry_add_button(g.calendar, day, month, year)) diff --git a/events/bp/calendar_entry/routes.py b/events/bp/calendar_entry/routes.py index fdbc53a..7f81c77 100644 --- a/events/bp/calendar_entry/routes.py +++ b/events/bp/calendar_entry/routes.py @@ -111,7 +111,7 @@ def register(): ) # Render OOB nav - from sxc.pages.renders import render_day_entries_nav_oob + from sxc.pages.entries import render_day_entries_nav_oob return await render_day_entries_nav_oob(visible.confirmed_entries, calendar, day_date) async def get_post_nav_oob(entry_id: int): @@ -148,7 +148,7 @@ def register(): ).scalars().all() # Render OOB nav for this post - from sxc.pages.renders import render_post_nav_entries_oob + from sxc.pages.entries import render_post_nav_entries_oob nav_oob = await render_post_nav_entries_oob(associated_entries, calendars, post) nav_oobs.append(nav_oob) @@ -256,7 +256,7 @@ def register(): result = await g.s.execute(stmt) day_slots = list(result.scalars()) - from sxc.pages.renders import render_entry_edit_form + from sxc.pages.entries import render_entry_edit_form return sx_response(await render_entry_edit_form(g.entry, g.calendar, day, month, year, day_slots)) @bp.put("/") @@ -448,7 +448,7 @@ def register(): # Re-read entry to get updated state await g.s.refresh(g.entry) - from sxc.pages.renders import render_entry_optioned + from sxc.pages.entries import render_entry_optioned html = await render_entry_optioned(g.entry, g.calendar, day, month, year) return sx_response(html + day_nav_oob + post_nav_oob) @@ -473,7 +473,7 @@ def register(): # Re-read entry to get updated state await g.s.refresh(g.entry) - from sxc.pages.renders import render_entry_optioned + from sxc.pages.entries import render_entry_optioned html = await render_entry_optioned(g.entry, g.calendar, day, month, year) return sx_response(html + day_nav_oob + post_nav_oob) @@ -498,7 +498,7 @@ def register(): # Re-read entry to get updated state await g.s.refresh(g.entry) - from sxc.pages.renders import render_entry_optioned + from sxc.pages.entries import render_entry_optioned html = await render_entry_optioned(g.entry, g.calendar, day, month, year) return sx_response(html + day_nav_oob + post_nav_oob) @@ -542,7 +542,7 @@ def register(): # Return just the tickets fragment (targeted by hx-target="#entry-tickets-...") await g.s.refresh(g.entry) - from sxc.pages.renders import render_entry_tickets_config + from sxc.pages.entries import render_entry_tickets_config html = await render_entry_tickets_config(g.entry, g.calendar, request.view_args.get("day"), request.view_args.get("month"), request.view_args.get("year")) return sx_response(html) @@ -558,7 +558,7 @@ def register(): total_pages = math.ceil(total / per_page) if total > 0 else 0 va = request.view_args or {} - from sxc.pages.renders import render_post_search_results + from sxc.pages.entries import render_post_search_results return sx_response(await render_post_search_results( search_posts, query, page, total_pages, g.entry, g.calendar, @@ -592,7 +592,7 @@ def register(): entry_posts = await get_entry_posts(g.s, entry_id) # Return updated posts list + OOB nav update - from sxc.pages.renders import render_entry_posts_panel, render_entry_posts_nav_oob + from sxc.pages.entries import render_entry_posts_panel, render_entry_posts_nav_oob va = request.view_args or {} html = await render_entry_posts_panel(entry_posts, g.entry, g.calendar, va.get("day"), va.get("month"), va.get("year")) nav_oob = await render_entry_posts_nav_oob(entry_posts) @@ -614,7 +614,7 @@ def register(): entry_posts = await get_entry_posts(g.s, entry_id) # Return updated posts list + OOB nav update - from sxc.pages.renders import render_entry_posts_panel, render_entry_posts_nav_oob + from sxc.pages.entries import render_entry_posts_panel, render_entry_posts_nav_oob va = request.view_args or {} html = await render_entry_posts_panel(entry_posts, g.entry, g.calendar, va.get("day"), va.get("month"), va.get("year")) nav_oob = await render_entry_posts_nav_oob(entry_posts) diff --git a/events/bp/calendars/routes.py b/events/bp/calendars/routes.py index b3cd72e..0bc3661 100644 --- a/events/bp/calendars/routes.py +++ b/events/bp/calendars/routes.py @@ -74,7 +74,7 @@ def register(): # Blog-embedded mode: also update post nav if post_data: from shared.services.entry_associations import get_associated_entries - from sxc.pages.renders import render_post_nav_entries_oob + from sxc.pages.entries import render_post_nav_entries_oob cals = ( await g.s.execute( diff --git a/events/bp/page/routes.py b/events/bp/page/routes.py index 2f4ac06..0b88755 100644 --- a/events/bp/page/routes.py +++ b/events/bp/page/routes.py @@ -106,7 +106,7 @@ def register() -> Blueprint: if ident["session_id"] is not None: frag_params["session_id"] = ident["session_id"] - from sxc.pages.renders import render_ticket_widget + from sxc.pages.tickets import render_ticket_widget widget_html = await render_ticket_widget(entry, qty, f"/{g.post_slug}/tickets/adjust") mini_html = await fetch_fragment("cart", "cart-mini", params=frag_params, required=False) return sx_response(widget_html + (mini_html or "")) diff --git a/events/bp/slot/routes.py b/events/bp/slot/routes.py index dea7507..5b157b1 100644 --- a/events/bp/slot/routes.py +++ b/events/bp/slot/routes.py @@ -35,7 +35,7 @@ def register(): slot = await svc_get_slot(g.s, slot_id) if not slot: return await make_response("Not found", 404) - from sxc.pages.renders import render_slot_edit_form + from sxc.pages.slots import render_slot_edit_form return sx_response(await render_slot_edit_form(slot, g.calendar)) @bp.get("/view/") @@ -44,7 +44,7 @@ def register(): slot = await svc_get_slot(g.s, slot_id) if not slot: return await make_response("Not found", 404) - from sxc.pages.renders import render_slot_main_panel + from sxc.pages.slots import render_slot_main_panel return sx_response(await render_slot_main_panel(slot, g.calendar)) @bp.delete("/") @@ -53,7 +53,7 @@ def register(): async def slot_delete(slot_id: int, **kwargs): await svc_delete_slot(g.s, slot_id) slots = await svc_list_slots(g.s, g.calendar.id) - from sxc.pages.renders import render_slots_table + from sxc.pages.slots import render_slots_table return sx_response(await render_slots_table(slots, g.calendar)) @bp.put("/") @@ -135,7 +135,7 @@ def register(): } ), 422 - from sxc.pages.renders import render_slot_main_panel + from sxc.pages.slots import render_slot_main_panel return sx_response(await render_slot_main_panel(slot, g.calendar, oob=True)) diff --git a/events/bp/slots/routes.py b/events/bp/slots/routes.py index 41cb73b..61f1ae7 100644 --- a/events/bp/slots/routes.py +++ b/events/bp/slots/routes.py @@ -110,20 +110,20 @@ def register(): # Success → re-render the slots table slots = await svc_list_slots(g.s, g.calendar.id) - from sxc.pages.renders import render_slots_table + from sxc.pages.slots import render_slots_table return sx_response(await render_slots_table(slots, g.calendar)) @bp.get("/add") @require_admin async def add_form(**kwargs): - from sxc.pages.renders import render_slot_add_form + from sxc.pages.slots import render_slot_add_form return sx_response(await render_slot_add_form(g.calendar)) @bp.get("/add-button") @require_admin async def add_button(**kwargs): - from sxc.pages.renders import render_slot_add_button + from sxc.pages.slots import render_slot_add_button return sx_response(await render_slot_add_button(g.calendar)) return bp diff --git a/events/bp/ticket_admin/routes.py b/events/bp/ticket_admin/routes.py index d519b70..88d14b8 100644 --- a/events/bp/ticket_admin/routes.py +++ b/events/bp/ticket_admin/routes.py @@ -53,7 +53,7 @@ def register() -> Blueprint: tickets = await get_tickets_for_entry(g.s, entry_id) - from sxc.pages.renders import render_entry_tickets_admin + from sxc.pages.tickets import render_entry_tickets_admin html = await render_entry_tickets_admin(entry, tickets) return sx_response(html) @@ -69,7 +69,7 @@ def register() -> Blueprint: ) ticket = await get_ticket_by_code(g.s, code) - from sxc.pages.renders import render_lookup_result + from sxc.pages.tickets import render_lookup_result if not ticket: return sx_response(await render_lookup_result(None, "Ticket not found")) @@ -82,7 +82,7 @@ def register() -> Blueprint: """Check in a ticket by its code.""" success, error = await checkin_ticket(g.s, code) - from sxc.pages.renders import render_checkin_result + from sxc.pages.tickets import render_checkin_result if not success: return sx_response(await render_checkin_result(False, error, None)) diff --git a/events/bp/ticket_type/routes.py b/events/bp/ticket_type/routes.py index 64dbfd6..64f2624 100644 --- a/events/bp/ticket_type/routes.py +++ b/events/bp/ticket_type/routes.py @@ -30,7 +30,7 @@ def register(): if not ticket_type: return await make_response("Not found", 404) - from sxc.pages.renders import render_ticket_type_edit_form + from sxc.pages.tickets import render_ticket_type_edit_form va = request.view_args or {} return sx_response(await render_ticket_type_edit_form( ticket_type, g.entry, g.calendar, @@ -45,7 +45,7 @@ def register(): if not ticket_type: return await make_response("Not found", 404) - from sxc.pages.renders import render_ticket_type_main_panel + from sxc.pages.tickets import render_ticket_type_main_panel va = request.view_args or {} return sx_response(await render_ticket_type_main_panel( ticket_type, g.entry, g.calendar, @@ -112,7 +112,7 @@ def register(): return await make_response("Not found", 404) # Return updated view with OOB flag - from sxc.pages.renders import render_ticket_type_main_panel + from sxc.pages.tickets import render_ticket_type_main_panel va = request.view_args or {} return sx_response(await render_ticket_type_main_panel( ticket_type, g.entry, g.calendar, @@ -131,7 +131,7 @@ def register(): # Re-render the ticket types list ticket_types = await svc_list_ticket_types(g.s, g.entry.id) - from sxc.pages.renders import render_ticket_types_table + from sxc.pages.tickets import render_ticket_types_table va = request.view_args or {} return sx_response(await render_ticket_types_table( ticket_types, g.entry, g.calendar, diff --git a/events/bp/ticket_types/routes.py b/events/bp/ticket_types/routes.py index e85e82d..05f55b4 100644 --- a/events/bp/ticket_types/routes.py +++ b/events/bp/ticket_types/routes.py @@ -93,7 +93,7 @@ def register(): # Success → re-render the ticket types table ticket_types = await svc_list_ticket_types(g.s, g.entry.id) - from sxc.pages.renders import render_ticket_types_table + from sxc.pages.tickets import render_ticket_types_table va = request.view_args or {} return sx_response(await render_ticket_types_table( ticket_types, g.entry, g.calendar, @@ -104,7 +104,7 @@ def register(): @require_admin async def add_form(**kwargs): """Show the add ticket type form.""" - from sxc.pages.renders import render_ticket_type_add_form + from sxc.pages.tickets import render_ticket_type_add_form va = request.view_args or {} return sx_response(await render_ticket_type_add_form( g.entry, g.calendar, @@ -115,7 +115,7 @@ def register(): @require_admin async def add_button(**kwargs): """Show the add ticket type button.""" - from sxc.pages.renders import render_ticket_type_add_button + from sxc.pages.tickets import render_ticket_type_add_button va = request.view_args or {} return sx_response(await render_ticket_type_add_button( g.entry, g.calendar, diff --git a/events/bp/tickets/routes.py b/events/bp/tickets/routes.py index d884ca5..f480e32 100644 --- a/events/bp/tickets/routes.py +++ b/events/bp/tickets/routes.py @@ -126,7 +126,7 @@ def register() -> Blueprint: summary = dto_from_dict(CartSummaryDTO, raw_summary) if raw_summary else CartSummaryDTO() cart_count = summary.count + summary.calendar_count + summary.ticket_count - from sxc.pages.renders import render_buy_result + from sxc.pages.tickets import render_buy_result return sx_response(await render_buy_result(entry, created, remaining, cart_count)) @bp.post("/adjust/") @@ -249,7 +249,7 @@ def register() -> Blueprint: summary = dto_from_dict(CartSummaryDTO, raw_summary) if raw_summary else CartSummaryDTO() cart_count = summary.count + summary.calendar_count + summary.ticket_count - from sxc.pages.renders import render_adjust_response + from sxc.pages.tickets import render_adjust_response return sx_response(await render_adjust_response( entry, ticket_remaining, ticket_sold_count, user_ticket_count, user_ticket_counts_by_type, cart_count, diff --git a/events/sxc/pages/__init__.py b/events/sxc/pages/__init__.py index 7052bc5..2737f52 100644 --- a/events/sxc/pages/__init__.py +++ b/events/sxc/pages/__init__.py @@ -4,8 +4,7 @@ from __future__ import annotations def setup_events_pages() -> None: """Register events-specific layouts, page helpers, and load page definitions.""" - from .layouts import _register_events_layouts - from .helpers import _register_events_helpers + from .helpers import _register_events_layouts, _register_events_helpers _register_events_layouts() _register_events_helpers() _load_events_page_files() @@ -15,7 +14,7 @@ def _load_events_page_files() -> None: import os from shared.sx.pages import load_page_dir from shared.sx.jinja_bridge import load_service_components - sxc_dir = os.path.dirname(os.path.dirname(__file__)) - service_root = os.path.dirname(sxc_dir) + sxc_dir = os.path.dirname(os.path.dirname(__file__)) # events/sxc/ + service_root = os.path.dirname(sxc_dir) # events/ load_service_components(service_root, service_name="events") load_page_dir(os.path.dirname(__file__), "events") diff --git a/events/sxc/pages/entries.py b/events/sxc/pages/entries.py index 28fcb2c..4516acb 100644 --- a/events/sxc/pages/entries.py +++ b/events/sxc/pages/entries.py @@ -210,7 +210,7 @@ async def _entry_main_panel_html(ctx: dict) -> str: """Render the entry detail panel (name, slot, time, state, cost, tickets, buy form, date, posts, options + edit button).""" from quart import url_for - from .tickets import render_buy_form, render_entry_tickets_config + from .tickets import render_buy_form entry = ctx.get("entry") if not entry: diff --git a/events/sxc/pages/helpers.py b/events/sxc/pages/helpers.py index e5333c8..91be5d1 100644 --- a/events/sxc/pages/helpers.py +++ b/events/sxc/pages/helpers.py @@ -1,8 +1,35 @@ -"""Page helpers — _h_* helper functions + _register_events_helpers + _ensure_* context hydration.""" +"""Layout registrations, page helpers, and shared hydration helpers.""" from __future__ import annotations from typing import Any +from shared.sx.helpers import render_to_sx + +from .utils import _clear_deeper_oob, _ensure_container_nav +from .calendar import ( + _post_header_sx, _calendar_header_sx, + _calendar_admin_header_sx, _day_header_sx, + _day_admin_header_sx, _markets_header_sx, + _calendars_main_panel_sx, + _calendar_admin_main_panel_html, + _day_admin_main_panel_html, + _markets_main_panel_html, +) +from .entries import ( + _entry_header_html, _entry_main_panel_html, + _entry_nav_html, + _entry_admin_header_html, _entry_admin_main_panel_html, +) +from .tickets import ( + _tickets_main_panel_html, _ticket_detail_panel_html, + _ticket_admin_main_panel_html, + _ticket_types_header_html, _ticket_type_header_html, + render_ticket_type_main_panel, render_ticket_types_table, +) +from .slots import ( + _slot_header_html, render_slot_main_panel, render_slots_table, +) + # --------------------------------------------------------------------------- # Shared hydration helpers @@ -181,6 +208,279 @@ async def _ensure_day_data(year: int, month: int, day: int) -> None: ) +# --------------------------------------------------------------------------- +# Layouts +# --------------------------------------------------------------------------- + +def _register_events_layouts() -> None: + from shared.sx.layouts import register_custom_layout + register_custom_layout("events-calendar-admin", _cal_admin_full, _cal_admin_oob) + register_custom_layout("events-slots", _slots_full, _slots_oob) + register_custom_layout("events-slot", _slot_full, _slot_oob) + register_custom_layout("events-day-admin", _day_admin_full, _day_admin_oob) + register_custom_layout("events-entry", _entry_full, _entry_oob) + register_custom_layout("events-entry-admin", _entry_admin_full, _entry_admin_oob) + register_custom_layout("events-ticket-types", _ticket_types_full, _ticket_types_oob) + register_custom_layout("events-ticket-type", _ticket_type_full, _ticket_type_oob) + register_custom_layout("events-markets", _markets_full, _markets_oob) + + +# --- Calendar admin layout (root + post + child(post-admin + calendar + cal-admin)) --- + +async def _cal_admin_full(ctx: dict, **kw: Any) -> str: + from shared.sx.helpers import render_to_sx_with_env, _ctx_to_env, post_admin_header_sx + from shared.sx.parser import SxExpr + ctx = await _ensure_container_nav(ctx) + slug = (ctx.get("post") or {}).get("slug", "") + return await render_to_sx_with_env("events-cal-admin-layout-full", _ctx_to_env(ctx), + post_header=SxExpr(await _post_header_sx(ctx)), + admin_header=SxExpr(await post_admin_header_sx(ctx, slug, selected="calendars")), + calendar_header=SxExpr(await _calendar_header_sx(ctx)), + calendar_admin_header=SxExpr(await _calendar_admin_header_sx(ctx)), + ) + + +async def _cal_admin_oob(ctx: dict, **kw: Any) -> str: + from shared.sx.helpers import render_to_sx_with_env, _ctx_to_env, post_admin_header_sx, oob_header_sx + from shared.sx.parser import SxExpr + ctx = await _ensure_container_nav(ctx) + slug = (ctx.get("post") or {}).get("slug", "") + return await render_to_sx_with_env("events-cal-admin-layout-oob", _ctx_to_env(ctx, oob=True), + admin_oob=SxExpr(await post_admin_header_sx(ctx, slug, oob=True, selected="calendars")), + cal_oob=SxExpr(await _calendar_header_sx(ctx, oob=True)), + cal_admin_oob_wrap=SxExpr(await oob_header_sx("calendar-header-child", + "calendar-admin-header-child", await _calendar_admin_header_sx(ctx))), + clear_oob=SxExpr(_clear_deeper_oob("post-row", "post-header-child", + "post-admin-row", "post-admin-header-child", + "calendar-row", "calendar-header-child", + "calendar-admin-row", "calendar-admin-header-child")), + ) + + +# --- Slots layout (same full as cal-admin but different OOB) --- + +async def _slots_full(ctx: dict, **kw: Any) -> str: + return await _cal_admin_full({**ctx, "is_admin_section": True}, **kw) + + +async def _slots_oob(ctx: dict, **kw: Any) -> str: + from shared.sx.helpers import render_to_sx_with_env, _ctx_to_env, post_admin_header_sx + from shared.sx.parser import SxExpr + ctx = await _ensure_container_nav({**ctx, "is_admin_section": True}) + slug = (ctx.get("post") or {}).get("slug", "") + return await render_to_sx_with_env("events-slots-layout-oob", _ctx_to_env(ctx, oob=True), + admin_oob=SxExpr(await post_admin_header_sx(ctx, slug, oob=True, selected="calendars")), + cal_admin_oob=SxExpr(await _calendar_admin_header_sx(ctx, oob=True)), + clear_oob=SxExpr(_clear_deeper_oob("post-row", "post-header-child", + "post-admin-row", "post-admin-header-child", + "calendar-row", "calendar-header-child", + "calendar-admin-row", "calendar-admin-header-child")), + ) + + +# --- Slot detail layout (extends cal-admin with slot header) --- + +async def _slot_full(ctx: dict, **kw: Any) -> str: + from shared.sx.helpers import render_to_sx_with_env, _ctx_to_env, post_admin_header_sx + from shared.sx.parser import SxExpr + ctx = await _ensure_container_nav({**ctx, "is_admin_section": True}) + slug = (ctx.get("post") or {}).get("slug", "") + return await render_to_sx_with_env("events-slot-layout-full", _ctx_to_env(ctx), + post_header=SxExpr(await _post_header_sx(ctx)), + admin_header=SxExpr(await post_admin_header_sx(ctx, slug, selected="calendars")), + calendar_header=SxExpr(await _calendar_header_sx(ctx)), + calendar_admin_header=SxExpr(await _calendar_admin_header_sx(ctx)), + slot_header=SxExpr(await _slot_header_html(ctx)), + ) + + +async def _slot_oob(ctx: dict, **kw: Any) -> str: + from shared.sx.helpers import render_to_sx_with_env, _ctx_to_env, post_admin_header_sx, oob_header_sx + from shared.sx.parser import SxExpr + ctx = await _ensure_container_nav({**ctx, "is_admin_section": True}) + slug = (ctx.get("post") or {}).get("slug", "") + return await render_to_sx_with_env("events-slot-layout-oob", _ctx_to_env(ctx, oob=True), + admin_oob=SxExpr(await post_admin_header_sx(ctx, slug, oob=True, selected="calendars")), + cal_admin_oob=SxExpr(await _calendar_admin_header_sx(ctx, oob=True)), + slot_oob_wrap=SxExpr(await oob_header_sx("calendar-admin-header-child", + "slot-header-child", await _slot_header_html(ctx))), + clear_oob=SxExpr(_clear_deeper_oob("post-row", "post-header-child", + "post-admin-row", "post-admin-header-child", + "calendar-row", "calendar-header-child", + "calendar-admin-row", "calendar-admin-header-child", + "slot-row", "slot-header-child")), + ) + + +# --- Day admin layout (root + post + post-admin + child(cal + day + day-admin)) --- + +async def _day_admin_full(ctx: dict, **kw: Any) -> str: + from shared.sx.helpers import render_to_sx_with_env, _ctx_to_env, post_admin_header_sx + from shared.sx.parser import SxExpr + ctx = await _ensure_container_nav(ctx) + slug = (ctx.get("post") or {}).get("slug", "") + return await render_to_sx_with_env("events-day-admin-layout-full", _ctx_to_env(ctx), + post_header=SxExpr(await _post_header_sx(ctx)), + admin_header=SxExpr(await post_admin_header_sx(ctx, slug, selected="calendars")), + calendar_header=SxExpr(await _calendar_header_sx(ctx)), + day_header=SxExpr(await _day_header_sx(ctx)), + day_admin_header=SxExpr(await _day_admin_header_sx(ctx)), + ) + + +async def _day_admin_oob(ctx: dict, **kw: Any) -> str: + from shared.sx.helpers import render_to_sx_with_env, _ctx_to_env, post_admin_header_sx, oob_header_sx + from shared.sx.parser import SxExpr + ctx = await _ensure_container_nav(ctx) + slug = (ctx.get("post") or {}).get("slug", "") + return await render_to_sx_with_env("events-day-admin-layout-oob", _ctx_to_env(ctx, oob=True), + admin_oob=SxExpr(await post_admin_header_sx(ctx, slug, oob=True, selected="calendars")), + cal_oob=SxExpr(await _calendar_header_sx(ctx, oob=True)), + day_admin_oob_wrap=SxExpr(await oob_header_sx("day-header-child", + "day-admin-header-child", await _day_admin_header_sx(ctx))), + clear_oob=SxExpr(_clear_deeper_oob("post-row", "post-header-child", + "post-admin-row", "post-admin-header-child", + "calendar-row", "calendar-header-child", + "day-row", "day-header-child", + "day-admin-row", "day-admin-header-child")), + ) + + +# --- Entry layout (root + child(post + cal + day + entry), + menu) --- + +async def _entry_full(ctx: dict, **kw: Any) -> str: + from shared.sx.helpers import render_to_sx_with_env, _ctx_to_env + from shared.sx.parser import SxExpr + return await render_to_sx_with_env("events-entry-layout-full", _ctx_to_env(ctx), + post_header=SxExpr(await _post_header_sx(ctx)), + calendar_header=SxExpr(await _calendar_header_sx(ctx)), + day_header=SxExpr(await _day_header_sx(ctx)), + entry_header=SxExpr(await _entry_header_html(ctx)), + ) + + +async def _entry_oob(ctx: dict, **kw: Any) -> str: + from shared.sx.helpers import render_to_sx_with_env, _ctx_to_env, oob_header_sx + from shared.sx.parser import SxExpr + return await render_to_sx_with_env("events-entry-layout-oob", _ctx_to_env(ctx, oob=True), + day_oob=SxExpr(await _day_header_sx(ctx, oob=True)), + entry_oob_wrap=SxExpr(await oob_header_sx("day-header-child", + "entry-header-child", await _entry_header_html(ctx))), + clear_oob=SxExpr(_clear_deeper_oob("post-row", "post-header-child", + "calendar-row", "calendar-header-child", + "day-row", "day-header-child", + "entry-row", "entry-header-child")), + ) + + +# --- Entry admin layout (root + post + child(post-admin + cal + day + entry + entry-admin), + menu) --- + +async def _entry_admin_full(ctx: dict, **kw: Any) -> str: + from shared.sx.helpers import render_to_sx_with_env, _ctx_to_env, post_admin_header_sx + from shared.sx.parser import SxExpr + ctx = await _ensure_container_nav(ctx) + slug = (ctx.get("post") or {}).get("slug", "") + return await render_to_sx_with_env("events-entry-admin-layout-full", _ctx_to_env(ctx), + post_header=SxExpr(await _post_header_sx(ctx)), + admin_header=SxExpr(await post_admin_header_sx(ctx, slug, selected="calendars")), + calendar_header=SxExpr(await _calendar_header_sx(ctx)), + day_header=SxExpr(await _day_header_sx(ctx)), + entry_header=SxExpr(await _entry_header_html(ctx)), + entry_admin_header=SxExpr(await _entry_admin_header_html(ctx)), + ) + + +async def _entry_admin_oob(ctx: dict, **kw: Any) -> str: + from shared.sx.helpers import render_to_sx_with_env, _ctx_to_env, post_admin_header_sx, oob_header_sx + from shared.sx.parser import SxExpr + ctx = await _ensure_container_nav(ctx) + slug = (ctx.get("post") or {}).get("slug", "") + return await render_to_sx_with_env("events-entry-admin-layout-oob", _ctx_to_env(ctx, oob=True), + admin_oob=SxExpr(await post_admin_header_sx(ctx, slug, oob=True, selected="calendars")), + entry_oob=SxExpr(await _entry_header_html(ctx, oob=True)), + entry_admin_oob_wrap=SxExpr(await oob_header_sx("entry-header-child", + "entry-admin-header-child", await _entry_admin_header_html(ctx))), + clear_oob=SxExpr(_clear_deeper_oob("post-row", "post-header-child", + "post-admin-row", "post-admin-header-child", + "calendar-row", "calendar-header-child", + "day-row", "day-header-child", + "entry-row", "entry-header-child", + "entry-admin-row", "entry-admin-header-child")), + ) + + +# --- Ticket types layout (extends entry admin with ticket-types header, + menu) --- + +async def _ticket_types_full(ctx: dict, **kw: Any) -> str: + from shared.sx.helpers import render_to_sx_with_env, _ctx_to_env + from shared.sx.parser import SxExpr + return await render_to_sx_with_env("events-ticket-types-layout-full", _ctx_to_env(ctx), + post_header=SxExpr(await _post_header_sx(ctx)), + calendar_header=SxExpr(await _calendar_header_sx(ctx)), + day_header=SxExpr(await _day_header_sx(ctx)), + entry_header=SxExpr(await _entry_header_html(ctx)), + entry_admin_header=SxExpr(await _entry_admin_header_html(ctx)), + ticket_types_header=SxExpr(await _ticket_types_header_html(ctx)), + ) + + +async def _ticket_types_oob(ctx: dict, **kw: Any) -> str: + from shared.sx.helpers import render_to_sx_with_env, _ctx_to_env, oob_header_sx + from shared.sx.parser import SxExpr + return await render_to_sx_with_env("events-ticket-types-layout-oob", _ctx_to_env(ctx, oob=True), + entry_admin_oob=SxExpr(await _entry_admin_header_html(ctx, oob=True)), + ticket_types_oob_wrap=SxExpr(await oob_header_sx("entry-admin-header-child", + "ticket_types-header-child", await _ticket_types_header_html(ctx))), + ) + + +# --- Ticket type detail layout (extends ticket types with ticket-type header, + menu) --- + +async def _ticket_type_full(ctx: dict, **kw: Any) -> str: + from shared.sx.helpers import render_to_sx_with_env, _ctx_to_env + from shared.sx.parser import SxExpr + return await render_to_sx_with_env("events-ticket-type-layout-full", _ctx_to_env(ctx), + post_header=SxExpr(await _post_header_sx(ctx)), + calendar_header=SxExpr(await _calendar_header_sx(ctx)), + day_header=SxExpr(await _day_header_sx(ctx)), + entry_header=SxExpr(await _entry_header_html(ctx)), + entry_admin_header=SxExpr(await _entry_admin_header_html(ctx)), + ticket_types_header=SxExpr(await _ticket_types_header_html(ctx)), + ticket_type_header=SxExpr(await _ticket_type_header_html(ctx)), + ) + + +async def _ticket_type_oob(ctx: dict, **kw: Any) -> str: + from shared.sx.helpers import render_to_sx_with_env, _ctx_to_env, oob_header_sx + from shared.sx.parser import SxExpr + return await render_to_sx_with_env("events-ticket-type-layout-oob", _ctx_to_env(ctx, oob=True), + ticket_types_oob=SxExpr(await _ticket_types_header_html(ctx, oob=True)), + ticket_type_oob_wrap=SxExpr(await oob_header_sx("ticket_types-header-child", + "ticket_type-header-child", await _ticket_type_header_html(ctx))), + ) + + +# --- Markets layout (root + child(post + markets)) --- + +async def _markets_full(ctx: dict, **kw: Any) -> str: + from shared.sx.helpers import render_to_sx_with_env, _ctx_to_env + from shared.sx.parser import SxExpr + return await render_to_sx_with_env("events-markets-layout-full", _ctx_to_env(ctx), + post_header=SxExpr(await _post_header_sx(ctx)), + markets_header=SxExpr(await _markets_header_sx(ctx)), + ) + + +async def _markets_oob(ctx: dict, **kw: Any) -> str: + from shared.sx.helpers import render_to_sx_with_env, _ctx_to_env, oob_header_sx + from shared.sx.parser import SxExpr + return await render_to_sx_with_env("events-markets-layout-oob", _ctx_to_env(ctx, oob=True), + post_oob=SxExpr(await _post_header_sx(ctx, oob=True)), + markets_oob_wrap=SxExpr(await oob_header_sx("post-header-child", + "markets-header-child", await _markets_header_sx(ctx))), + ) + + # --------------------------------------------------------------------------- # Page helpers # --------------------------------------------------------------------------- @@ -209,7 +509,6 @@ def _register_events_helpers() -> None: async def _h_calendar_admin_content(calendar_slug=None, **kw): await _ensure_calendar(calendar_slug) from shared.sx.page import get_template_context - from .calendar import _calendar_admin_main_panel_html ctx = await get_template_context() return await _calendar_admin_main_panel_html(ctx) @@ -218,7 +517,6 @@ async def _h_day_admin_content(calendar_slug=None, year=None, month=None, day=No await _ensure_calendar(calendar_slug) if year is not None: await _ensure_day_data(int(year), int(month), int(day)) - from .calendar import _day_admin_main_panel_html return await _day_admin_main_panel_html({}) @@ -227,7 +525,6 @@ async def _h_slots_content(calendar_slug=None, **kw): await _ensure_calendar(calendar_slug) calendar = getattr(g, "calendar", None) from bp.slots.services.slots import list_slots as svc_list_slots - from .slots import render_slots_table slots = await svc_list_slots(g.s, calendar.id) if calendar else [] _add_to_defpage_ctx(slots=slots) return await render_slots_table(slots, calendar) @@ -237,7 +534,6 @@ async def _h_slot_content(calendar_slug=None, slot_id=None, **kw): from quart import g, abort await _ensure_calendar(calendar_slug) from bp.slot.services.slot import get_slot as svc_get_slot - from .slots import render_slot_main_panel slot = await svc_get_slot(g.s, slot_id) if slot_id else None if not slot: abort(404) @@ -251,7 +547,6 @@ async def _h_entry_content(calendar_slug=None, entry_id=None, **kw): await _ensure_calendar(calendar_slug) await _ensure_entry_context(entry_id) from shared.sx.page import get_template_context - from .entries import _entry_main_panel_html ctx = await get_template_context() return await _entry_main_panel_html(ctx) @@ -260,7 +555,6 @@ async def _h_entry_menu(calendar_slug=None, entry_id=None, **kw): await _ensure_calendar(calendar_slug) await _ensure_entry_context(entry_id) from shared.sx.page import get_template_context - from .entries import _entry_nav_html ctx = await get_template_context() return await _entry_nav_html(ctx) @@ -269,7 +563,6 @@ async def _h_entry_admin_content(calendar_slug=None, entry_id=None, **kw): await _ensure_calendar(calendar_slug) await _ensure_entry_context(entry_id) from shared.sx.page import get_template_context - from .entries import _entry_admin_main_panel_html ctx = await get_template_context() return await _entry_admin_main_panel_html(ctx) @@ -287,7 +580,6 @@ async def _h_ticket_types_content(calendar_slug=None, entry_id=None, entry = getattr(g, "entry", None) calendar = getattr(g, "calendar", None) from bp.ticket_types.services.tickets import list_ticket_types as svc_list_ticket_types - from .tickets import render_ticket_types_table ticket_types = await svc_list_ticket_types(g.s, entry.id) if entry else [] _add_to_defpage_ctx(ticket_types=ticket_types) return await render_ticket_types_table(ticket_types, entry, calendar, day, month, year) @@ -299,7 +591,6 @@ async def _h_ticket_type_content(calendar_slug=None, entry_id=None, await _ensure_calendar(calendar_slug) await _ensure_entry(entry_id) from bp.ticket_type.services.ticket import get_ticket_type as svc_get_ticket_type - from .tickets import render_ticket_type_main_panel ticket_type = await svc_get_ticket_type(g.s, ticket_type_id) if ticket_type_id else None if not ticket_type: abort(404) @@ -314,7 +605,6 @@ async def _h_tickets_content(**kw): from quart import g from shared.infrastructure.cart_identity import current_cart_identity from bp.tickets.services.tickets import get_user_tickets - from .tickets import _tickets_main_panel_html ident = current_cart_identity() tickets = await get_user_tickets( g.s, @@ -330,7 +620,6 @@ async def _h_ticket_detail_content(code=None, **kw): from quart import g, abort from shared.infrastructure.cart_identity import current_cart_identity from bp.tickets.services.tickets import get_ticket_by_code - from .tickets import _ticket_detail_panel_html ticket = await get_ticket_by_code(g.s, code) if code else None if not ticket: abort(404) @@ -354,7 +643,6 @@ async def _h_ticket_admin_content(**kw): from sqlalchemy import select, func from sqlalchemy.orm import selectinload from models.calendars import CalendarEntry, Ticket - from .tickets import _ticket_admin_main_panel_html result = await g.s.execute( select(Ticket) @@ -391,6 +679,5 @@ async def _h_ticket_admin_content(**kw): async def _h_markets_content(**kw): from shared.sx.page import get_template_context - from .calendar import _markets_main_panel_html ctx = await get_template_context() return await _markets_main_panel_html(ctx) diff --git a/sx/sxc/pages/__init__.py b/sx/sxc/pages/__init__.py index 782923f..9f313f8 100644 --- a/sx/sxc/pages/__init__.py +++ b/sx/sxc/pages/__init__.py @@ -1,10 +1,6 @@ -"""SX docs defpage setup — registers layouts and page helpers.""" +"""SX docs defpage setup — registers layouts, page helpers, and loads .sx pages.""" from __future__ import annotations -import os - -from shared.sx.jinja_bridge import load_sx_dir, watch_sx_dir - def setup_sx_pages() -> None: """Register sx-specific layouts, page helpers, and load page definitions.""" @@ -17,8 +13,9 @@ def setup_sx_pages() -> None: def _load_sx_page_files() -> None: """Load defpage definitions from sx/sxc/pages/*.sx.""" + import os from shared.sx.pages import load_page_dir - from shared.sx.jinja_bridge import load_service_components + from shared.sx.jinja_bridge import load_sx_dir, watch_sx_dir, load_service_components _sxc_dir = os.path.dirname(os.path.dirname(__file__)) # sx/sxc/ service_root = os.path.dirname(_sxc_dir) # sx/ load_service_components(service_root, service_name="sx") diff --git a/sx/sxc/pages/essays.py b/sx/sxc/pages/essays.py index a58dab6..1685783 100644 --- a/sx/sxc/pages/essays.py +++ b/sx/sxc/pages/essays.py @@ -1,9 +1,107 @@ -"""Individual content builder functions for sx docs pages.""" +"""All content generator functions and dispatchers for sx docs pages.""" from __future__ import annotations from .renders import _code, _example_code, _placeholder, _oob_code, _clear_components_btn from .utils import _attr_table_sx, _primitives_section_sx, _headers_table_sx + +# --------------------------------------------------------------------------- +# Dispatcher functions — route slugs to content builders +# --------------------------------------------------------------------------- + +async def _docs_content_sx(slug: str) -> str: + """Route to the right docs content builder.""" + import inspect + builders = { + "introduction": _docs_introduction_sx, + "getting-started": _docs_getting_started_sx, + "components": _docs_components_sx, + "evaluator": _docs_evaluator_sx, + "primitives": _docs_primitives_sx, + "css": _docs_css_sx, + "server-rendering": _docs_server_rendering_sx, + } + builder = builders.get(slug, _docs_introduction_sx) + result = builder() + return await result if inspect.isawaitable(result) else result + + +async def _reference_content_sx(slug: str) -> str: + import inspect + builders = { + "attributes": _reference_attrs_sx, + "headers": _reference_headers_sx, + "events": _reference_events_sx, + "js-api": _reference_js_api_sx, + } + result = builders.get(slug or "", _reference_attrs_sx)() + return await result if inspect.isawaitable(result) else result + + +def _protocol_content_sx(slug: str) -> str: + builders = { + "wire-format": _protocol_wire_format_sx, + "fragments": _protocol_fragments_sx, + "resolver-io": _protocol_resolver_io_sx, + "internal-services": _protocol_internal_services_sx, + "activitypub": _protocol_activitypub_sx, + "future": _protocol_future_sx, + } + return builders.get(slug, _protocol_wire_format_sx)() + + +def _examples_content_sx(slug: str) -> str: + builders = { + "click-to-load": _example_click_to_load_sx, + "form-submission": _example_form_submission_sx, + "polling": _example_polling_sx, + "delete-row": _example_delete_row_sx, + "inline-edit": _example_inline_edit_sx, + "oob-swaps": _example_oob_swaps_sx, + "lazy-loading": _example_lazy_loading_sx, + "infinite-scroll": _example_infinite_scroll_sx, + "progress-bar": _example_progress_bar_sx, + "active-search": _example_active_search_sx, + "inline-validation": _example_inline_validation_sx, + "value-select": _example_value_select_sx, + "reset-on-submit": _example_reset_on_submit_sx, + "edit-row": _example_edit_row_sx, + "bulk-update": _example_bulk_update_sx, + "swap-positions": _example_swap_positions_sx, + "select-filter": _example_select_filter_sx, + "tabs": _example_tabs_sx, + "animations": _example_animations_sx, + "dialogs": _example_dialogs_sx, + "keyboard-shortcuts": _example_keyboard_shortcuts_sx, + "put-patch": _example_put_patch_sx, + "json-encoding": _example_json_encoding_sx, + "vals-and-headers": _example_vals_and_headers_sx, + "loading-states": _example_loading_states_sx, + "sync-replace": _example_sync_replace_sx, + "retry": _example_retry_sx, + } + return builders.get(slug, _example_click_to_load_sx)() + + +def _essay_content_sx(slug: str) -> str: + builders = { + "sx-sucks": _essay_sx_sucks, + "why-sexps": _essay_why_sexps, + "htmx-react-hybrid": _essay_htmx_react_hybrid, + "on-demand-css": _essay_on_demand_css, + "client-reactivity": _essay_client_reactivity, + "sx-native": _essay_sx_native, + "sx-manifesto": _essay_sx_manifesto, + "tail-call-optimization": _essay_tail_call_optimization, + "continuations": _essay_continuations, + } + return builders.get(slug, _essay_sx_sucks)() + + +# --------------------------------------------------------------------------- +# Individual content builders +# --------------------------------------------------------------------------- + def _docs_introduction_sx() -> str: return ( '(~doc-page :title "Introduction"' diff --git a/sx/sxc/pages/helpers.py b/sx/sxc/pages/helpers.py index 399c606..97462a3 100644 --- a/sx/sxc/pages/helpers.py +++ b/sx/sxc/pages/helpers.py @@ -1,117 +1,14 @@ -"""Dispatcher functions, public partials, and page helper registration for sx docs.""" +"""Public partials and page helper registration for sx docs.""" from __future__ import annotations from .essays import ( - _docs_introduction_sx, _docs_getting_started_sx, _docs_components_sx, - _docs_evaluator_sx, _docs_primitives_sx, _docs_css_sx, _docs_server_rendering_sx, - _reference_index_sx, _reference_attr_detail_sx, _reference_attrs_sx, - _reference_headers_sx, _reference_events_sx, _reference_js_api_sx, - _protocol_wire_format_sx, _protocol_fragments_sx, _protocol_resolver_io_sx, - _protocol_internal_services_sx, _protocol_activitypub_sx, _protocol_future_sx, - _example_click_to_load_sx, _example_form_submission_sx, _example_polling_sx, - _example_delete_row_sx, _example_inline_edit_sx, _example_oob_swaps_sx, - _example_lazy_loading_sx, _example_infinite_scroll_sx, _example_progress_bar_sx, - _example_active_search_sx, _example_inline_validation_sx, _example_value_select_sx, - _example_reset_on_submit_sx, _example_edit_row_sx, _example_bulk_update_sx, - _example_swap_positions_sx, _example_select_filter_sx, _example_tabs_sx, - _example_animations_sx, _example_dialogs_sx, _example_keyboard_shortcuts_sx, - _example_put_patch_sx, _example_json_encoding_sx, _example_vals_and_headers_sx, - _example_loading_states_sx, _example_sync_replace_sx, _example_retry_sx, - _essay_sx_sucks, _essay_why_sexps, _essay_htmx_react_hybrid, - _essay_on_demand_css, _essay_client_reactivity, _essay_sx_native, - _essay_sx_manifesto, _essay_tail_call_optimization, _essay_continuations, + _docs_content_sx, _reference_content_sx, _protocol_content_sx, + _examples_content_sx, _essay_content_sx, + _reference_index_sx, _reference_attr_detail_sx, ) from .utils import _docs_nav_sx, _reference_nav_sx, _protocols_nav_sx, _examples_nav_sx, _essays_nav_sx from content.highlight import highlight -async def _docs_content_sx(slug: str) -> str: - """Route to the right docs content builder.""" - import inspect - builders = { - "introduction": _docs_introduction_sx, - "getting-started": _docs_getting_started_sx, - "components": _docs_components_sx, - "evaluator": _docs_evaluator_sx, - "primitives": _docs_primitives_sx, - "css": _docs_css_sx, - "server-rendering": _docs_server_rendering_sx, - } - builder = builders.get(slug, _docs_introduction_sx) - result = builder() - return await result if inspect.isawaitable(result) else result - - -async def _reference_content_sx(slug: str) -> str: - import inspect - builders = { - "attributes": _reference_attrs_sx, - "headers": _reference_headers_sx, - "events": _reference_events_sx, - "js-api": _reference_js_api_sx, - } - result = builders.get(slug or "", _reference_attrs_sx)() - return await result if inspect.isawaitable(result) else result - - -def _protocol_content_sx(slug: str) -> str: - builders = { - "wire-format": _protocol_wire_format_sx, - "fragments": _protocol_fragments_sx, - "resolver-io": _protocol_resolver_io_sx, - "internal-services": _protocol_internal_services_sx, - "activitypub": _protocol_activitypub_sx, - "future": _protocol_future_sx, - } - return builders.get(slug, _protocol_wire_format_sx)() - - -def _examples_content_sx(slug: str) -> str: - builders = { - "click-to-load": _example_click_to_load_sx, - "form-submission": _example_form_submission_sx, - "polling": _example_polling_sx, - "delete-row": _example_delete_row_sx, - "inline-edit": _example_inline_edit_sx, - "oob-swaps": _example_oob_swaps_sx, - "lazy-loading": _example_lazy_loading_sx, - "infinite-scroll": _example_infinite_scroll_sx, - "progress-bar": _example_progress_bar_sx, - "active-search": _example_active_search_sx, - "inline-validation": _example_inline_validation_sx, - "value-select": _example_value_select_sx, - "reset-on-submit": _example_reset_on_submit_sx, - "edit-row": _example_edit_row_sx, - "bulk-update": _example_bulk_update_sx, - "swap-positions": _example_swap_positions_sx, - "select-filter": _example_select_filter_sx, - "tabs": _example_tabs_sx, - "animations": _example_animations_sx, - "dialogs": _example_dialogs_sx, - "keyboard-shortcuts": _example_keyboard_shortcuts_sx, - "put-patch": _example_put_patch_sx, - "json-encoding": _example_json_encoding_sx, - "vals-and-headers": _example_vals_and_headers_sx, - "loading-states": _example_loading_states_sx, - "sync-replace": _example_sync_replace_sx, - "retry": _example_retry_sx, - } - return builders.get(slug, _example_click_to_load_sx)() - - -def _essay_content_sx(slug: str) -> str: - builders = { - "sx-sucks": _essay_sx_sucks, - "why-sexps": _essay_why_sexps, - "htmx-react-hybrid": _essay_htmx_react_hybrid, - "on-demand-css": _essay_on_demand_css, - "client-reactivity": _essay_client_reactivity, - "sx-native": _essay_sx_native, - "sx-manifesto": _essay_sx_manifesto, - "tail-call-optimization": _essay_tail_call_optimization, - "continuations": _essay_continuations, - } - return builders.get(slug, _essay_sx_sucks)() - def home_content_sx() -> str: """Home page content as sx wire format.""" From 7fda7a8027c976f25a5b3b68ab7855b890f8b746 Mon Sep 17 00:00:00 2001 From: giles Date: Wed, 4 Mar 2026 18:20:57 +0000 Subject: [PATCH 084/105] Replace env free-variable threading with IO-primitive auto-fetch macros Layout components now self-resolve context (cart-mini, auth-menu, nav-tree, rights, URLs) via new IO primitives (root-header-ctx, select-colours, account-nav-ctx, app-rights) and defmacro wrappers (~root-header-auto, ~auth-header-row-auto, ~root-mobile-auto). This eliminates _ctx_to_env(), HELPER_CSS_CLASSES, and verbose :key threading across all 10 services. Co-Authored-By: Claude Opus 4.6 --- account/sx/layouts.sx | 27 +++------ blog/sx/admin.sx | 9 +++ blog/sx/layouts.sx | 37 ++++++------ blog/sxc/pages/helpers.py | 11 ++-- blog/sxc/pages/layouts.py | 40 ++++--------- cart/sx/header.sx | 5 ++ cart/sx/layouts.sx | 35 ++++------- cart/sxc/pages/layouts.py | 21 +++---- cart/sxc/pages/renders.py | 20 +++---- events/sx/header.sx | 7 +++ events/sx/layouts.sx | 34 +++-------- events/sxc/pages/calendar.py | 2 +- events/sxc/pages/entries.py | 2 +- events/sxc/pages/helpers.py | 68 ++++++++++----------- events/sxc/pages/layouts.py | 68 ++++++++++----------- events/sxc/pages/renders.py | 12 ++-- events/sxc/pages/slots.py | 15 +---- events/sxc/pages/utils.py | 3 +- federation/sx/layouts.sx | 9 +-- federation/sxc/pages/utils.py | 15 ++--- market/sx/grids.sx | 5 +- market/sx/headers.sx | 2 +- market/sx/layouts.sx | 18 ++---- market/sxc/pages/helpers.py | 10 ++-- market/sxc/pages/layouts.py | 13 ++-- market/sxc/pages/renders.py | 12 ++-- market/sxc/pages/utils.py | 3 +- orders/sx/layouts.sx | 37 ++++-------- shared/sx/helpers.py | 96 +++--------------------------- shared/sx/layouts.py | 17 +++--- shared/sx/primitives_io.py | 108 ++++++++++++++++++++++++++++++++++ shared/sx/templates/auth.sx | 21 +++++++ shared/sx/templates/layout.sx | 65 +++++++++++++++++--- sx/sx/docs.sx | 65 ++++++++++++++++++++ sx/sx/layouts.sx | 10 +--- sx/sxc/pages/essays.py | 60 ++++++++----------- sx/sxc/pages/layouts.py | 16 ++--- sx/sxc/pages/renders.py | 23 ++------ sx/sxc/pages/utils.py | 39 +++--------- test/sx/components.sx | 8 +-- test/sxc/pages/renders.py | 6 +- 41 files changed, 551 insertions(+), 523 deletions(-) create mode 100644 sx/sx/docs.sx diff --git a/account/sx/layouts.sx b/account/sx/layouts.sx index 19c028a..10052e5 100644 --- a/account/sx/layouts.sx +++ b/account/sx/layouts.sx @@ -1,33 +1,20 @@ -;; Account layout defcomps — read ctx values from env free variables. +;; Account layout defcomps — fully self-contained via IO primitives. ;; Registered via register_sx_layout("account", ...) in __init__.py. -;; Free variables come from _ctx_to_env() in shared/sx/helpers.py. ;; Full page: root header + auth header row in header-child (defcomp ~account-layout-full () - (<> (~root-header :cart-mini cart-mini :blog-url blog-url :site-title site-title - :app-label app-label :nav-tree nav-tree :auth-menu auth-menu - :nav-panel nav-panel :settings-url settings-url :is-admin is-admin) + (<> (~root-header-auto) (~header-child-sx - :inner (~auth-header-row :account-url account-url - :select-colours select-colours - :account-nav account-nav)))) + :inner (~auth-header-row-auto)))) ;; OOB (HTMX): auth row + root header, both with oob=true (defcomp ~account-layout-oob () - (<> (~auth-header-row :account-url account-url - :select-colours select-colours - :account-nav account-nav - :oob true) - (~root-header :cart-mini cart-mini :blog-url blog-url :site-title site-title - :app-label app-label :nav-tree nav-tree :auth-menu auth-menu - :nav-panel nav-panel :settings-url settings-url :is-admin is-admin - :oob true))) + (<> (~auth-header-row-auto true) + (~root-header-auto true))) ;; Mobile menu: auth section + root nav (defcomp ~account-layout-mobile () (<> (~mobile-menu-section :label "account" :href "/" :level 1 :colour "sky" - :items (~auth-nav-items :account-url account-url - :select-colours select-colours - :account-nav account-nav)) - (~root-mobile :nav-tree nav-tree :auth-menu auth-menu))) + :items (~auth-nav-items-auto)) + (~root-mobile-auto))) diff --git a/blog/sx/admin.sx b/blog/sx/admin.sx index b78233d..95172ec 100644 --- a/blog/sx/admin.sx +++ b/blog/sx/admin.sx @@ -170,6 +170,15 @@ (summary :class "cursor-pointer px-4 py-3 font-medium text-sm bg-stone-100 hover:bg-stone-200 select-none" title) (div :class "p-4 overflow-x-auto text-xs" content))) +(defcomp ~blog-preview-rendered (&key html) + (div :class "blog-content prose max-w-none" (raw! html))) + +(defcomp ~blog-preview-empty () + (div :class "p-8 text-stone-500" "No content to preview.")) + +(defcomp ~blog-admin-placeholder () + (div :class "pb-8")) + ;; --------------------------------------------------------------------------- ;; Data-driven content defcomps (called from defpages with service data) ;; --------------------------------------------------------------------------- diff --git a/blog/sx/layouts.sx b/blog/sx/layouts.sx index 9058011..a1fe5e5 100644 --- a/blog/sx/layouts.sx +++ b/blog/sx/layouts.sx @@ -1,37 +1,38 @@ -;; Blog layout defcomps — root header from env free variables, -;; blog-specific headers passed as &key params. +;; Blog layout defcomps — fully self-contained via IO primitives. -;; --- Blog layout (root + invisible blog header) --- +;; --- Blog header (invisible row for blog-header-child swap target) --- -(defcomp ~blog-layout-full (&key blog-header) - (<> (~root-header :cart-mini cart-mini :blog-url blog-url :site-title site-title - :app-label app-label :nav-tree nav-tree :auth-menu auth-menu - :nav-panel nav-panel :settings-url settings-url :is-admin is-admin) - blog-header)) +(defcomp ~blog-header (&key oob) + (~menu-row-sx :id "blog-row" :level 1 + :link-label-content (div) + :child-id "blog-header-child" :oob oob)) + +;; --- Blog layout (root + blog header) --- + +(defcomp ~blog-layout-full () + (<> (~root-header-auto) + (~blog-header))) ;; --- Settings layout (root + settings header) --- (defcomp ~settings-layout-full (&key settings-header) - (<> (~root-header :cart-mini cart-mini :blog-url blog-url :site-title site-title - :app-label app-label :nav-tree nav-tree :auth-menu auth-menu - :nav-panel nav-panel :settings-url settings-url :is-admin is-admin) + (<> (~root-header-auto) settings-header)) ;; --- Sub-settings layout (root + settings + sub row) --- (defcomp ~sub-settings-layout-full (&key settings-header sub-header) - (<> (~root-header :cart-mini cart-mini :blog-url blog-url :site-title site-title - :app-label app-label :nav-tree nav-tree :auth-menu auth-menu - :nav-panel nav-panel :settings-url settings-url :is-admin is-admin) + (<> (~root-header-auto) settings-header sub-header)) (defcomp ~sub-settings-layout-oob (&key settings-header-oob sub-header-oob) (<> settings-header-oob sub-header-oob)) -;; --- Settings nav links (replaces Python _settings_nav_sx loop) --- +;; --- Settings nav links — uses (select-colours) IO primitive --- -(defcomp ~blog-settings-nav (&key select-colours) - (let* ((links (list +(defcomp ~blog-settings-nav () + (let* ((sc (select-colours)) + (links (list (dict :endpoint "menu_items.defpage_menu_items_page" :icon "fa fa-bars" :label "Menu Items") (dict :endpoint "snippets.defpage_snippets_page" :icon "fa fa-puzzle-piece" :label "Snippets") (dict :endpoint "blog.tag_groups_admin.defpage_tag_groups_page" :icon "fa fa-tags" :label "Tag Groups") @@ -41,7 +42,7 @@ :href (url-for (get lnk "endpoint")) :icon (get lnk "icon") :label (get lnk "label") - :select-colours (or select-colours ""))) + :select-colours (or sc ""))) links)))) ;; --- Editor panel wrapper --- diff --git a/blog/sxc/pages/helpers.py b/blog/sxc/pages/helpers.py index 4c75d33..bc4b900 100644 --- a/blog/sxc/pages/helpers.py +++ b/blog/sxc/pages/helpers.py @@ -140,7 +140,8 @@ async def _h_editor_page_content(**kw): async def _h_post_admin_content(slug=None, **kw): await _ensure_post_data(slug) - return '(div :class "pb-8")' + from shared.sx.helpers import render_to_sx + return await render_to_sx("blog-admin-placeholder") async def _h_post_data_content(slug=None, **kw): @@ -264,7 +265,7 @@ async def _h_post_preview_content(slug=None, **kw): from quart import g from shared.services.registry import services from shared.sx.helpers import render_to_sx - from shared.sx.parser import SxExpr, serialize as sx_serialize + from shared.sx.parser import SxExpr preview = await services.blog_page.preview_data(g.s) @@ -276,16 +277,16 @@ async def _h_post_preview_content(slug=None, **kw): sections.append(await render_to_sx("blog-preview-section", title="Lexical JSON", content=SxExpr(preview["json_pretty"]))) if preview.get("sx_rendered"): - rendered_sx = f'(div :class "blog-content prose max-w-none" (raw! {sx_serialize(preview["sx_rendered"])}))' + rendered_sx = await render_to_sx("blog-preview-rendered", html=preview["sx_rendered"]) sections.append(await render_to_sx("blog-preview-section", title="SX Rendered", content=SxExpr(rendered_sx))) if preview.get("lex_rendered"): - rendered_sx = f'(div :class "blog-content prose max-w-none" (raw! {sx_serialize(preview["lex_rendered"])}))' + rendered_sx = await render_to_sx("blog-preview-rendered", html=preview["lex_rendered"]) sections.append(await render_to_sx("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.")' + return await render_to_sx("blog-preview-empty") inner = " ".join(sections) return await render_to_sx("blog-preview-panel", sections=SxExpr(f"(<> {inner})")) diff --git a/blog/sxc/pages/layouts.py b/blog/sxc/pages/layouts.py index aeb22c6..36eb7f3 100644 --- a/blog/sxc/pages/layouts.py +++ b/blog/sxc/pages/layouts.py @@ -8,15 +8,6 @@ from typing import Any # Header helpers (moved from sx_components — thin render_to_sx wrappers) # --------------------------------------------------------------------------- -async def _blog_header_sx(ctx: dict, *, oob: bool = False) -> str: - from shared.sx.helpers import render_to_sx - from shared.sx.parser import SxExpr - return await render_to_sx("menu-row-sx", - id="blog-row", level=1, - link_label_content=SxExpr("(div)"), - child_id="blog-header-child", oob=oob) - - async def _settings_header_sx(ctx: dict, *, oob: bool = False) -> str: from shared.sx.helpers import render_to_sx from shared.sx.parser import SxExpr @@ -36,8 +27,7 @@ async def _settings_header_sx(ctx: dict, *, oob: bool = False) -> str: async def _settings_nav_sx(ctx: dict) -> str: from shared.sx.helpers import render_to_sx - return await render_to_sx("blog-settings-nav", - select_colours=ctx.get("select_colours", "")) + return await render_to_sx("blog-settings-nav") async def _sub_settings_header_sx(row_id: str, child_id: str, href: str, @@ -76,33 +66,29 @@ def _register_blog_layouts() -> None: # --- Blog layout (root + blog header) --- async def _blog_full(ctx: dict, **kw: Any) -> str: - from shared.sx.helpers import render_to_sx_with_env, _ctx_to_env - from shared.sx.parser import SxExpr - return await render_to_sx_with_env("blog-layout-full", _ctx_to_env(ctx), - blog_header=SxExpr(await _blog_header_sx(ctx))) + from shared.sx.helpers import render_to_sx_with_env + return await render_to_sx_with_env("blog-layout-full", {}) async def _blog_oob(ctx: dict, **kw: Any) -> str: - from shared.sx.helpers import render_to_sx_with_env, _ctx_to_env, oob_header_sx - from shared.sx.parser import SxExpr - rows = await render_to_sx_with_env("blog-layout-full", _ctx_to_env(ctx), - blog_header=SxExpr(await _blog_header_sx(ctx))) + from shared.sx.helpers import render_to_sx_with_env, oob_header_sx + rows = await render_to_sx_with_env("blog-layout-full", {}) return await oob_header_sx("root-header-child", "blog-header-child", rows) # --- Settings layout (root + settings header) --- async def _settings_full(ctx: dict, **kw: Any) -> str: - from shared.sx.helpers import render_to_sx_with_env, _ctx_to_env + from shared.sx.helpers import render_to_sx_with_env from shared.sx.parser import SxExpr - return await render_to_sx_with_env("settings-layout-full", _ctx_to_env(ctx), + return await render_to_sx_with_env("settings-layout-full", {}, settings_header=SxExpr(await _settings_header_sx(ctx))) async def _settings_oob(ctx: dict, **kw: Any) -> str: - from shared.sx.helpers import render_to_sx_with_env, _ctx_to_env, oob_header_sx + from shared.sx.helpers import render_to_sx_with_env, oob_header_sx from shared.sx.parser import SxExpr - rows = await render_to_sx_with_env("settings-layout-full", _ctx_to_env(ctx), + rows = await render_to_sx_with_env("settings-layout-full", {}, settings_header=SxExpr(await _settings_header_sx(ctx))) return await oob_header_sx("root-header-child", "root-settings-header-child", rows) @@ -115,10 +101,10 @@ async def _settings_mobile(ctx: dict, **kw: Any) -> str: async def _sub_settings_full(ctx: dict, row_id: str, child_id: str, endpoint: str, icon: str, label: str) -> str: - from shared.sx.helpers import render_to_sx_with_env, _ctx_to_env + from shared.sx.helpers import render_to_sx_with_env from shared.sx.parser import SxExpr from quart import url_for as qurl - return await render_to_sx_with_env("sub-settings-layout-full", _ctx_to_env(ctx), + return await render_to_sx_with_env("sub-settings-layout-full", {}, settings_header=SxExpr(await _settings_header_sx(ctx)), sub_header=SxExpr(await _sub_settings_header_sx( row_id, child_id, qurl(endpoint), icon, label, ctx))) @@ -190,10 +176,10 @@ async def _tag_groups_oob(ctx: dict, **kw: Any) -> str: async def _tag_group_edit_full(ctx: dict, **kw: Any) -> str: from quart import request, url_for as qurl - from shared.sx.helpers import render_to_sx_with_env, _ctx_to_env + from shared.sx.helpers import render_to_sx_with_env from shared.sx.parser import SxExpr g_id = (request.view_args or {}).get("id") - return await render_to_sx_with_env("sub-settings-layout-full", _ctx_to_env(ctx), + return await render_to_sx_with_env("sub-settings-layout-full", {}, settings_header=SxExpr(await _settings_header_sx(ctx)), sub_header=SxExpr(await _sub_settings_header_sx( "tag-groups-row", "tag-groups-header-child", diff --git a/cart/sx/header.sx b/cart/sx/header.sx index 692be37..1fc17bf 100644 --- a/cart/sx/header.sx +++ b/cart/sx/header.sx @@ -3,6 +3,11 @@ (defcomp ~cart-page-label-img (&key src) (img :src src :class "h-8 w-8 rounded-full object-cover border border-stone-300 flex-shrink-0")) +(defcomp ~cart-page-label (&key feature-image title) + (<> (when feature-image + (~cart-page-label-img :src feature-image)) + (span title))) + (defcomp ~cart-all-carts-link (&key href) (a :href href :class "inline-flex items-center gap-1.5 px-3 py-1.5 text-sm rounded-full border border-stone-300 bg-white hover:bg-stone-50 transition" (i :class "fa fa-arrow-left text-xs" :aria-hidden "true") "All carts")) diff --git a/cart/sx/layouts.sx b/cart/sx/layouts.sx index bb34148..1bdfaf9 100644 --- a/cart/sx/layouts.sx +++ b/cart/sx/layouts.sx @@ -1,12 +1,9 @@ -;; Cart layout defcomps — root header from env free variables, -;; cart-specific headers passed as &key params. +;; Cart layout defcomps — fully self-contained via IO primitives. ;; --- cart-page layout: root + cart row + page-cart row --- (defcomp ~cart-page-layout-full (&key cart-row page-cart-row) - (<> (~root-header :cart-mini cart-mini :blog-url blog-url :site-title site-title - :app-label app-label :nav-tree nav-tree :auth-menu auth-menu - :nav-panel nav-panel :settings-url settings-url :is-admin is-admin) + (<> (~root-header-auto) (~header-child-sx :inner (<> cart-row (~header-child-sx :id "cart-header-child" :inner page-cart-row))))) @@ -19,40 +16,31 @@ ;; --- cart-admin layout: root + post header + admin header --- (defcomp ~cart-admin-layout-full (&key post-header admin-header) - (<> (~root-header :cart-mini cart-mini :blog-url blog-url :site-title site-title - :app-label app-label :nav-tree nav-tree :auth-menu auth-menu - :nav-panel nav-panel :settings-url settings-url :is-admin is-admin) + (<> (~root-header-auto) post-header admin-header)) ;; --- orders-within-cart: root + auth-simple + orders --- (defcomp ~cart-orders-layout-full (&key list-url) - (<> (~root-header :cart-mini cart-mini :blog-url blog-url :site-title site-title - :app-label app-label :nav-tree nav-tree :auth-menu auth-menu - :nav-panel nav-panel :settings-url settings-url :is-admin is-admin) + (<> (~root-header-auto) (~header-child-sx - :inner (<> (~auth-header-row-simple :account-url account-url) + :inner (<> (~auth-header-row-simple-auto) (~header-child-sx :id "auth-header-child" :inner (~orders-header-row :list-url list-url)))))) (defcomp ~cart-orders-layout-oob (&key list-url) - (<> (~auth-header-row-simple :account-url account-url :oob true) + (<> (~auth-header-row-simple-auto true) (~oob-header-sx :parent-id "auth-header-child" :row (~orders-header-row :list-url list-url)) - (~root-header :cart-mini cart-mini :blog-url blog-url :site-title site-title - :app-label app-label :nav-tree nav-tree :auth-menu auth-menu - :nav-panel nav-panel :settings-url settings-url :is-admin is-admin - :oob true))) + (~root-header-auto true))) ;; --- order-detail-within-cart: root + auth-simple + orders + order --- (defcomp ~cart-order-detail-layout-full (&key list-url detail-url order-label) - (<> (~root-header :cart-mini cart-mini :blog-url blog-url :site-title site-title - :app-label app-label :nav-tree nav-tree :auth-menu auth-menu - :nav-panel nav-panel :settings-url settings-url :is-admin is-admin) + (<> (~root-header-auto) (~header-child-sx - :inner (<> (~auth-header-row-simple :account-url account-url) + :inner (<> (~auth-header-row-simple-auto) (~header-child-sx :id "auth-header-child" :inner (<> (~orders-header-row :list-url list-url) (~header-child-sx :id "orders-header-child" @@ -67,10 +55,7 @@ :row (~menu-row-sx :id "order-row" :level 3 :colour "sky" :link-href detail-url :link-label order-label :icon "fa fa-gbp" :oob true)) - (~root-header :cart-mini cart-mini :blog-url blog-url :site-title site-title - :app-label app-label :nav-tree nav-tree :auth-menu auth-menu - :nav-panel nav-panel :settings-url settings-url :is-admin is-admin - :oob true))) + (~root-header-auto true))) ;; --- orders rows wrapper (for infinite scroll) --- diff --git a/cart/sxc/pages/layouts.py b/cart/sxc/pages/layouts.py index 1591129..9b4c981 100644 --- a/cart/sxc/pages/layouts.py +++ b/cart/sxc/pages/layouts.py @@ -3,7 +3,6 @@ from __future__ import annotations from typing import Any -from markupsafe import escape from shared.sx.parser import SxExpr @@ -73,11 +72,9 @@ async def _page_cart_header_sx(ctx: dict, page_post: Any, *, oob: bool = False) from shared.sx.helpers import render_to_sx, call_url slug = page_post.slug if page_post else "" title = ((page_post.title if page_post else None) or "")[:160] - label_parts = [] - if page_post and page_post.feature_image: - label_parts.append(await render_to_sx("cart-page-label-img", src=page_post.feature_image)) - label_parts.append(f'(span "{escape(title)}")') - label_sx = "(<> " + " ".join(label_parts) + ")" + label_sx = await render_to_sx("cart-page-label", + feature_image=page_post.feature_image if page_post else None, + title=title) nav_sx = await render_to_sx("cart-all-carts-link", href=call_url(ctx, "cart_url", "/")) return await render_to_sx( "menu-row-sx", @@ -101,9 +98,9 @@ async def _cart_page_admin_header_sx(ctx: dict, page_post: Any, *, oob: bool = F # --------------------------------------------------------------------------- async def _cart_page_full(ctx: dict, **kw: Any) -> str: - from shared.sx.helpers import render_to_sx_with_env, _ctx_to_env + from shared.sx.helpers import render_to_sx_with_env page_post = ctx.get("page_post") - env = _ctx_to_env(ctx) + env = {} return await render_to_sx_with_env("cart-page-layout-full", env, cart_row=SxExpr(await _cart_header_sx(ctx)), page_cart_row=SxExpr(await _page_cart_header_sx(ctx, page_post)), @@ -111,9 +108,9 @@ async def _cart_page_full(ctx: dict, **kw: Any) -> str: async def _cart_page_oob(ctx: dict, **kw: Any) -> str: - from shared.sx.helpers import render_to_sx_with_env, _ctx_to_env, root_header_sx + from shared.sx.helpers import render_to_sx_with_env, root_header_sx page_post = ctx.get("page_post") - env = _ctx_to_env(ctx, oob=True) + env = {} return await render_to_sx_with_env("cart-page-layout-oob", env, root_header_oob=SxExpr(await root_header_sx(ctx, oob=True)), cart_row_oob=SxExpr(await _cart_header_sx(ctx, oob=True)), @@ -122,10 +119,10 @@ async def _cart_page_oob(ctx: dict, **kw: Any) -> str: async def _cart_admin_full(ctx: dict, **kw: Any) -> str: - from shared.sx.helpers import render_to_sx_with_env, _ctx_to_env + from shared.sx.helpers import render_to_sx_with_env page_post = ctx.get("page_post") selected = kw.get("selected", "") - env = _ctx_to_env(ctx) + env = {} return await render_to_sx_with_env("cart-admin-layout-full", env, post_header=SxExpr(await _post_header_sx(ctx, page_post)), admin_header=SxExpr(await _cart_page_admin_header_sx(ctx, page_post, selected=selected)), diff --git a/cart/sxc/pages/renders.py b/cart/sxc/pages/renders.py index f53027e..125d53c 100644 --- a/cart/sxc/pages/renders.py +++ b/cart/sxc/pages/renders.py @@ -7,7 +7,7 @@ from .utils import _serialize_order, _serialize_calendar_entry async def render_orders_page(ctx, orders, page, total_pages, search, search_count, url_for_fn, qs_fn): - from shared.sx.helpers import render_to_sx, render_to_sx_with_env, _ctx_to_env, search_desktop_sx, search_mobile_sx, full_page_sx + from shared.sx.helpers import render_to_sx, render_to_sx_with_env, search_desktop_sx, search_mobile_sx, full_page_sx from shared.utils import route_prefix ctx["search"] = search ctx["search_count"] = search_count @@ -17,7 +17,7 @@ async def render_orders_page(ctx, orders, page, total_pages, search, search_coun order_dicts = [_serialize_order(o) for o in orders] content = await render_to_sx("orders-list-content", orders=order_dicts, page=page, total_pages=total_pages, rows_url=list_url, detail_url_prefix=detail_url_prefix) - header_rows = await render_to_sx_with_env("cart-orders-layout-full", _ctx_to_env(ctx), + header_rows = await render_to_sx_with_env("cart-orders-layout-full", {}, list_url=list_url, ) filt = await render_to_sx("order-list-header", search_mobile=SxExpr(await search_mobile_sx(ctx))) @@ -49,7 +49,7 @@ async def render_orders_rows(ctx, orders, page, total_pages, url_for_fn, qs_fn): async def render_orders_oob(ctx, orders, page, total_pages, search, search_count, url_for_fn, qs_fn): - from shared.sx.helpers import render_to_sx, render_to_sx_with_env, _ctx_to_env, search_desktop_sx, search_mobile_sx, oob_page_sx + from shared.sx.helpers import render_to_sx, render_to_sx_with_env, search_desktop_sx, search_mobile_sx, oob_page_sx from shared.utils import route_prefix ctx["search"] = search ctx["search_count"] = search_count @@ -59,7 +59,7 @@ async def render_orders_oob(ctx, orders, page, total_pages, search, search_count order_dicts = [_serialize_order(o) for o in orders] content = await render_to_sx("orders-list-content", orders=order_dicts, page=page, total_pages=total_pages, rows_url=list_url, detail_url_prefix=detail_url_prefix) - oobs = await render_to_sx_with_env("cart-orders-layout-oob", _ctx_to_env(ctx, oob=True), + oobs = await render_to_sx_with_env("cart-orders-layout-oob", {}, list_url=list_url, ) filt = await render_to_sx("order-list-header", search_mobile=SxExpr(await search_mobile_sx(ctx))) @@ -67,7 +67,7 @@ async def render_orders_oob(ctx, orders, page, total_pages, search, search_count async def render_order_page(ctx, order, calendar_entries, url_for_fn): - from shared.sx.helpers import render_to_sx, render_to_sx_with_env, _ctx_to_env, full_page_sx + from shared.sx.helpers import render_to_sx, render_to_sx_with_env, full_page_sx from shared.utils import route_prefix from shared.browser.app.csrf import generate_csrf_token pfx = route_prefix() @@ -80,7 +80,7 @@ async def render_order_page(ctx, order, calendar_entries, url_for_fn): main = await render_to_sx("order-detail-content", order=order_data, calendar_entries=cal_data) filt = await render_to_sx("order-detail-filter-content", order=order_data, list_url=list_url, recheck_url=recheck_url, pay_url=pay_url, csrf=generate_csrf_token()) - header_rows = await render_to_sx_with_env("cart-order-detail-layout-full", _ctx_to_env(ctx), + header_rows = await render_to_sx_with_env("cart-order-detail-layout-full", {}, list_url=list_url, detail_url=detail_url, order_label=f"Order {order.id}", ) @@ -88,7 +88,7 @@ async def render_order_page(ctx, order, calendar_entries, url_for_fn): async def render_order_oob(ctx, order, calendar_entries, url_for_fn): - from shared.sx.helpers import render_to_sx, render_to_sx_with_env, _ctx_to_env, oob_page_sx + from shared.sx.helpers import render_to_sx, render_to_sx_with_env, oob_page_sx from shared.utils import route_prefix from shared.browser.app.csrf import generate_csrf_token pfx = route_prefix() @@ -101,7 +101,7 @@ async def render_order_oob(ctx, order, calendar_entries, url_for_fn): main = await render_to_sx("order-detail-content", order=order_data, calendar_entries=cal_data) filt = await render_to_sx("order-detail-filter-content", order=order_data, list_url=list_url, recheck_url=recheck_url, pay_url=pay_url, csrf=generate_csrf_token()) - oobs = await render_to_sx_with_env("cart-order-detail-layout-oob", _ctx_to_env(ctx, oob=True), + oobs = await render_to_sx_with_env("cart-order-detail-layout-oob", {}, detail_url=detail_url, order_label=f"Order {order.id}", ) @@ -109,11 +109,11 @@ async def render_order_oob(ctx, order, calendar_entries, url_for_fn): async def render_checkout_error_page(ctx, error=None, order=None): - from shared.sx.helpers import render_to_sx, render_to_sx_with_env, _ctx_to_env, full_page_sx + from shared.sx.helpers import render_to_sx, render_to_sx_with_env, full_page_sx from shared.infrastructure.urls import cart_url err_msg = error or "Unexpected error while creating the hosted checkout session." order_sx = await render_to_sx("checkout-error-order-id", oid=f"#{order.id}") if order else None - hdr = await render_to_sx_with_env("layout-root-full", _ctx_to_env(ctx)) + hdr = await render_to_sx_with_env("layout-root-full", {}) filt = await render_to_sx("checkout-error-header") content = await render_to_sx("checkout-error-content", msg=err_msg, order=SxExpr(order_sx) if order_sx else None, back_url=cart_url("/")) diff --git a/events/sx/header.sx b/events/sx/header.sx index 1aec230..0f62026 100644 --- a/events/sx/header.sx +++ b/events/sx/header.sx @@ -24,3 +24,10 @@ (div :id (str "entry-title-" entry-id) :class "flex gap-1 items-center" title times)) +(defcomp ~events-slot-label (&key name description) + (div :class "flex flex-col md:flex-row md:gap-2 items-center" + (div :class "flex flex-row items-center gap-2" + (i :class "fa fa-clock") + (div :class "shrink-0" name)) + (p :class "text-stone-500 whitespace-pre-line break-all w-full" description))) + diff --git a/events/sx/layouts.sx b/events/sx/layouts.sx index 2ba7825..81ff1a8 100644 --- a/events/sx/layouts.sx +++ b/events/sx/layouts.sx @@ -1,13 +1,11 @@ -;; Events layout defcomps — root header from env free variables, +;; Events layout defcomps — root header via ~root-header-auto, ;; events-specific headers passed as &key params. ;; --- Calendar admin layout: root + post + child(admin + cal + cal-admin) --- (defcomp ~events-cal-admin-layout-full (&key post-header admin-header calendar-header calendar-admin-header) - (<> (~root-header :cart-mini cart-mini :blog-url blog-url :site-title site-title - :app-label app-label :nav-tree nav-tree :auth-menu auth-menu - :nav-panel nav-panel :settings-url settings-url :is-admin is-admin) + (<> (~root-header-auto) post-header (~header-child-sx :inner (<> admin-header calendar-header calendar-admin-header)))) @@ -23,9 +21,7 @@ (defcomp ~events-slot-layout-full (&key post-header admin-header calendar-header calendar-admin-header slot-header) - (<> (~root-header :cart-mini cart-mini :blog-url blog-url :site-title site-title - :app-label app-label :nav-tree nav-tree :auth-menu auth-menu - :nav-panel nav-panel :settings-url settings-url :is-admin is-admin) + (<> (~root-header-auto) post-header (~header-child-sx :inner (<> admin-header calendar-header calendar-admin-header slot-header)))) @@ -36,9 +32,7 @@ (defcomp ~events-day-admin-layout-full (&key post-header admin-header calendar-header day-header day-admin-header) - (<> (~root-header :cart-mini cart-mini :blog-url blog-url :site-title site-title - :app-label app-label :nav-tree nav-tree :auth-menu auth-menu - :nav-panel nav-panel :settings-url settings-url :is-admin is-admin) + (<> (~root-header-auto) post-header (~header-child-sx :inner (<> admin-header calendar-header day-header day-admin-header)))) @@ -48,9 +42,7 @@ ;; --- Entry layout: root + child(post + cal + day + entry) --- (defcomp ~events-entry-layout-full (&key post-header calendar-header day-header entry-header) - (<> (~root-header :cart-mini cart-mini :blog-url blog-url :site-title site-title - :app-label app-label :nav-tree nav-tree :auth-menu auth-menu - :nav-panel nav-panel :settings-url settings-url :is-admin is-admin) + (<> (~root-header-auto) (~header-child-sx :inner (<> post-header calendar-header day-header entry-header)))) (defcomp ~events-entry-layout-oob (&key day-oob entry-oob-wrap clear-oob) @@ -61,9 +53,7 @@ (defcomp ~events-entry-admin-layout-full (&key post-header admin-header calendar-header day-header entry-header entry-admin-header) - (<> (~root-header :cart-mini cart-mini :blog-url blog-url :site-title site-title - :app-label app-label :nav-tree nav-tree :auth-menu auth-menu - :nav-panel nav-panel :settings-url settings-url :is-admin is-admin) + (<> (~root-header-auto) post-header (~header-child-sx :inner (<> admin-header calendar-header day-header entry-header entry-admin-header)))) @@ -76,9 +66,7 @@ (defcomp ~events-ticket-types-layout-full (&key post-header calendar-header day-header entry-header entry-admin-header ticket-types-header) - (<> (~root-header :cart-mini cart-mini :blog-url blog-url :site-title site-title - :app-label app-label :nav-tree nav-tree :auth-menu auth-menu - :nav-panel nav-panel :settings-url settings-url :is-admin is-admin) + (<> (~root-header-auto) (~header-child-sx :inner (<> post-header calendar-header day-header entry-header entry-admin-header ticket-types-header)))) @@ -90,9 +78,7 @@ (defcomp ~events-ticket-type-layout-full (&key post-header calendar-header day-header entry-header entry-admin-header ticket-types-header ticket-type-header) - (<> (~root-header :cart-mini cart-mini :blog-url blog-url :site-title site-title - :app-label app-label :nav-tree nav-tree :auth-menu auth-menu - :nav-panel nav-panel :settings-url settings-url :is-admin is-admin) + (<> (~root-header-auto) (~header-child-sx :inner (<> post-header calendar-header day-header entry-header entry-admin-header ticket-types-header ticket-type-header)))) @@ -103,9 +89,7 @@ ;; --- Markets layout: root + child(post + markets) --- (defcomp ~events-markets-layout-full (&key post-header markets-header) - (<> (~root-header :cart-mini cart-mini :blog-url blog-url :site-title site-title - :app-label app-label :nav-tree nav-tree :auth-menu auth-menu - :nav-panel nav-panel :settings-url settings-url :is-admin is-admin) + (<> (~root-header-auto) (~header-child-sx :inner (<> post-header markets-header)))) (defcomp ~events-markets-layout-oob (&key post-oob markets-oob-wrap) diff --git a/events/sxc/pages/calendar.py b/events/sxc/pages/calendar.py index ab22d5f..28bb587 100644 --- a/events/sxc/pages/calendar.py +++ b/events/sxc/pages/calendar.py @@ -2,7 +2,7 @@ from __future__ import annotations from shared.sx.helpers import ( - call_url, render_to_sx, render_to_sx_with_env, _ctx_to_env, + call_url, render_to_sx, render_to_sx_with_env, post_admin_header_sx, ) from shared.sx.parser import SxExpr diff --git a/events/sxc/pages/entries.py b/events/sxc/pages/entries.py index 4516acb..5c9bdec 100644 --- a/events/sxc/pages/entries.py +++ b/events/sxc/pages/entries.py @@ -3,7 +3,7 @@ from __future__ import annotations from markupsafe import escape -from shared.sx.helpers import render_to_sx, render_to_sx_with_env, _ctx_to_env +from shared.sx.helpers import render_to_sx, render_to_sx_with_env from shared.sx.parser import SxExpr from .utils import ( diff --git a/events/sxc/pages/helpers.py b/events/sxc/pages/helpers.py index 91be5d1..8604e05 100644 --- a/events/sxc/pages/helpers.py +++ b/events/sxc/pages/helpers.py @@ -228,11 +228,11 @@ def _register_events_layouts() -> None: # --- Calendar admin layout (root + post + child(post-admin + calendar + cal-admin)) --- async def _cal_admin_full(ctx: dict, **kw: Any) -> str: - from shared.sx.helpers import render_to_sx_with_env, _ctx_to_env, post_admin_header_sx + from shared.sx.helpers import render_to_sx_with_env, post_admin_header_sx from shared.sx.parser import SxExpr ctx = await _ensure_container_nav(ctx) slug = (ctx.get("post") or {}).get("slug", "") - return await render_to_sx_with_env("events-cal-admin-layout-full", _ctx_to_env(ctx), + return await render_to_sx_with_env("events-cal-admin-layout-full", {}, post_header=SxExpr(await _post_header_sx(ctx)), admin_header=SxExpr(await post_admin_header_sx(ctx, slug, selected="calendars")), calendar_header=SxExpr(await _calendar_header_sx(ctx)), @@ -241,11 +241,11 @@ async def _cal_admin_full(ctx: dict, **kw: Any) -> str: async def _cal_admin_oob(ctx: dict, **kw: Any) -> str: - from shared.sx.helpers import render_to_sx_with_env, _ctx_to_env, post_admin_header_sx, oob_header_sx + from shared.sx.helpers import render_to_sx_with_env, post_admin_header_sx, oob_header_sx from shared.sx.parser import SxExpr ctx = await _ensure_container_nav(ctx) slug = (ctx.get("post") or {}).get("slug", "") - return await render_to_sx_with_env("events-cal-admin-layout-oob", _ctx_to_env(ctx, oob=True), + return await render_to_sx_with_env("events-cal-admin-layout-oob", {}, admin_oob=SxExpr(await post_admin_header_sx(ctx, slug, oob=True, selected="calendars")), cal_oob=SxExpr(await _calendar_header_sx(ctx, oob=True)), cal_admin_oob_wrap=SxExpr(await oob_header_sx("calendar-header-child", @@ -264,11 +264,11 @@ async def _slots_full(ctx: dict, **kw: Any) -> str: async def _slots_oob(ctx: dict, **kw: Any) -> str: - from shared.sx.helpers import render_to_sx_with_env, _ctx_to_env, post_admin_header_sx + from shared.sx.helpers import render_to_sx_with_env, post_admin_header_sx from shared.sx.parser import SxExpr ctx = await _ensure_container_nav({**ctx, "is_admin_section": True}) slug = (ctx.get("post") or {}).get("slug", "") - return await render_to_sx_with_env("events-slots-layout-oob", _ctx_to_env(ctx, oob=True), + return await render_to_sx_with_env("events-slots-layout-oob", {}, admin_oob=SxExpr(await post_admin_header_sx(ctx, slug, oob=True, selected="calendars")), cal_admin_oob=SxExpr(await _calendar_admin_header_sx(ctx, oob=True)), clear_oob=SxExpr(_clear_deeper_oob("post-row", "post-header-child", @@ -281,11 +281,11 @@ async def _slots_oob(ctx: dict, **kw: Any) -> str: # --- Slot detail layout (extends cal-admin with slot header) --- async def _slot_full(ctx: dict, **kw: Any) -> str: - from shared.sx.helpers import render_to_sx_with_env, _ctx_to_env, post_admin_header_sx + from shared.sx.helpers import render_to_sx_with_env, post_admin_header_sx from shared.sx.parser import SxExpr ctx = await _ensure_container_nav({**ctx, "is_admin_section": True}) slug = (ctx.get("post") or {}).get("slug", "") - return await render_to_sx_with_env("events-slot-layout-full", _ctx_to_env(ctx), + return await render_to_sx_with_env("events-slot-layout-full", {}, post_header=SxExpr(await _post_header_sx(ctx)), admin_header=SxExpr(await post_admin_header_sx(ctx, slug, selected="calendars")), calendar_header=SxExpr(await _calendar_header_sx(ctx)), @@ -295,11 +295,11 @@ async def _slot_full(ctx: dict, **kw: Any) -> str: async def _slot_oob(ctx: dict, **kw: Any) -> str: - from shared.sx.helpers import render_to_sx_with_env, _ctx_to_env, post_admin_header_sx, oob_header_sx + from shared.sx.helpers import render_to_sx_with_env, post_admin_header_sx, oob_header_sx from shared.sx.parser import SxExpr ctx = await _ensure_container_nav({**ctx, "is_admin_section": True}) slug = (ctx.get("post") or {}).get("slug", "") - return await render_to_sx_with_env("events-slot-layout-oob", _ctx_to_env(ctx, oob=True), + return await render_to_sx_with_env("events-slot-layout-oob", {}, admin_oob=SxExpr(await post_admin_header_sx(ctx, slug, oob=True, selected="calendars")), cal_admin_oob=SxExpr(await _calendar_admin_header_sx(ctx, oob=True)), slot_oob_wrap=SxExpr(await oob_header_sx("calendar-admin-header-child", @@ -315,11 +315,11 @@ async def _slot_oob(ctx: dict, **kw: Any) -> str: # --- Day admin layout (root + post + post-admin + child(cal + day + day-admin)) --- async def _day_admin_full(ctx: dict, **kw: Any) -> str: - from shared.sx.helpers import render_to_sx_with_env, _ctx_to_env, post_admin_header_sx + from shared.sx.helpers import render_to_sx_with_env, post_admin_header_sx from shared.sx.parser import SxExpr ctx = await _ensure_container_nav(ctx) slug = (ctx.get("post") or {}).get("slug", "") - return await render_to_sx_with_env("events-day-admin-layout-full", _ctx_to_env(ctx), + return await render_to_sx_with_env("events-day-admin-layout-full", {}, post_header=SxExpr(await _post_header_sx(ctx)), admin_header=SxExpr(await post_admin_header_sx(ctx, slug, selected="calendars")), calendar_header=SxExpr(await _calendar_header_sx(ctx)), @@ -329,11 +329,11 @@ async def _day_admin_full(ctx: dict, **kw: Any) -> str: async def _day_admin_oob(ctx: dict, **kw: Any) -> str: - from shared.sx.helpers import render_to_sx_with_env, _ctx_to_env, post_admin_header_sx, oob_header_sx + from shared.sx.helpers import render_to_sx_with_env, post_admin_header_sx, oob_header_sx from shared.sx.parser import SxExpr ctx = await _ensure_container_nav(ctx) slug = (ctx.get("post") or {}).get("slug", "") - return await render_to_sx_with_env("events-day-admin-layout-oob", _ctx_to_env(ctx, oob=True), + return await render_to_sx_with_env("events-day-admin-layout-oob", {}, admin_oob=SxExpr(await post_admin_header_sx(ctx, slug, oob=True, selected="calendars")), cal_oob=SxExpr(await _calendar_header_sx(ctx, oob=True)), day_admin_oob_wrap=SxExpr(await oob_header_sx("day-header-child", @@ -349,9 +349,9 @@ async def _day_admin_oob(ctx: dict, **kw: Any) -> str: # --- Entry layout (root + child(post + cal + day + entry), + menu) --- async def _entry_full(ctx: dict, **kw: Any) -> str: - from shared.sx.helpers import render_to_sx_with_env, _ctx_to_env + from shared.sx.helpers import render_to_sx_with_env from shared.sx.parser import SxExpr - return await render_to_sx_with_env("events-entry-layout-full", _ctx_to_env(ctx), + return await render_to_sx_with_env("events-entry-layout-full", {}, post_header=SxExpr(await _post_header_sx(ctx)), calendar_header=SxExpr(await _calendar_header_sx(ctx)), day_header=SxExpr(await _day_header_sx(ctx)), @@ -360,9 +360,9 @@ async def _entry_full(ctx: dict, **kw: Any) -> str: async def _entry_oob(ctx: dict, **kw: Any) -> str: - from shared.sx.helpers import render_to_sx_with_env, _ctx_to_env, oob_header_sx + from shared.sx.helpers import render_to_sx_with_env, oob_header_sx from shared.sx.parser import SxExpr - return await render_to_sx_with_env("events-entry-layout-oob", _ctx_to_env(ctx, oob=True), + return await render_to_sx_with_env("events-entry-layout-oob", {}, day_oob=SxExpr(await _day_header_sx(ctx, oob=True)), entry_oob_wrap=SxExpr(await oob_header_sx("day-header-child", "entry-header-child", await _entry_header_html(ctx))), @@ -376,11 +376,11 @@ async def _entry_oob(ctx: dict, **kw: Any) -> str: # --- Entry admin layout (root + post + child(post-admin + cal + day + entry + entry-admin), + menu) --- async def _entry_admin_full(ctx: dict, **kw: Any) -> str: - from shared.sx.helpers import render_to_sx_with_env, _ctx_to_env, post_admin_header_sx + from shared.sx.helpers import render_to_sx_with_env, post_admin_header_sx from shared.sx.parser import SxExpr ctx = await _ensure_container_nav(ctx) slug = (ctx.get("post") or {}).get("slug", "") - return await render_to_sx_with_env("events-entry-admin-layout-full", _ctx_to_env(ctx), + return await render_to_sx_with_env("events-entry-admin-layout-full", {}, post_header=SxExpr(await _post_header_sx(ctx)), admin_header=SxExpr(await post_admin_header_sx(ctx, slug, selected="calendars")), calendar_header=SxExpr(await _calendar_header_sx(ctx)), @@ -391,11 +391,11 @@ async def _entry_admin_full(ctx: dict, **kw: Any) -> str: async def _entry_admin_oob(ctx: dict, **kw: Any) -> str: - from shared.sx.helpers import render_to_sx_with_env, _ctx_to_env, post_admin_header_sx, oob_header_sx + from shared.sx.helpers import render_to_sx_with_env, post_admin_header_sx, oob_header_sx from shared.sx.parser import SxExpr ctx = await _ensure_container_nav(ctx) slug = (ctx.get("post") or {}).get("slug", "") - return await render_to_sx_with_env("events-entry-admin-layout-oob", _ctx_to_env(ctx, oob=True), + return await render_to_sx_with_env("events-entry-admin-layout-oob", {}, admin_oob=SxExpr(await post_admin_header_sx(ctx, slug, oob=True, selected="calendars")), entry_oob=SxExpr(await _entry_header_html(ctx, oob=True)), entry_admin_oob_wrap=SxExpr(await oob_header_sx("entry-header-child", @@ -412,9 +412,9 @@ async def _entry_admin_oob(ctx: dict, **kw: Any) -> str: # --- Ticket types layout (extends entry admin with ticket-types header, + menu) --- async def _ticket_types_full(ctx: dict, **kw: Any) -> str: - from shared.sx.helpers import render_to_sx_with_env, _ctx_to_env + from shared.sx.helpers import render_to_sx_with_env from shared.sx.parser import SxExpr - return await render_to_sx_with_env("events-ticket-types-layout-full", _ctx_to_env(ctx), + return await render_to_sx_with_env("events-ticket-types-layout-full", {}, post_header=SxExpr(await _post_header_sx(ctx)), calendar_header=SxExpr(await _calendar_header_sx(ctx)), day_header=SxExpr(await _day_header_sx(ctx)), @@ -425,9 +425,9 @@ async def _ticket_types_full(ctx: dict, **kw: Any) -> str: async def _ticket_types_oob(ctx: dict, **kw: Any) -> str: - from shared.sx.helpers import render_to_sx_with_env, _ctx_to_env, oob_header_sx + from shared.sx.helpers import render_to_sx_with_env, oob_header_sx from shared.sx.parser import SxExpr - return await render_to_sx_with_env("events-ticket-types-layout-oob", _ctx_to_env(ctx, oob=True), + return await render_to_sx_with_env("events-ticket-types-layout-oob", {}, entry_admin_oob=SxExpr(await _entry_admin_header_html(ctx, oob=True)), ticket_types_oob_wrap=SxExpr(await oob_header_sx("entry-admin-header-child", "ticket_types-header-child", await _ticket_types_header_html(ctx))), @@ -437,9 +437,9 @@ async def _ticket_types_oob(ctx: dict, **kw: Any) -> str: # --- Ticket type detail layout (extends ticket types with ticket-type header, + menu) --- async def _ticket_type_full(ctx: dict, **kw: Any) -> str: - from shared.sx.helpers import render_to_sx_with_env, _ctx_to_env + from shared.sx.helpers import render_to_sx_with_env from shared.sx.parser import SxExpr - return await render_to_sx_with_env("events-ticket-type-layout-full", _ctx_to_env(ctx), + return await render_to_sx_with_env("events-ticket-type-layout-full", {}, post_header=SxExpr(await _post_header_sx(ctx)), calendar_header=SxExpr(await _calendar_header_sx(ctx)), day_header=SxExpr(await _day_header_sx(ctx)), @@ -451,9 +451,9 @@ async def _ticket_type_full(ctx: dict, **kw: Any) -> str: async def _ticket_type_oob(ctx: dict, **kw: Any) -> str: - from shared.sx.helpers import render_to_sx_with_env, _ctx_to_env, oob_header_sx + from shared.sx.helpers import render_to_sx_with_env, oob_header_sx from shared.sx.parser import SxExpr - return await render_to_sx_with_env("events-ticket-type-layout-oob", _ctx_to_env(ctx, oob=True), + return await render_to_sx_with_env("events-ticket-type-layout-oob", {}, ticket_types_oob=SxExpr(await _ticket_types_header_html(ctx, oob=True)), ticket_type_oob_wrap=SxExpr(await oob_header_sx("ticket_types-header-child", "ticket_type-header-child", await _ticket_type_header_html(ctx))), @@ -463,18 +463,18 @@ async def _ticket_type_oob(ctx: dict, **kw: Any) -> str: # --- Markets layout (root + child(post + markets)) --- async def _markets_full(ctx: dict, **kw: Any) -> str: - from shared.sx.helpers import render_to_sx_with_env, _ctx_to_env + from shared.sx.helpers import render_to_sx_with_env from shared.sx.parser import SxExpr - return await render_to_sx_with_env("events-markets-layout-full", _ctx_to_env(ctx), + return await render_to_sx_with_env("events-markets-layout-full", {}, post_header=SxExpr(await _post_header_sx(ctx)), markets_header=SxExpr(await _markets_header_sx(ctx)), ) async def _markets_oob(ctx: dict, **kw: Any) -> str: - from shared.sx.helpers import render_to_sx_with_env, _ctx_to_env, oob_header_sx + from shared.sx.helpers import render_to_sx_with_env, oob_header_sx from shared.sx.parser import SxExpr - return await render_to_sx_with_env("events-markets-layout-oob", _ctx_to_env(ctx, oob=True), + return await render_to_sx_with_env("events-markets-layout-oob", {}, post_oob=SxExpr(await _post_header_sx(ctx, oob=True)), markets_oob_wrap=SxExpr(await oob_header_sx("post-header-child", "markets-header-child", await _markets_header_sx(ctx))), diff --git a/events/sxc/pages/layouts.py b/events/sxc/pages/layouts.py index 3532431..82717ab 100644 --- a/events/sxc/pages/layouts.py +++ b/events/sxc/pages/layouts.py @@ -35,11 +35,11 @@ def _register_events_layouts() -> None: # --- Calendar admin layout (root + post + child(post-admin + calendar + cal-admin)) --- async def _cal_admin_full(ctx: dict, **kw: Any) -> str: - from shared.sx.helpers import render_to_sx_with_env, _ctx_to_env, post_admin_header_sx + from shared.sx.helpers import render_to_sx_with_env, post_admin_header_sx from shared.sx.parser import SxExpr ctx = await _ensure_container_nav(ctx) slug = (ctx.get("post") or {}).get("slug", "") - return await render_to_sx_with_env("events-cal-admin-layout-full", _ctx_to_env(ctx), + return await render_to_sx_with_env("events-cal-admin-layout-full", {}, post_header=SxExpr(await _post_header_sx(ctx)), admin_header=SxExpr(await post_admin_header_sx(ctx, slug, selected="calendars")), calendar_header=SxExpr(await _calendar_header_sx(ctx)), @@ -48,11 +48,11 @@ async def _cal_admin_full(ctx: dict, **kw: Any) -> str: async def _cal_admin_oob(ctx: dict, **kw: Any) -> str: - from shared.sx.helpers import render_to_sx_with_env, _ctx_to_env, post_admin_header_sx, oob_header_sx + from shared.sx.helpers import render_to_sx_with_env, post_admin_header_sx, oob_header_sx from shared.sx.parser import SxExpr ctx = await _ensure_container_nav(ctx) slug = (ctx.get("post") or {}).get("slug", "") - return await render_to_sx_with_env("events-cal-admin-layout-oob", _ctx_to_env(ctx, oob=True), + return await render_to_sx_with_env("events-cal-admin-layout-oob", {}, admin_oob=SxExpr(await post_admin_header_sx(ctx, slug, oob=True, selected="calendars")), cal_oob=SxExpr(await _calendar_header_sx(ctx, oob=True)), cal_admin_oob_wrap=SxExpr(await oob_header_sx("calendar-header-child", @@ -71,11 +71,11 @@ async def _slots_full(ctx: dict, **kw: Any) -> str: async def _slots_oob(ctx: dict, **kw: Any) -> str: - from shared.sx.helpers import render_to_sx_with_env, _ctx_to_env, post_admin_header_sx + from shared.sx.helpers import render_to_sx_with_env, post_admin_header_sx from shared.sx.parser import SxExpr ctx = await _ensure_container_nav({**ctx, "is_admin_section": True}) slug = (ctx.get("post") or {}).get("slug", "") - return await render_to_sx_with_env("events-slots-layout-oob", _ctx_to_env(ctx, oob=True), + return await render_to_sx_with_env("events-slots-layout-oob", {}, admin_oob=SxExpr(await post_admin_header_sx(ctx, slug, oob=True, selected="calendars")), cal_admin_oob=SxExpr(await _calendar_admin_header_sx(ctx, oob=True)), clear_oob=SxExpr(_clear_deeper_oob("post-row", "post-header-child", @@ -88,11 +88,11 @@ async def _slots_oob(ctx: dict, **kw: Any) -> str: # --- Slot detail layout (extends cal-admin with slot header) --- async def _slot_full(ctx: dict, **kw: Any) -> str: - from shared.sx.helpers import render_to_sx_with_env, _ctx_to_env, post_admin_header_sx + from shared.sx.helpers import render_to_sx_with_env, post_admin_header_sx from shared.sx.parser import SxExpr ctx = await _ensure_container_nav({**ctx, "is_admin_section": True}) slug = (ctx.get("post") or {}).get("slug", "") - return await render_to_sx_with_env("events-slot-layout-full", _ctx_to_env(ctx), + return await render_to_sx_with_env("events-slot-layout-full", {}, post_header=SxExpr(await _post_header_sx(ctx)), admin_header=SxExpr(await post_admin_header_sx(ctx, slug, selected="calendars")), calendar_header=SxExpr(await _calendar_header_sx(ctx)), @@ -102,11 +102,11 @@ async def _slot_full(ctx: dict, **kw: Any) -> str: async def _slot_oob(ctx: dict, **kw: Any) -> str: - from shared.sx.helpers import render_to_sx_with_env, _ctx_to_env, post_admin_header_sx, oob_header_sx + from shared.sx.helpers import render_to_sx_with_env, post_admin_header_sx, oob_header_sx from shared.sx.parser import SxExpr ctx = await _ensure_container_nav({**ctx, "is_admin_section": True}) slug = (ctx.get("post") or {}).get("slug", "") - return await render_to_sx_with_env("events-slot-layout-oob", _ctx_to_env(ctx, oob=True), + return await render_to_sx_with_env("events-slot-layout-oob", {}, admin_oob=SxExpr(await post_admin_header_sx(ctx, slug, oob=True, selected="calendars")), cal_admin_oob=SxExpr(await _calendar_admin_header_sx(ctx, oob=True)), slot_oob_wrap=SxExpr(await oob_header_sx("calendar-admin-header-child", @@ -122,11 +122,11 @@ async def _slot_oob(ctx: dict, **kw: Any) -> str: # --- Day admin layout (root + post + post-admin + child(cal + day + day-admin)) --- async def _day_admin_full(ctx: dict, **kw: Any) -> str: - from shared.sx.helpers import render_to_sx_with_env, _ctx_to_env, post_admin_header_sx + from shared.sx.helpers import render_to_sx_with_env, post_admin_header_sx from shared.sx.parser import SxExpr ctx = await _ensure_container_nav(ctx) slug = (ctx.get("post") or {}).get("slug", "") - return await render_to_sx_with_env("events-day-admin-layout-full", _ctx_to_env(ctx), + return await render_to_sx_with_env("events-day-admin-layout-full", {}, post_header=SxExpr(await _post_header_sx(ctx)), admin_header=SxExpr(await post_admin_header_sx(ctx, slug, selected="calendars")), calendar_header=SxExpr(await _calendar_header_sx(ctx)), @@ -136,11 +136,11 @@ async def _day_admin_full(ctx: dict, **kw: Any) -> str: async def _day_admin_oob(ctx: dict, **kw: Any) -> str: - from shared.sx.helpers import render_to_sx_with_env, _ctx_to_env, post_admin_header_sx, oob_header_sx + from shared.sx.helpers import render_to_sx_with_env, post_admin_header_sx, oob_header_sx from shared.sx.parser import SxExpr ctx = await _ensure_container_nav(ctx) slug = (ctx.get("post") or {}).get("slug", "") - return await render_to_sx_with_env("events-day-admin-layout-oob", _ctx_to_env(ctx, oob=True), + return await render_to_sx_with_env("events-day-admin-layout-oob", {}, admin_oob=SxExpr(await post_admin_header_sx(ctx, slug, oob=True, selected="calendars")), cal_oob=SxExpr(await _calendar_header_sx(ctx, oob=True)), day_admin_oob_wrap=SxExpr(await oob_header_sx("day-header-child", @@ -156,9 +156,9 @@ async def _day_admin_oob(ctx: dict, **kw: Any) -> str: # --- Entry layout (root + child(post + cal + day + entry), + menu) --- async def _entry_full(ctx: dict, **kw: Any) -> str: - from shared.sx.helpers import render_to_sx_with_env, _ctx_to_env + from shared.sx.helpers import render_to_sx_with_env from shared.sx.parser import SxExpr - return await render_to_sx_with_env("events-entry-layout-full", _ctx_to_env(ctx), + return await render_to_sx_with_env("events-entry-layout-full", {}, post_header=SxExpr(await _post_header_sx(ctx)), calendar_header=SxExpr(await _calendar_header_sx(ctx)), day_header=SxExpr(await _day_header_sx(ctx)), @@ -167,9 +167,9 @@ async def _entry_full(ctx: dict, **kw: Any) -> str: async def _entry_oob(ctx: dict, **kw: Any) -> str: - from shared.sx.helpers import render_to_sx_with_env, _ctx_to_env, oob_header_sx + from shared.sx.helpers import render_to_sx_with_env, oob_header_sx from shared.sx.parser import SxExpr - return await render_to_sx_with_env("events-entry-layout-oob", _ctx_to_env(ctx, oob=True), + return await render_to_sx_with_env("events-entry-layout-oob", {}, day_oob=SxExpr(await _day_header_sx(ctx, oob=True)), entry_oob_wrap=SxExpr(await oob_header_sx("day-header-child", "entry-header-child", await _entry_header_html(ctx))), @@ -183,11 +183,11 @@ async def _entry_oob(ctx: dict, **kw: Any) -> str: # --- Entry admin layout (root + post + child(post-admin + cal + day + entry + entry-admin), + menu) --- async def _entry_admin_full(ctx: dict, **kw: Any) -> str: - from shared.sx.helpers import render_to_sx_with_env, _ctx_to_env, post_admin_header_sx + from shared.sx.helpers import render_to_sx_with_env, post_admin_header_sx from shared.sx.parser import SxExpr ctx = await _ensure_container_nav(ctx) slug = (ctx.get("post") or {}).get("slug", "") - return await render_to_sx_with_env("events-entry-admin-layout-full", _ctx_to_env(ctx), + return await render_to_sx_with_env("events-entry-admin-layout-full", {}, post_header=SxExpr(await _post_header_sx(ctx)), admin_header=SxExpr(await post_admin_header_sx(ctx, slug, selected="calendars")), calendar_header=SxExpr(await _calendar_header_sx(ctx)), @@ -198,11 +198,11 @@ async def _entry_admin_full(ctx: dict, **kw: Any) -> str: async def _entry_admin_oob(ctx: dict, **kw: Any) -> str: - from shared.sx.helpers import render_to_sx_with_env, _ctx_to_env, post_admin_header_sx, oob_header_sx + from shared.sx.helpers import render_to_sx_with_env, post_admin_header_sx, oob_header_sx from shared.sx.parser import SxExpr ctx = await _ensure_container_nav(ctx) slug = (ctx.get("post") or {}).get("slug", "") - return await render_to_sx_with_env("events-entry-admin-layout-oob", _ctx_to_env(ctx, oob=True), + return await render_to_sx_with_env("events-entry-admin-layout-oob", {}, admin_oob=SxExpr(await post_admin_header_sx(ctx, slug, oob=True, selected="calendars")), entry_oob=SxExpr(await _entry_header_html(ctx, oob=True)), entry_admin_oob_wrap=SxExpr(await oob_header_sx("entry-header-child", @@ -219,9 +219,9 @@ async def _entry_admin_oob(ctx: dict, **kw: Any) -> str: # --- Ticket types layout (extends entry admin with ticket-types header, + menu) --- async def _ticket_types_full(ctx: dict, **kw: Any) -> str: - from shared.sx.helpers import render_to_sx_with_env, _ctx_to_env + from shared.sx.helpers import render_to_sx_with_env from shared.sx.parser import SxExpr - return await render_to_sx_with_env("events-ticket-types-layout-full", _ctx_to_env(ctx), + return await render_to_sx_with_env("events-ticket-types-layout-full", {}, post_header=SxExpr(await _post_header_sx(ctx)), calendar_header=SxExpr(await _calendar_header_sx(ctx)), day_header=SxExpr(await _day_header_sx(ctx)), @@ -232,9 +232,9 @@ async def _ticket_types_full(ctx: dict, **kw: Any) -> str: async def _ticket_types_oob(ctx: dict, **kw: Any) -> str: - from shared.sx.helpers import render_to_sx_with_env, _ctx_to_env, oob_header_sx + from shared.sx.helpers import render_to_sx_with_env, oob_header_sx from shared.sx.parser import SxExpr - return await render_to_sx_with_env("events-ticket-types-layout-oob", _ctx_to_env(ctx, oob=True), + return await render_to_sx_with_env("events-ticket-types-layout-oob", {}, entry_admin_oob=SxExpr(await _entry_admin_header_html(ctx, oob=True)), ticket_types_oob_wrap=SxExpr(await oob_header_sx("entry-admin-header-child", "ticket_types-header-child", await _ticket_types_header_html(ctx))), @@ -244,9 +244,9 @@ async def _ticket_types_oob(ctx: dict, **kw: Any) -> str: # --- Ticket type detail layout (extends ticket types with ticket-type header, + menu) --- async def _ticket_type_full(ctx: dict, **kw: Any) -> str: - from shared.sx.helpers import render_to_sx_with_env, _ctx_to_env + from shared.sx.helpers import render_to_sx_with_env from shared.sx.parser import SxExpr - return await render_to_sx_with_env("events-ticket-type-layout-full", _ctx_to_env(ctx), + return await render_to_sx_with_env("events-ticket-type-layout-full", {}, post_header=SxExpr(await _post_header_sx(ctx)), calendar_header=SxExpr(await _calendar_header_sx(ctx)), day_header=SxExpr(await _day_header_sx(ctx)), @@ -258,9 +258,9 @@ async def _ticket_type_full(ctx: dict, **kw: Any) -> str: async def _ticket_type_oob(ctx: dict, **kw: Any) -> str: - from shared.sx.helpers import render_to_sx_with_env, _ctx_to_env, oob_header_sx + from shared.sx.helpers import render_to_sx_with_env, oob_header_sx from shared.sx.parser import SxExpr - return await render_to_sx_with_env("events-ticket-type-layout-oob", _ctx_to_env(ctx, oob=True), + return await render_to_sx_with_env("events-ticket-type-layout-oob", {}, ticket_types_oob=SxExpr(await _ticket_types_header_html(ctx, oob=True)), ticket_type_oob_wrap=SxExpr(await oob_header_sx("ticket_types-header-child", "ticket_type-header-child", await _ticket_type_header_html(ctx))), @@ -270,18 +270,18 @@ async def _ticket_type_oob(ctx: dict, **kw: Any) -> str: # --- Markets layout (root + child(post + markets)) --- async def _markets_full(ctx: dict, **kw: Any) -> str: - from shared.sx.helpers import render_to_sx_with_env, _ctx_to_env + from shared.sx.helpers import render_to_sx_with_env from shared.sx.parser import SxExpr - return await render_to_sx_with_env("events-markets-layout-full", _ctx_to_env(ctx), + return await render_to_sx_with_env("events-markets-layout-full", {}, post_header=SxExpr(await _post_header_sx(ctx)), markets_header=SxExpr(await _markets_header_sx(ctx)), ) async def _markets_oob(ctx: dict, **kw: Any) -> str: - from shared.sx.helpers import render_to_sx_with_env, _ctx_to_env, oob_header_sx + from shared.sx.helpers import render_to_sx_with_env, oob_header_sx from shared.sx.parser import SxExpr - return await render_to_sx_with_env("events-markets-layout-oob", _ctx_to_env(ctx, oob=True), + return await render_to_sx_with_env("events-markets-layout-oob", {}, post_oob=SxExpr(await _post_header_sx(ctx, oob=True)), markets_oob_wrap=SxExpr(await oob_header_sx("post-header-child", "markets-header-child", await _markets_header_sx(ctx))), diff --git a/events/sxc/pages/renders.py b/events/sxc/pages/renders.py index 8dbd156..1684fc6 100644 --- a/events/sxc/pages/renders.py +++ b/events/sxc/pages/renders.py @@ -2,7 +2,7 @@ from __future__ import annotations from shared.sx.helpers import ( - render_to_sx_with_env, _ctx_to_env, + render_to_sx_with_env, post_admin_header_sx, oob_header_sx, header_child_sx, full_page_sx, oob_page_sx, ) @@ -44,7 +44,7 @@ async def render_all_events_page(ctx: dict, entries, has_more, pending_tickets, ctx, entries, has_more, pending_tickets, page_info, page, view, ticket_url, next_url, events_url, ) - hdr = await render_to_sx_with_env("layout-root-full", _ctx_to_env(ctx)) + hdr = await render_to_sx_with_env("layout-root-full", {}) return await full_page_sx(ctx, header_rows=hdr, content=content) @@ -105,7 +105,7 @@ async def render_page_summary_page(ctx: dict, entries, has_more, pending_tickets is_page_scoped=True, post=post, ) - hdr = await render_to_sx_with_env("layout-root-full", _ctx_to_env(ctx)) + hdr = await render_to_sx_with_env("layout-root-full", {}) hdr += await header_child_sx(await _post_header_sx(ctx)) return await full_page_sx(ctx, header_rows=hdr, content=content) @@ -160,7 +160,7 @@ async def render_calendars_page(ctx: dict) -> str: content = await _calendars_main_panel_sx(ctx) ctx = await _ensure_container_nav(ctx) slug = (ctx.get("post") or {}).get("slug", "") - root_hdr = await render_to_sx_with_env("layout-root-full", _ctx_to_env(ctx)) + root_hdr = await render_to_sx_with_env("layout-root-full", {}) post_hdr = await _post_header_sx(ctx) admin_hdr = await post_admin_header_sx(ctx, slug, selected="calendars") return await full_page_sx(ctx, header_rows=root_hdr + post_hdr + admin_hdr, content=content) @@ -184,7 +184,7 @@ async def render_calendars_oob(ctx: dict) -> str: async def render_calendar_page(ctx: dict) -> str: """Full page: calendar month view.""" content = await _calendar_main_panel_html(ctx) - hdr = await render_to_sx_with_env("layout-root-full", _ctx_to_env(ctx)) + hdr = await render_to_sx_with_env("layout-root-full", {}) child = await _post_header_sx(ctx) + await _calendar_header_sx(ctx) hdr += await header_child_sx(child) return await full_page_sx(ctx, header_rows=hdr, content=content) @@ -208,7 +208,7 @@ async def render_calendar_oob(ctx: dict) -> str: async def render_day_page(ctx: dict) -> str: """Full page: day detail.""" content = await _day_main_panel_html(ctx) - hdr = await render_to_sx_with_env("layout-root-full", _ctx_to_env(ctx)) + hdr = await render_to_sx_with_env("layout-root-full", {}) child = (await _post_header_sx(ctx) + await _calendar_header_sx(ctx) + await _day_header_sx(ctx)) hdr += await header_child_sx(child) diff --git a/events/sxc/pages/slots.py b/events/sxc/pages/slots.py index f9a2db4..b3ad565 100644 --- a/events/sxc/pages/slots.py +++ b/events/sxc/pages/slots.py @@ -1,7 +1,6 @@ """Slot panels, forms, edit/add, slot picker JS.""" from __future__ import annotations -from markupsafe import escape from shared.sx.helpers import render_to_sx from shared.sx.parser import SxExpr @@ -156,20 +155,12 @@ async def _slot_header_html(ctx: dict, *, oob: bool = False) -> str: if not slot: return "" - # Label: icon + name + description desc = getattr(slot, "description", "") or "" - label_inner = ( - f'
' - f'
' - f'' - f'
{escape(slot.name)}
' - f'
' - f'

{escape(desc)}

' - f'
' - ) + label_sx = await render_to_sx("events-slot-label", + name=slot.name, description=desc) return await render_to_sx("menu-row-sx", id="slot-row", level=5, - link_label_content=SxExpr(label_inner), + link_label_content=SxExpr(label_sx), child_id="slot-header-child", oob=oob) diff --git a/events/sxc/pages/utils.py b/events/sxc/pages/utils.py index 67c8271..286615b 100644 --- a/events/sxc/pages/utils.py +++ b/events/sxc/pages/utils.py @@ -17,7 +17,8 @@ from shared.sx.parser import SxExpr def _clear_oob(*ids: str) -> str: """Generate OOB swaps to remove orphaned header rows/children.""" - return "".join(f'(div :id "{i}" :hx-swap-oob "outerHTML")' for i in ids) + from shared.sx.helpers import sx_call + return "".join(sx_call("clear-oob-div", id=i) for i in ids) # All possible header row/child IDs at each depth (deepest first) diff --git a/federation/sx/layouts.sx b/federation/sx/layouts.sx index c2587da..03a3a0d 100644 --- a/federation/sx/layouts.sx +++ b/federation/sx/layouts.sx @@ -3,9 +3,7 @@ ;; Full page: root header + social header in header-child (defcomp ~social-layout-full () - (<> (~root-header :cart-mini cart-mini :blog-url blog-url :site-title site-title - :app-label app-label :nav-tree nav-tree :auth-menu auth-menu - :nav-panel nav-panel :settings-url settings-url :is-admin is-admin) + (<> (~root-header-auto) (~header-child-sx :inner (~federation-social-header :nav (~federation-social-nav :actor actor))))) @@ -16,7 +14,4 @@ :parent-id "root-header-child" :row (~federation-social-header :nav (~federation-social-nav :actor actor))) - (~root-header :cart-mini cart-mini :blog-url blog-url :site-title site-title - :app-label app-label :nav-tree nav-tree :auth-menu auth-menu - :nav-panel nav-panel :settings-url settings-url :is-admin is-admin - :oob true))) + (~root-header-auto true))) diff --git a/federation/sxc/pages/utils.py b/federation/sxc/pages/utils.py index 97b1f01..9e15547 100644 --- a/federation/sxc/pages/utils.py +++ b/federation/sxc/pages/utils.py @@ -25,11 +25,10 @@ def _serialize_remote_actor(a) -> dict: async def _social_page(ctx: dict, actor, *, content: str, title: str = "Rose Ash", meta_html: str = "") -> str: """Build a full social page with social header.""" - from shared.sx.helpers import render_to_sx_with_env, _ctx_to_env, full_page_sx + from shared.sx.helpers import render_to_sx_with_env, full_page_sx from markupsafe import escape - env = _ctx_to_env(ctx) - env["actor"] = _serialize_actor(actor) if actor else None + env = {"actor": _serialize_actor(actor) if actor else None} header_rows = await render_to_sx_with_env("social-layout-full", env) return await full_page_sx(ctx, header_rows=header_rows, content=content, meta_html=meta_html or f'{escape(title)}') @@ -58,14 +57,12 @@ def _actor_data(ctx: dict) -> dict | None: async def _social_full(ctx: dict, **kw: Any) -> str: - from shared.sx.helpers import render_to_sx_with_env, _ctx_to_env - env = _ctx_to_env(ctx) - env["actor"] = kw.get("actor") or _actor_data(ctx) + from shared.sx.helpers import render_to_sx_with_env + env = {"actor": kw.get("actor") or _actor_data(ctx)} return await render_to_sx_with_env("social-layout-full", env) async def _social_oob(ctx: dict, **kw: Any) -> str: - from shared.sx.helpers import render_to_sx_with_env, _ctx_to_env - env = _ctx_to_env(ctx, oob=True) - env["actor"] = kw.get("actor") or _actor_data(ctx) + from shared.sx.helpers import render_to_sx_with_env + env = {"actor": kw.get("actor") or _actor_data(ctx)} return await render_to_sx_with_env("social-layout-oob", env) diff --git a/market/sx/grids.sx b/market/sx/grids.sx index 4617af0..1546c18 100644 --- a/market/sx/grids.sx +++ b/market/sx/grids.sx @@ -1,11 +1,14 @@ ;; Market grid and layout components (defcomp ~market-markets-grid (&key cards) - (div :class "max-w-full px-3 py-3 grid grid-cols-1 sm:grid-cols-2 md:grid-cols-3 gap-4" cards)) + (<> (div :class "max-w-full px-3 py-3 grid grid-cols-1 sm:grid-cols-2 md:grid-cols-3 gap-4" cards) (div :class "pb-8"))) (defcomp ~market-product-grid (&key cards) (<> (div :class "grid grid-cols-1 sm:grid-cols-3 md:grid-cols-6 gap-3" cards) (div :class "pb-8"))) +(defcomp ~market-admin-content-wrap (&key inner) + (div :id "main-panel" inner)) + (defcomp ~market-like-toggle-button (&key colour action hx-headers label icon-cls) (button :class (str "flex items-center gap-1 " colour " hover:text-red-600 transition-colors w-[1em] h-[1em]") :sx-post action :sx-target "this" :sx-swap "outerHTML" :sx-push-url "false" diff --git a/market/sx/headers.sx b/market/sx/headers.sx index 181a6bd..b88c567 100644 --- a/market/sx/headers.sx +++ b/market/sx/headers.sx @@ -4,7 +4,7 @@ (div :class "font-bold text-xl flex-shrink-0 flex gap-2 items-center" (div (i :class "fa fa-shop") " " title) (div :class "flex flex-col md:flex-row md:gap-2 text-xs" - (div top-slug) sub-div))) + (div top-slug) (when sub-div (div sub-div))))) (defcomp ~market-product-label (&key title) (<> (i :class "fa fa-shopping-bag" :aria-hidden "true") (div title))) diff --git a/market/sx/layouts.sx b/market/sx/layouts.sx index 3fe0d5f..2893b51 100644 --- a/market/sx/layouts.sx +++ b/market/sx/layouts.sx @@ -1,12 +1,10 @@ -;; Market layout defcomps — root header from env free variables, +;; Market layout defcomps — root header via ~root-header-auto, ;; market-specific headers passed as &key params. ;; --- Browse layout: root + post header + market header --- (defcomp ~market-browse-layout-full (&key post-header market-header) - (<> (~root-header :cart-mini cart-mini :blog-url blog-url :site-title site-title - :app-label app-label :nav-tree nav-tree :auth-menu auth-menu - :nav-panel nav-panel :settings-url settings-url :is-admin is-admin) + (<> (~root-header-auto) (~header-child-sx :inner (<> post-header market-header)))) (defcomp ~market-browse-layout-oob (&key oob-header post-header-oob clear-oob) @@ -15,25 +13,19 @@ ;; --- Product layout: root + post + market + product --- (defcomp ~market-product-layout-full (&key post-header market-header product-header) - (<> (~root-header :cart-mini cart-mini :blog-url blog-url :site-title site-title - :app-label app-label :nav-tree nav-tree :auth-menu auth-menu - :nav-panel nav-panel :settings-url settings-url :is-admin is-admin) + (<> (~root-header-auto) (~header-child-sx :inner (<> post-header market-header product-header)))) ;; --- Product admin layout: root + post + market + product + admin --- (defcomp ~market-product-admin-layout-full (&key post-header market-header product-header admin-header) - (<> (~root-header :cart-mini cart-mini :blog-url blog-url :site-title site-title - :app-label app-label :nav-tree nav-tree :auth-menu auth-menu - :nav-panel nav-panel :settings-url settings-url :is-admin is-admin) + (<> (~root-header-auto) (~header-child-sx :inner (<> post-header market-header product-header admin-header)))) ;; --- Market admin layout: root + post + market + market-admin --- (defcomp ~market-admin-layout-full (&key post-header market-header admin-header) - (<> (~root-header :cart-mini cart-mini :blog-url blog-url :site-title site-title - :app-label app-label :nav-tree nav-tree :auth-menu auth-menu - :nav-panel nav-panel :settings-url settings-url :is-admin is-admin) + (<> (~root-header-auto) (~header-child-sx :inner (<> post-header market-header admin-header)))) (defcomp ~market-admin-layout-oob (&key market-header-oob admin-oob-header clear-oob) diff --git a/market/sxc/pages/helpers.py b/market/sxc/pages/helpers.py index d6183b4..8f826c3 100644 --- a/market/sxc/pages/helpers.py +++ b/market/sxc/pages/helpers.py @@ -122,8 +122,7 @@ async def _h_all_markets_content(**kw): next_url = prefix + url_for("all_markets.markets_fragment", page=page + 1) cards = await _market_cards_sx(markets, page_info, page, has_more, next_url) - content = await _markets_grid(cards) - return "(<> " + content + " " + '(div :class "pb-8")' + ")" + return await _markets_grid(cards) async def _h_page_markets_content(slug=None, **kw): @@ -146,15 +145,16 @@ async def _h_page_markets_content(slug=None, **kw): cards = await _market_cards_sx(markets, {}, page, has_more, next_url, show_page_badge=False, post_slug=post_slug) - content = await _markets_grid(cards) - return "(<> " + content + " " + '(div :class "pb-8")' + ")" + return await _markets_grid(cards) async def _h_page_admin_content(slug=None, **kw): from shared.sx.page import get_template_context + from shared.sx.helpers import render_to_sx + from shared.sx.parser import SxExpr ctx = await get_template_context() content = await _markets_admin_panel_sx(ctx) - return '(div :id "main-panel" ' + content + ')' + return await render_to_sx("market-admin-content-wrap", inner=SxExpr(content)) async def _h_market_home_content(page_slug=None, market_slug=None, **kw): diff --git a/market/sxc/pages/layouts.py b/market/sxc/pages/layouts.py index 0f0f563..60fddd4 100644 --- a/market/sxc/pages/layouts.py +++ b/market/sxc/pages/layouts.py @@ -3,7 +3,7 @@ from __future__ import annotations from typing import Any -from shared.sx.parser import serialize, SxExpr +from shared.sx.parser import SxExpr from shared.sx.helpers import ( render_to_sx, post_header_sx as _post_header_sx, @@ -28,11 +28,10 @@ async def _market_header_sx(ctx: dict, *, oob: bool = False) -> str: sub_slug = ctx.get("sub_slug", "") hx_select_search = ctx.get("hx_select_search", "#main-panel") - sub_div = f'(div {serialize(sub_slug)})' if sub_slug else "" label_sx = await render_to_sx( "market-shop-label", title=market_title, top_slug=top_slug or "", - sub_div=SxExpr(sub_div) if sub_div else None, + sub_div=sub_slug or None, ) link_href = url_for("defpage_market_home") @@ -294,8 +293,8 @@ def _register_market_layouts() -> None: async def _market_full(ctx: dict, **kw: Any) -> str: - from shared.sx.helpers import render_to_sx_with_env, _ctx_to_env - return await render_to_sx_with_env("market-browse-layout-full", _ctx_to_env(ctx), + from shared.sx.helpers import render_to_sx_with_env + return await render_to_sx_with_env("market-browse-layout-full", {}, post_header=SxExpr(await _post_header_sx(ctx)), market_header=SxExpr(await _market_header_sx(ctx))) @@ -315,9 +314,9 @@ async def _market_mobile(ctx: dict, **kw: Any) -> str: async def _market_admin_full(ctx: dict, **kw: Any) -> str: - from shared.sx.helpers import render_to_sx_with_env, _ctx_to_env + from shared.sx.helpers import render_to_sx_with_env selected = kw.get("selected", "") - return await render_to_sx_with_env("market-admin-layout-full", _ctx_to_env(ctx), + return await render_to_sx_with_env("market-admin-layout-full", {}, post_header=SxExpr(await _post_header_sx(ctx)), market_header=SxExpr(await _market_header_sx(ctx)), admin_header=SxExpr(await _market_admin_header_sx(ctx, selected=selected))) diff --git a/market/sxc/pages/renders.py b/market/sxc/pages/renders.py index 1178bd7..63e3067 100644 --- a/market/sxc/pages/renders.py +++ b/market/sxc/pages/renders.py @@ -35,8 +35,8 @@ async def render_browse_page(ctx: dict) -> str: cards = await _product_cards_sx(ctx) content = await _product_grid(cards) - from shared.sx.helpers import render_to_sx_with_env, _ctx_to_env - hdr = await render_to_sx_with_env("market-browse-layout-full", _ctx_to_env(ctx), + from shared.sx.helpers import render_to_sx_with_env + hdr = await render_to_sx_with_env("market-browse-layout-full", {}, post_header=SxExpr(await _post_header_sx(ctx)), market_header=SxExpr(await _market_header_sx(ctx))) menu = await _mobile_nav_panel_sx(ctx) @@ -81,8 +81,8 @@ async def render_product_page(ctx: dict, d: dict) -> str: content = await _product_detail_sx(d, ctx) meta = await _product_meta_sx(d, ctx) - from shared.sx.helpers import render_to_sx_with_env, _ctx_to_env - hdr = await render_to_sx_with_env("market-product-layout-full", _ctx_to_env(ctx), + from shared.sx.helpers import render_to_sx_with_env + hdr = await render_to_sx_with_env("market-product-layout-full", {}, post_header=SxExpr(await _post_header_sx(ctx)), market_header=SxExpr(await _market_header_sx(ctx)), product_header=SxExpr(await _product_header_sx(ctx, d))) @@ -112,8 +112,8 @@ async def render_product_admin_page(ctx: dict, d: dict) -> str: """Full page: product admin.""" content = await _product_detail_sx(d, ctx) - from shared.sx.helpers import render_to_sx_with_env, _ctx_to_env - hdr = await render_to_sx_with_env("market-product-admin-layout-full", _ctx_to_env(ctx), + from shared.sx.helpers import render_to_sx_with_env + hdr = await render_to_sx_with_env("market-product-admin-layout-full", {}, post_header=SxExpr(await _post_header_sx(ctx)), market_header=SxExpr(await _market_header_sx(ctx)), product_header=SxExpr(await _product_header_sx(ctx, d)), diff --git a/market/sxc/pages/utils.py b/market/sxc/pages/utils.py index 25caa65..6ccc33c 100644 --- a/market/sxc/pages/utils.py +++ b/market/sxc/pages/utils.py @@ -22,8 +22,9 @@ _MARKET_DEEP_IDS = [ def _clear_deeper_oob(*keep_ids: str) -> str: """Clear all market header rows/children NOT in keep_ids.""" + from shared.sx.helpers import sx_call to_clear = [i for i in _MARKET_DEEP_IDS if i not in keep_ids] - return " ".join(f'(div :id "{i}" :sx-swap-oob "outerHTML")' for i in to_clear) + return " ".join(sx_call("clear-oob-div", id=i) for i in to_clear) # --------------------------------------------------------------------------- diff --git a/orders/sx/layouts.sx b/orders/sx/layouts.sx index 16867d0..308487a 100644 --- a/orders/sx/layouts.sx +++ b/orders/sx/layouts.sx @@ -1,44 +1,30 @@ -;; Orders layout defcomps — read ctx values from env free variables. +;; Orders layout defcomps — fully self-contained via IO primitives. ;; Registered via register_sx_layout("orders", ...) in __init__.py. ;; --- orders layout: root + auth + orders rows --- (defcomp ~orders-layout-full (&key list-url) - (<> (~root-header :cart-mini cart-mini :blog-url blog-url :site-title site-title - :app-label app-label :nav-tree nav-tree :auth-menu auth-menu - :nav-panel nav-panel :settings-url settings-url :is-admin is-admin) + (<> (~root-header-auto) (~header-child-sx - :inner (<> (~auth-header-row :account-url account-url - :select-colours select-colours - :account-nav account-nav) + :inner (<> (~auth-header-row-auto) (~orders-header-row :list-url (or list-url "/")))))) (defcomp ~orders-layout-oob (&key list-url) - (<> (~auth-header-row :account-url account-url - :select-colours select-colours - :account-nav account-nav - :oob true) + (<> (~auth-header-row-auto true) (~oob-header-sx :parent-id "auth-header-child" :row (~orders-header-row :list-url (or list-url "/"))) - (~root-header :cart-mini cart-mini :blog-url blog-url :site-title site-title - :app-label app-label :nav-tree nav-tree :auth-menu auth-menu - :nav-panel nav-panel :settings-url settings-url :is-admin is-admin - :oob true))) + (~root-header-auto true))) (defcomp ~orders-layout-mobile () - (~root-mobile :nav-tree nav-tree :auth-menu auth-menu)) + (~root-mobile-auto)) ;; --- order-detail layout: root + auth + orders + order rows --- (defcomp ~order-detail-layout-full (&key list-url detail-url) - (<> (~root-header :cart-mini cart-mini :blog-url blog-url :site-title site-title - :app-label app-label :nav-tree nav-tree :auth-menu auth-menu - :nav-panel nav-panel :settings-url settings-url :is-admin is-admin) + (<> (~root-header-auto) (~order-detail-header-stack - :auth (~auth-header-row :account-url account-url - :select-colours select-colours - :account-nav account-nav) + :auth (~auth-header-row-auto) :orders (~orders-header-row :list-url (or list-url "/")) :order (~menu-row-sx :id "order-row" :level 3 :colour "sky" :link-href (or detail-url "/") :link-label "Order" @@ -50,10 +36,7 @@ :row (~menu-row-sx :id "order-row" :level 3 :colour "sky" :link-href (or detail-url "/") :link-label "Order" :icon "fa fa-gbp" :oob true)) - (~root-header :cart-mini cart-mini :blog-url blog-url :site-title site-title - :app-label app-label :nav-tree nav-tree :auth-menu auth-menu - :nav-panel nav-panel :settings-url settings-url :is-admin is-admin - :oob true))) + (~root-header-auto true))) (defcomp ~order-detail-layout-mobile () - (~root-mobile :nav-tree nav-tree :auth-menu auth-menu)) + (~root-mobile-auto)) diff --git a/shared/sx/helpers.py b/shared/sx/helpers.py index fb4dd73..820a0f0 100644 --- a/shared/sx/helpers.py +++ b/shared/sx/helpers.py @@ -16,34 +16,6 @@ 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) @@ -141,12 +113,8 @@ async def _post_nav_items_sx(ctx: dict) -> str: 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})' - ) + parts.append(await render_to_sx("container-nav-wrapper", + content=SxExpr(container_nav))) # Admin cog admin_nav = ctx.get("post_admin_nav") @@ -157,15 +125,9 @@ async def _post_nav_items_sx(ctx: dict) -> str: from quart import request admin_href = call_url(ctx, "blog_url", f"/{slug}/admin/") is_admin_page = ctx.get("is_admin_section") or "/admin" in request.path - 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")))' - ) + admin_nav = await render_to_sx("admin-cog-button", + href=admin_href, + is_admin_page=is_admin_page or None) if admin_nav: parts.append(admin_nav) return "(<> " + " ".join(parts) + ")" if parts else "" @@ -282,10 +244,8 @@ async def post_admin_header_sx(ctx: dict, slug: str, *, oob: bool = False, selected: str = "", admin_href: str = "") -> str: """Post admin header row as sx wire format.""" # Label - label_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) + ")" + label_sx = await render_to_sx("post-admin-label", + selected=str(escape(selected)) if selected else None) nav_sx = await _post_admin_nav_items_sx(ctx, slug, selected) or None @@ -385,44 +345,6 @@ def _build_component_ast(__name: str, **kwargs: Any) -> list: return ast -def _ctx_to_env(ctx: dict, *, oob: bool = False) -> dict: - """Convert template context dict → SX evaluation env dict. - - Applies ``_as_sx()`` to HTML fragments, ``call_url()`` to URL helpers, - extracts rights/admin flags. Returns kebab-case keys matching SX - symbol conventions so .sx defcomps can read them as free variables. - """ - rights = ctx.get("rights") or {} - is_admin = rights.get("admin") if isinstance(rights, dict) else getattr(rights, "admin", False) - env = { - # Root header values (match ~header-row-sx &key params) - "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": call_url(ctx, "blog_url", "/settings/") if is_admin else "", - "is-admin": is_admin, - "oob": oob, - # URL helpers (pre-resolved to strings) - "account-url": call_url(ctx, "account_url", ""), - "events-url": call_url(ctx, "events_url", ""), - "market-url": call_url(ctx, "market_url", ""), - "cart-url": call_url(ctx, "cart_url", ""), - # Common values - "select-colours": ctx.get("select_colours", ""), - "rights": rights, - # Fragments (used by various services) - "container-nav": _as_sx(ctx.get("container_nav")), - "account-nav": _as_sx(ctx.get("account_nav")), - # Post context - "post": ctx.get("post") or {}, - } - return env - - async def render_to_sx_with_env(__name: str, extra_env: dict, **kwargs: Any) -> str: """Like ``render_to_sx`` but merges *extra_env* into the evaluation environment before eval. Used by ``register_sx_layout`` so .sx @@ -584,8 +506,6 @@ def sx_response(source: str, status: int = 200, 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)) @@ -717,8 +637,6 @@ def sx_page(ctx: dict, page_sx: str, *, for val in _COMPONENT_ENV.values(): if isinstance(val, Component) and val.css_classes: classes.update(val.css_classes) - # Include pre-computed helper classes (menu bars, admin nav, etc.) - classes.update(HELPER_CSS_CLASSES) # Page sx is unique per request — scan it classes.update(scan_classes_from_sx(page_sx)) # Always include body classes diff --git a/shared/sx/layouts.py b/shared/sx/layouts.py index df69649..bfef411 100644 --- a/shared/sx/layouts.py +++ b/shared/sx/layouts.py @@ -146,30 +146,27 @@ def register_sx_layout(name: str, full_defcomp: str, oob_defcomp: str, mobile_defcomp: str | None = None) -> None: """Register a layout that delegates entirely to .sx defcomps. - The defcomps read ctx values as free variables from the evaluation - environment (populated by ``_ctx_to_env``). Python layouts become - one-liners:: + Layout defcomps use IO primitives (via auto-fetching macros) to + self-populate — no Python env injection needed. Any extra kwargs + from the caller are passed as kebab-case env entries:: register_sx_layout("account", "account-layout-full", "account-layout-oob", "account-layout-mobile") """ - from .helpers import render_to_sx_with_env, _ctx_to_env + from .helpers import render_to_sx_with_env async def full_fn(ctx: dict, **kw: Any) -> str: - env = _ctx_to_env(ctx) - env.update({k.replace("_", "-"): v for k, v in kw.items()}) + env = {k.replace("_", "-"): v for k, v in kw.items()} return await render_to_sx_with_env(full_defcomp, env) async def oob_fn(ctx: dict, **kw: Any) -> str: - env = _ctx_to_env(ctx, oob=True) - env.update({k.replace("_", "-"): v for k, v in kw.items()}) + env = {k.replace("_", "-"): v for k, v in kw.items()} return await render_to_sx_with_env(oob_defcomp, env) mobile_fn = None if mobile_defcomp: async def mobile_fn(ctx: dict, **kw: Any) -> str: - env = _ctx_to_env(ctx) - env.update({k.replace("_", "-"): v for k, v in kw.items()}) + env = {k.replace("_", "-"): v for k, v in kw.items()} return await render_to_sx_with_env(mobile_defcomp, env) register_layout(Layout(name, full_fn, oob_fn, mobile_fn)) diff --git a/shared/sx/primitives_io.py b/shared/sx/primitives_io.py index dc066f7..c7b0053 100644 --- a/shared/sx/primitives_io.py +++ b/shared/sx/primitives_io.py @@ -45,6 +45,10 @@ IO_PRIMITIVES: frozenset[str] = frozenset({ "abort", "url-for", "route-prefix", + "root-header-ctx", + "select-colours", + "account-nav-ctx", + "app-rights", }) @@ -378,6 +382,106 @@ async def _io_route_prefix( return route_prefix() +async def _io_root_header_ctx( + args: list[Any], kwargs: dict[str, Any], ctx: RequestContext +) -> dict[str, Any]: + """``(root-header-ctx)`` → dict with all root header values. + + Fetches cart-mini, auth-menu, nav-tree fragments and computes + settings-url / is-admin from rights. Result is cached on ``g`` + per request so multiple calls (e.g. header + mobile) are free. + """ + from quart import g, current_app, request + cached = getattr(g, "_root_header_ctx", None) + if cached is not None: + return cached + + from shared.infrastructure.fragments import fetch_fragments + from shared.infrastructure.cart_identity import current_cart_identity + from shared.infrastructure.urls import app_url + from shared.config import config + from .types import NIL + + user = getattr(g, "user", None) + ident = current_cart_identity() + + cart_params: dict[str, Any] = {} + if ident["user_id"] is not None: + cart_params["user_id"] = ident["user_id"] + if ident["session_id"] is not None: + cart_params["session_id"] = ident["session_id"] + + auth_params: dict[str, Any] = {} + if user and getattr(user, "email", None): + auth_params["email"] = user.email + + nav_params = {"app_name": current_app.name, "path": request.path} + + cart_mini, auth_menu, nav_tree = await fetch_fragments([ + ("cart", "cart-mini", cart_params or None), + ("account", "auth-menu", auth_params or None), + ("blog", "nav-tree", nav_params), + ]) + + rights = getattr(g, "rights", None) or {} + is_admin = ( + rights.get("admin", False) + if isinstance(rights, dict) + else getattr(rights, "admin", False) + ) + + result = { + "cart-mini": cart_mini or NIL, + "blog-url": app_url("blog", ""), + "site-title": config()["title"], + "app-label": current_app.name, + "nav-tree": nav_tree or NIL, + "auth-menu": auth_menu or NIL, + "nav-panel": NIL, + "settings-url": app_url("blog", "/settings/") if is_admin else "", + "is-admin": is_admin, + } + g._root_header_ctx = result + return result + + +async def _io_select_colours( + args: list[Any], kwargs: dict[str, Any], ctx: RequestContext +) -> str: + """``(select-colours)`` → the shared select/hover CSS class string.""" + from quart import current_app + return current_app.jinja_env.globals.get("select_colours", "") + + +async def _io_account_nav_ctx( + args: list[Any], kwargs: dict[str, Any], ctx: RequestContext +) -> Any: + """``(account-nav-ctx)`` → account nav fragments as SxExpr, or NIL. + + Reads ``g.account_nav`` (set by account service's before_request hook), + wrapping HTML strings in ``~rich-text`` for SX rendering. + """ + from quart import g + from .types import NIL + from .parser import SxExpr + val = getattr(g, "account_nav", None) + if not val: + return NIL + if isinstance(val, SxExpr): + return val + # HTML string → wrap for SX rendering + escaped = str(val).replace("\\", "\\\\").replace('"', '\\"') + return SxExpr(f'(~rich-text :html "{escaped}")') + + +async def _io_app_rights( + args: list[Any], kwargs: dict[str, Any], ctx: RequestContext +) -> dict[str, Any]: + """``(app-rights)`` → user rights dict from ``g.rights``.""" + from quart import g + return getattr(g, "rights", None) or {} + + _IO_HANDLERS: dict[str, Any] = { "frag": _io_frag, "query": _io_query, @@ -394,4 +498,8 @@ _IO_HANDLERS: dict[str, Any] = { "abort": _io_abort, "url-for": _io_url_for, "route-prefix": _io_route_prefix, + "root-header-ctx": _io_root_header_ctx, + "select-colours": _io_select_colours, + "account-nav-ctx": _io_account_nav_ctx, + "app-rights": _io_app_rights, } diff --git a/shared/sx/templates/auth.sx b/shared/sx/templates/auth.sx index 084a2b9..2f3ed2a 100644 --- a/shared/sx/templates/auth.sx +++ b/shared/sx/templates/auth.sx @@ -30,6 +30,27 @@ :link-label "account" :icon "fa-solid fa-user" :child-id "auth-header-child" :oob oob)) +;; Auto-fetching auth header — uses IO primitives, no free variables needed. +;; Expands inline (defmacro) so IO calls resolve in _aser mode. +(defmacro ~auth-header-row-auto (oob) + (quasiquote + (~auth-header-row :account-url (app-url "account" "") + :select-colours (select-colours) + :account-nav (account-nav-ctx) + :oob (unquote oob)))) + +(defmacro ~auth-header-row-simple-auto (oob) + (quasiquote + (~auth-header-row-simple :account-url (app-url "account" "") + :oob (unquote oob)))) + +;; Auto-fetching auth nav items — for mobile menus +(defmacro ~auth-nav-items-auto () + (quasiquote + (~auth-nav-items :account-url (app-url "account" "") + :select-colours (select-colours) + :account-nav (account-nav-ctx)))) + ;; Orders header row (defcomp ~orders-header-row (&key list-url) (~menu-row-sx :id "orders-row" :level 2 :colour "sky" diff --git a/shared/sx/templates/layout.sx b/shared/sx/templates/layout.sx index 0ab4679..d1b3c85 100644 --- a/shared/sx/templates/layout.sx +++ b/shared/sx/templates/layout.sx @@ -162,24 +162,46 @@ (defcomp ~root-mobile (&key nav-tree auth-menu) (~mobile-root-nav :nav-tree nav-tree :auth-menu auth-menu)) +;; --------------------------------------------------------------------------- +;; Auto-fetching header/mobile macros — use IO primitives to self-populate. +;; These expand inline so IO calls resolve in _aser mode within layout bodies. +;; Replaces the 10-parameter ~root-header boilerplate in layout defcomps. +;; --------------------------------------------------------------------------- + +(defmacro ~root-header-auto (oob) + (quasiquote + (let ((__rhctx (root-header-ctx))) + (~header-row-sx :cart-mini (get __rhctx "cart-mini") + :blog-url (get __rhctx "blog-url") + :site-title (get __rhctx "site-title") + :app-label (get __rhctx "app-label") + :nav-tree (get __rhctx "nav-tree") + :auth-menu (get __rhctx "auth-menu") + :nav-panel (get __rhctx "nav-panel") + :settings-url (get __rhctx "settings-url") + :is-admin (get __rhctx "is-admin") + :oob (unquote oob))))) + +(defmacro ~root-mobile-auto () + (quasiquote + (let ((__rhctx (root-header-ctx))) + (~mobile-root-nav :nav-tree (get __rhctx "nav-tree") + :auth-menu (get __rhctx "auth-menu"))))) + ;; --------------------------------------------------------------------------- ;; Built-in layout defcomps — used by register_sx_layout("root", ...) -;; Free variables (cart-mini, blog-url, etc.) come from _ctx_to_env(). +;; These use ~root-header-auto / ~root-mobile-auto macros (IO primitives). ;; --------------------------------------------------------------------------- (defcomp ~layout-root-full () - (~root-header :cart-mini cart-mini :blog-url blog-url :site-title site-title - :app-label app-label :nav-tree nav-tree :auth-menu auth-menu - :nav-panel nav-panel :settings-url settings-url :is-admin is-admin)) + (~root-header-auto)) (defcomp ~layout-root-oob () (~oob-header-sx :parent-id "root-header-child" - :row (~root-header :cart-mini cart-mini :blog-url blog-url :site-title site-title - :app-label app-label :nav-tree nav-tree :auth-menu auth-menu - :nav-panel nav-panel :settings-url settings-url :is-admin is-admin))) + :row (~root-header-auto true))) (defcomp ~layout-root-mobile () - (~root-mobile :nav-tree nav-tree :auth-menu auth-menu)) + (~root-mobile-auto)) (defcomp ~error-content (&key errnum message image) (div :class "text-center p-8 max-w-lg mx-auto" @@ -189,6 +211,33 @@ (div :class "flex justify-center" (img :src image :width "300" :height "300"))))) +(defcomp ~clear-oob-div (&key id) + (div :id id :sx-swap-oob "outerHTML")) + +;; --------------------------------------------------------------------------- +;; Shared nav helpers — used by post_header_sx / post_admin_header_sx +;; --------------------------------------------------------------------------- + +(defcomp ~container-nav-wrapper (&key content) + (div :id "entries-calendars-nav-wrapper" + :class "flex flex-col sm:flex-row sm:items-center gap-2 border-r border-stone-200 mr-2 sm:max-w-2xl" + content)) + +; @css justify-center cursor-pointer flex flex-row items-center gap-2 rounded bg-stone-200 text-black p-3 !bg-stone-500 !text-white +(defcomp ~admin-cog-button (&key href is-admin-page) + (div :class "relative nav-group" + (a :href href + :class (str "justify-center cursor-pointer flex flex-row items-center gap-2 rounded bg-stone-200 text-black p-3 " + (if is-admin-page "!bg-stone-500 !text-white" "")) + (i :class "fa fa-cog" :aria-hidden "true")))) + +(defcomp ~post-admin-label (&key selected) + (<> + (i :class "fa fa-shield-halved" :aria-hidden "true") + " admin" + (when selected + (span :class "text-white" selected)))) + (defcomp ~nav-link (&key href hx-select label icon aclass select-colours is-selected) (div :class "relative nav-group" (a :href href diff --git a/sx/sx/docs.sx b/sx/sx/docs.sx new file mode 100644 index 0000000..1747e19 --- /dev/null +++ b/sx/sx/docs.sx @@ -0,0 +1,65 @@ +;; SX docs utility components + +(defcomp ~doc-placeholder (&key id) + (div :id id + (div :class "bg-stone-50 border border-stone-200 rounded p-4 mt-3" + (p :class "text-stone-400 italic text-sm" + "Trigger the demo to see the actual content.")))) + +(defcomp ~doc-oob-code (&key target-id text) + (div :id target-id :sx-swap-oob "innerHTML" + (div :class "bg-stone-50 border border-stone-200 rounded p-4 mt-3 overflow-x-auto" + (pre :class "text-sm whitespace-pre-wrap" + (code text))))) + +(defcomp ~doc-attr-table (&key title rows) + (div :class "space-y-3" + (h3 :class "text-xl font-semibold text-stone-700" title) + (div :class "overflow-x-auto rounded border border-stone-200" + (table :class "w-full text-left text-sm" + (thead (tr :class "border-b border-stone-200 bg-stone-50" + (th :class "px-3 py-2 font-medium text-stone-600" "Attribute") + (th :class "px-3 py-2 font-medium text-stone-600" "Description") + (th :class "px-3 py-2 font-medium text-stone-600 text-center w-20" "In sx?"))) + (tbody rows))))) + +(defcomp ~doc-headers-table (&key title rows) + (div :class "space-y-3" + (h3 :class "text-xl font-semibold text-stone-700" title) + (div :class "overflow-x-auto rounded border border-stone-200" + (table :class "w-full text-left text-sm" + (thead (tr :class "border-b border-stone-200 bg-stone-50" + (th :class "px-3 py-2 font-medium text-stone-600" "Header") + (th :class "px-3 py-2 font-medium text-stone-600" "Value") + (th :class "px-3 py-2 font-medium text-stone-600" "Description"))) + (tbody rows))))) + +(defcomp ~doc-headers-row (&key name value description) + (tr :class "border-b border-stone-100" + (td :class "px-3 py-2 font-mono text-sm text-violet-700 whitespace-nowrap" name) + (td :class "px-3 py-2 font-mono text-sm text-stone-500" value) + (td :class "px-3 py-2 text-stone-700 text-sm" description))) + +(defcomp ~doc-two-col-row (&key name description) + (tr :class "border-b border-stone-100" + (td :class "px-3 py-2 font-mono text-sm text-violet-700 whitespace-nowrap" name) + (td :class "px-3 py-2 text-stone-700 text-sm" description))) + +(defcomp ~doc-two-col-table (&key title intro col1 col2 rows) + (div :class "space-y-3" + (when title (h3 :class "text-xl font-semibold text-stone-700" title)) + (when intro (p :class "text-stone-600 mb-6" intro)) + (div :class "overflow-x-auto rounded border border-stone-200" + (table :class "w-full text-left text-sm" + (thead (tr :class "border-b border-stone-200 bg-stone-50" + (th :class "px-3 py-2 font-medium text-stone-600" (or col1 "Name")) + (th :class "px-3 py-2 font-medium text-stone-600" (or col2 "Description")))) + (tbody rows))))) + +(defcomp ~sx-docs-label () + (span :class "font-mono" "()")) + +(defcomp ~doc-clear-cache-btn () + (button :onclick "localStorage.removeItem('sx-components-hash');localStorage.removeItem('sx-components-src');var e=Sx.getEnv();Object.keys(e).forEach(function(k){if(k.charAt(0)==='~')delete e[k]});var b=this;b.textContent='Cleared!';setTimeout(function(){b.textContent='Clear component cache'},2000)" + :class "text-xs text-stone-400 hover:text-stone-600 border border-stone-200 rounded px-2 py-1 transition-colors" + "Clear component cache")) diff --git a/sx/sx/layouts.sx b/sx/sx/layouts.sx index 0a32026..eeecb56 100644 --- a/sx/sx/layouts.sx +++ b/sx/sx/layouts.sx @@ -1,12 +1,10 @@ -;; SX docs layout defcomps — root header from env free variables, +;; SX docs layout defcomps — root header via ~root-header-auto, ;; sx-specific headers passed as &key params. ;; --- SX home layout: root + sx menu row --- (defcomp ~sx-layout-full (&key sx-row) - (<> (~root-header :cart-mini cart-mini :blog-url blog-url :site-title site-title - :app-label app-label :nav-tree nav-tree :auth-menu auth-menu - :nav-panel nav-panel :settings-url settings-url :is-admin is-admin) + (<> (~root-header-auto) sx-row)) (defcomp ~sx-layout-oob (&key root-header sx-row) @@ -15,7 +13,5 @@ ;; --- SX section layout: root + sx row (with child sub-row) --- (defcomp ~sx-section-layout-full (&key sx-row) - (<> (~root-header :cart-mini cart-mini :blog-url blog-url :site-title site-title - :app-label app-label :nav-tree nav-tree :auth-menu auth-menu - :nav-panel nav-panel :settings-url settings-url :is-admin is-admin) + (<> (~root-header-auto) sx-row)) diff --git a/sx/sxc/pages/essays.py b/sx/sxc/pages/essays.py index 1685783..eea1e3a 100644 --- a/sx/sxc/pages/essays.py +++ b/sx/sxc/pages/essays.py @@ -419,60 +419,46 @@ async def _reference_attrs_sx() -> str: ) -def _reference_headers_sx() -> str: +async def _reference_headers_sx() -> str: from content.pages import REQUEST_HEADERS, RESPONSE_HEADERS + req_table = await _headers_table_sx("Request Headers", REQUEST_HEADERS) + resp_table = await _headers_table_sx("Response Headers", RESPONSE_HEADERS) return ( f'(~doc-page :title "Headers"' f' (p :class "text-stone-600 mb-6"' f' "sx uses custom HTTP headers to coordinate between client and server.")' f' (div :class "space-y-8"' - f' {_headers_table_sx("Request Headers", REQUEST_HEADERS)}' - f' {_headers_table_sx("Response Headers", RESPONSE_HEADERS)}))' + f' {req_table}' + f' {resp_table}))' ) -def _reference_events_sx() -> str: +async def _reference_events_sx() -> str: + from shared.sx.helpers import render_to_sx + from shared.sx.parser import SxExpr from content.pages import EVENTS rows = [] for name, desc in EVENTS: - rows.append( - f'(tr :class "border-b border-stone-100"' - f' (td :class "px-3 py-2 font-mono text-sm text-violet-700 whitespace-nowrap" "{name}")' - f' (td :class "px-3 py-2 text-stone-700 text-sm" "{desc}"))' - ) - return ( - f'(~doc-page :title "Events"' - f' (p :class "text-stone-600 mb-6"' - f' "sx fires custom DOM events at various points in the request lifecycle.")' - f' (div :class "overflow-x-auto rounded border border-stone-200"' - f' (table :class "w-full text-left text-sm"' - f' (thead (tr :class "border-b border-stone-200 bg-stone-50"' - f' (th :class "px-3 py-2 font-medium text-stone-600" "Event")' - f' (th :class "px-3 py-2 font-medium text-stone-600" "Description")))' - f' (tbody {" ".join(rows)}))))' - ) + rows.append(await render_to_sx("doc-two-col-row", name=name, description=desc)) + rows_sx = "(<> " + " ".join(rows) + ")" + table = await render_to_sx("doc-two-col-table", + intro="sx fires custom DOM events at various points in the request lifecycle.", + col1="Event", col2="Description", rows=SxExpr(rows_sx)) + return f'(~doc-page :title "Events" {table})' -def _reference_js_api_sx() -> str: +async def _reference_js_api_sx() -> str: + from shared.sx.helpers import render_to_sx + from shared.sx.parser import SxExpr from content.pages import JS_API rows = [] for name, desc in JS_API: - rows.append( - f'(tr :class "border-b border-stone-100"' - f' (td :class "px-3 py-2 font-mono text-sm text-violet-700 whitespace-nowrap" "{name}")' - f' (td :class "px-3 py-2 text-stone-700 text-sm" "{desc}"))' - ) - return ( - f'(~doc-page :title "JavaScript API"' - f' (p :class "text-stone-600 mb-6"' - f' "The client-side sx.js library exposes a public API for programmatic use.")' - f' (div :class "overflow-x-auto rounded border border-stone-200"' - f' (table :class "w-full text-left text-sm"' - f' (thead (tr :class "border-b border-stone-200 bg-stone-50"' - f' (th :class "px-3 py-2 font-medium text-stone-600" "Method")' - f' (th :class "px-3 py-2 font-medium text-stone-600" "Description")))' - f' (tbody {" ".join(rows)}))))' - ) + rows.append(await render_to_sx("doc-two-col-row", name=name, description=desc)) + rows_sx = "(<> " + " ".join(rows) + ")" + table = await render_to_sx("doc-two-col-table", + intro="The client-side sx.js library exposes a public API for programmatic use.", + col1="Method", col2="Description", rows=SxExpr(rows_sx)) + return f'(~doc-page :title "JavaScript API" {table})' def _protocol_wire_format_sx() -> str: diff --git a/sx/sxc/pages/layouts.py b/sx/sxc/pages/layouts.py index 809fae0..2112dbe 100644 --- a/sx/sxc/pages/layouts.py +++ b/sx/sxc/pages/layouts.py @@ -15,30 +15,30 @@ def _register_sx_layouts() -> None: async def _sx_full_headers(ctx: dict, **kw: Any) -> str: """Full headers for sx home page: root + sx menu row.""" - from shared.sx.helpers import render_to_sx_with_env, _ctx_to_env + from shared.sx.helpers import render_to_sx_with_env from shared.sx.parser import SxExpr main_nav = await _main_nav_sx(kw.get("section")) sx_row = await _sx_header_sx(main_nav) - return await render_to_sx_with_env("sx-layout-full", _ctx_to_env(ctx), + return await render_to_sx_with_env("sx-layout-full", {}, sx_row=SxExpr(sx_row)) async def _sx_oob_headers(ctx: dict, **kw: Any) -> str: """OOB headers for sx home page.""" - from shared.sx.helpers import render_to_sx_with_env, _ctx_to_env, oob_header_sx + from shared.sx.helpers import render_to_sx_with_env, oob_header_sx from shared.sx.parser import SxExpr main_nav = await _main_nav_sx(kw.get("section")) sx_row = await _sx_header_sx(main_nav) - rows = await render_to_sx_with_env("sx-layout-full", _ctx_to_env(ctx), + rows = await render_to_sx_with_env("sx-layout-full", {}, sx_row=SxExpr(sx_row)) return await oob_header_sx("root-header-child", "sx-header-child", rows) async def _sx_section_full_headers(ctx: dict, **kw: Any) -> str: """Full headers for sx section pages: root + sx row + sub row.""" - from shared.sx.helpers import render_to_sx_with_env, _ctx_to_env + from shared.sx.helpers import render_to_sx_with_env from shared.sx.parser import SxExpr section = kw.get("section", "") @@ -50,13 +50,13 @@ async def _sx_section_full_headers(ctx: dict, **kw: Any) -> str: main_nav = await _main_nav_sx(section) sub_row = await _sub_row_sx(sub_label, sub_href, sub_nav, selected) sx_row = await _sx_header_sx(main_nav, child=sub_row) - return await render_to_sx_with_env("sx-section-layout-full", _ctx_to_env(ctx), + return await render_to_sx_with_env("sx-section-layout-full", {}, sx_row=SxExpr(sx_row)) async def _sx_section_oob_headers(ctx: dict, **kw: Any) -> str: """OOB headers for sx section pages.""" - from shared.sx.helpers import render_to_sx_with_env, _ctx_to_env, oob_header_sx + from shared.sx.helpers import render_to_sx_with_env, oob_header_sx from shared.sx.parser import SxExpr section = kw.get("section", "") @@ -68,7 +68,7 @@ async def _sx_section_oob_headers(ctx: dict, **kw: Any) -> str: main_nav = await _main_nav_sx(section) sub_row = await _sub_row_sx(sub_label, sub_href, sub_nav, selected) sx_row = await _sx_header_sx(main_nav, child=sub_row) - rows = await render_to_sx_with_env("sx-section-layout-full", _ctx_to_env(ctx), + rows = await render_to_sx_with_env("sx-section-layout-full", {}, sx_row=SxExpr(sx_row)) return await oob_header_sx("root-header-child", "sx-header-child", rows) diff --git a/sx/sxc/pages/renders.py b/sx/sxc/pages/renders.py index 0a5a77a..ada6cee 100644 --- a/sx/sxc/pages/renders.py +++ b/sx/sxc/pages/renders.py @@ -18,10 +18,8 @@ def _example_code(code: str, language: str = "lisp") -> str: def _placeholder(div_id: str) -> str: """Empty placeholder that will be filled by OOB swap on interaction.""" - return (f'(div :id "{div_id}"' - f' (div :class "bg-stone-50 border border-stone-200 rounded p-4 mt-3"' - f' (p :class "text-stone-400 italic text-sm"' - f' "Trigger the demo to see the actual content.")))') + from shared.sx.helpers import sx_call + return sx_call("doc-placeholder", id=div_id) def _component_source_text(*names: str) -> str: @@ -45,23 +43,14 @@ def _component_source_text(*names: str) -> str: def _oob_code(target_id: str, text: str) -> str: """OOB swap that displays plain code in a styled block.""" - escaped = text.replace('\\', '\\\\').replace('"', '\\"') - return (f'(div :id "{target_id}" :sx-swap-oob "innerHTML"' - f' (div :class "bg-stone-50 border border-stone-200 rounded p-4 mt-3 overflow-x-auto"' - f' (pre :class "text-sm whitespace-pre-wrap"' - f' (code "{escaped}"))))') + from shared.sx.helpers import sx_call + return sx_call("doc-oob-code", target_id=target_id, text=text) def _clear_components_btn() -> str: """Button that clears the client-side component cache (localStorage + in-memory).""" - js = ("localStorage.removeItem('sx-components-hash');" - "localStorage.removeItem('sx-components-src');" - "var e=Sx.getEnv();Object.keys(e).forEach(function(k){if(k.charAt(0)==='~')delete e[k]});" - "var b=this;b.textContent='Cleared!';setTimeout(function(){b.textContent='Clear component cache'},2000)") - return (f'(button :onclick "{js}"' - f' :class "text-xs text-stone-400 hover:text-stone-600 border border-stone-200' - f' rounded px-2 py-1 transition-colors"' - f' "Clear component cache")') + from shared.sx.helpers import sx_call + return sx_call("doc-clear-cache-btn") def _full_wire_text(sx_src: str, *comp_names: str) -> str: diff --git a/sx/sxc/pages/utils.py b/sx/sxc/pages/utils.py index 7853078..d11069e 100644 --- a/sx/sxc/pages/utils.py +++ b/sx/sxc/pages/utils.py @@ -36,40 +36,18 @@ async def _attr_table_sx(title: str, attrs: list[tuple[str, str, bool]]) -> str: rows.append(await render_to_sx("doc-attr-row", attr=attr, description=desc, exists="true" if exists else None, href=href)) - return ( - f'(div :class "space-y-3"' - f' (h3 :class "text-xl font-semibold text-stone-700" "{title}")' - f' (div :class "overflow-x-auto rounded border border-stone-200"' - f' (table :class "w-full text-left text-sm"' - f' (thead (tr :class "border-b border-stone-200 bg-stone-50"' - f' (th :class "px-3 py-2 font-medium text-stone-600" "Attribute")' - f' (th :class "px-3 py-2 font-medium text-stone-600" "Description")' - f' (th :class "px-3 py-2 font-medium text-stone-600 text-center w-20" "In sx?")))' - f' (tbody {" ".join(rows)}))))' - ) + rows_sx = "(<> " + " ".join(rows) + ")" + return await render_to_sx("doc-attr-table", title=title, rows=SxExpr(rows_sx)) -def _headers_table_sx(title: str, headers: list[tuple[str, str, str]]) -> str: +async def _headers_table_sx(title: str, headers: list[tuple[str, str, str]]) -> str: """Build a headers reference table.""" rows = [] for name, value, desc in headers: - rows.append( - f'(tr :class "border-b border-stone-100"' - f' (td :class "px-3 py-2 font-mono text-sm text-violet-700 whitespace-nowrap" "{name}")' - f' (td :class "px-3 py-2 font-mono text-sm text-stone-500" "{value}")' - f' (td :class "px-3 py-2 text-stone-700 text-sm" "{desc}"))' - ) - return ( - f'(div :class "space-y-3"' - f' (h3 :class "text-xl font-semibold text-stone-700" "{title}")' - f' (div :class "overflow-x-auto rounded border border-stone-200"' - f' (table :class "w-full text-left text-sm"' - f' (thead (tr :class "border-b border-stone-200 bg-stone-50"' - f' (th :class "px-3 py-2 font-medium text-stone-600" "Header")' - f' (th :class "px-3 py-2 font-medium text-stone-600" "Value")' - f' (th :class "px-3 py-2 font-medium text-stone-600" "Description")))' - f' (tbody {" ".join(rows)}))))' - ) + rows.append(await render_to_sx("doc-headers-row", + name=name, value=value, description=desc)) + rows_sx = "(<> " + " ".join(rows) + ")" + return await render_to_sx("doc-headers-table", title=title, rows=SxExpr(rows_sx)) async def _primitives_section_sx() -> str: @@ -86,10 +64,11 @@ async def _primitives_section_sx() -> str: async def _sx_header_sx(nav: str | None = None, *, child: str | None = None) -> str: """Build the sx docs menu-row.""" + label_sx = await render_to_sx("sx-docs-label") return await render_to_sx("menu-row-sx", id="sx-row", level=1, colour="violet", link_href="/", link_label="sx", - link_label_content=SxExpr('(span :class "font-mono" "()")'), + link_label_content=SxExpr(label_sx), nav=SxExpr(nav) if nav else None, child_id="sx-header-child", child=SxExpr(child) if child else None, diff --git a/test/sx/components.sx b/test/sx/components.sx index 9e9ac41..691d736 100644 --- a/test/sx/components.sx +++ b/test/sx/components.sx @@ -22,9 +22,7 @@ ;; Layout: full page header stack (reads root header values from env free variables) (defcomp ~test-layout-full (&key services active-service) - (<> (~root-header :cart-mini cart-mini :blog-url blog-url :site-title site-title - :app-label app-label :nav-tree nav-tree :auth-menu auth-menu - :nav-panel nav-panel :settings-url settings-url :is-admin is-admin) + (<> (~root-header-auto) (~header-child-sx :inner (~test-header-row :services services :active-service active-service)))) @@ -93,9 +91,7 @@ ;; Detail page header stack (reads root header values from env free variables) (defcomp ~test-detail-layout-full (&key services test-nodeid test-label) - (<> (~root-header :cart-mini cart-mini :blog-url blog-url :site-title site-title - :app-label app-label :nav-tree nav-tree :auth-menu auth-menu - :nav-panel nav-panel :settings-url settings-url :is-admin is-admin) + (<> (~root-header-auto) (~header-child-sx :inner (<> (~test-header-row :services services) (~header-child-sx :id "test-header-child" diff --git a/test/sxc/pages/renders.py b/test/sxc/pages/renders.py index c907963..7f46738 100644 --- a/test/sxc/pages/renders.py +++ b/test/sxc/pages/renders.py @@ -5,7 +5,7 @@ import os from datetime import datetime from shared.sx.jinja_bridge import load_service_components -from shared.sx.helpers import render_to_sx, SxExpr, render_to_sx_with_env, _ctx_to_env, full_page_sx +from shared.sx.helpers import render_to_sx, SxExpr, render_to_sx_with_env, full_page_sx # Load test-specific .sx components at import time load_service_components(os.path.dirname(os.path.dirname(os.path.dirname(__file__)))) @@ -99,7 +99,7 @@ async def render_dashboard_page_sx(ctx: dict, result: dict | None, inner = await render_to_sx("test-results-partial", summary_data=summary_data, sections=sections, has_failures=has_failures) content = await render_to_sx("test-results-wrap", running=running, inner=SxExpr(inner)) - hdr = await render_to_sx_with_env("test-layout-full", _ctx_to_env(ctx), + hdr = await render_to_sx_with_env("test-layout-full", {}, services=_service_list(), active_service=active_service, ) @@ -131,7 +131,7 @@ async def render_results_partial_sx(result: dict | None, running: bool, async def render_test_detail_page_sx(ctx: dict, test: dict) -> str: """Full page: test detail (sx wire format).""" - hdr = await render_to_sx_with_env("test-detail-layout-full", _ctx_to_env(ctx), + hdr = await render_to_sx_with_env("test-detail-layout-full", {}, services=_service_list(), test_nodeid=test["nodeid"], test_label=test["nodeid"].rsplit("::", 1)[-1], From 57e0d0c341e8f0de29b800c95a4aaa5b8b383373 Mon Sep 17 00:00:00 2001 From: giles Date: Wed, 4 Mar 2026 18:29:14 +0000 Subject: [PATCH 085/105] Fix defmacro expansion in _aser: check for macros before serializing ~components The ~component check in _aser immediately serialized all names starting with ~ as unexpanded component calls. This meant defmacro definitions like ~root-header-auto were sent to the client unexpanded, causing "Undefined symbol: root-header-ctx" errors since IO primitives only exist server-side. Now checks env for Macro instances first. Co-Authored-By: Claude Opus 4.6 --- shared/sx/async_eval.py | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/shared/sx/async_eval.py b/shared/sx/async_eval.py index 86856f6..9c25fbf 100644 --- a/shared/sx/async_eval.py +++ b/shared/sx/async_eval.py @@ -1127,8 +1127,12 @@ async def _aser(expr: Any, env: dict[str, Any], ctx: RequestContext) -> Any: if name.startswith("html:"): return await _aser_call(name[5:], expr[1:], env, ctx) - # Component call — serialize (don't expand) + # Component call — expand macros, serialize regular components if name.startswith("~"): + val = env.get(name) + if isinstance(val, Macro): + expanded = _expand_macro(val, expr[1:], env) + return await _aser(expanded, env, ctx) return await _aser_call(name, expr[1:], env, ctx) # Serialize-mode special/HO forms (checked BEFORE HTML_TAGS From 959e63d440cfc3c8eea8c8d33bee4e15660df20b Mon Sep 17 00:00:00 2001 From: giles Date: Wed, 4 Mar 2026 19:30:45 +0000 Subject: [PATCH 086/105] Remove render_to_sx from public API: enforce sx_call for all service code Replace ~250 render_to_sx calls across all services with sync sx_call, converting many async functions to sync where no other awaits remained. Make render_to_sx/render_to_sx_with_env private (_render_to_sx). Add (post-header-ctx) IO primitive and shared post/post-admin defmacros. Convert built-in post/post-admin layouts from Python to register_sx_layout with .sx defcomps. Remove dead post_admin_mobile_nav_sx. Co-Authored-By: Claude Opus 4.6 --- account/bp/account/routes.py | 4 +- account/bp/auth/routes.py | 4 +- blog/bp/blog/routes.py | 34 +-- blog/bp/menu_items/routes.py | 52 ++--- blog/bp/post/admin/routes.py | 42 ++-- blog/bp/post/routes.py | 16 +- blog/bp/snippets/routes.py | 4 +- blog/sxc/pages/helpers.py | 48 ++--- blog/sxc/pages/layouts.py | 62 +++--- blog/sxc/pages/renders.py | 16 +- cart/bp/orders/routes.py | 2 +- cart/bp/page_admin/routes.py | 2 +- cart/sxc/pages/layouts.py | 24 +-- cart/sxc/pages/renders.py | 50 ++--- events/bp/all_events/routes.py | 4 +- events/bp/calendar/admin/routes.py | 6 +- events/bp/calendar/routes.py | 6 +- events/bp/calendar_entries/routes.py | 6 +- events/bp/calendar_entry/routes.py | 26 +-- events/bp/calendars/routes.py | 4 +- events/bp/markets/routes.py | 4 +- events/bp/page/routes.py | 4 +- events/bp/slot/routes.py | 8 +- events/bp/slots/routes.py | 6 +- events/bp/ticket_admin/routes.py | 10 +- events/bp/ticket_type/routes.py | 8 +- events/bp/ticket_types/routes.py | 6 +- events/bp/tickets/routes.py | 4 +- events/sxc/pages/calendar.py | 150 ++++++------- events/sxc/pages/entries.py | 304 +++++++++++++-------------- events/sxc/pages/helpers.py | 119 ++++++----- events/sxc/pages/layouts.py | 86 ++++---- events/sxc/pages/renders.py | 68 +++--- events/sxc/pages/slots.py | 58 ++--- events/sxc/pages/tickets.py | 210 +++++++++--------- events/sxc/pages/utils.py | 30 +-- federation/bp/auth/routes.py | 4 +- federation/bp/identity/routes.py | 6 +- federation/bp/social/routes.py | 34 +-- market/bp/all_markets/routes.py | 2 +- market/bp/browse/routes.py | 6 +- market/bp/page_markets/routes.py | 2 +- market/bp/product/routes.py | 6 +- market/sxc/pages/cards.py | 56 ++--- market/sxc/pages/filters.py | 108 +++++----- market/sxc/pages/helpers.py | 36 ++-- market/sxc/pages/layouts.py | 92 ++++---- market/sxc/pages/renders.py | 86 ++++---- market/sxc/pages/utils.py | 95 +++++---- orders/bp/checkout/routes.py | 32 +-- orders/bp/order/routes.py | 10 +- orders/bp/orders/routes.py | 8 +- shared/browser/app/errors.py | 4 +- shared/sx/helpers.py | 64 +++--- shared/sx/layouts.py | 75 +------ shared/sx/primitives_io.py | 76 +++++++ shared/sx/templates/layout.sx | 129 ++++++++++++ sx/sxc/pages/essays.py | 34 +-- sx/sxc/pages/layouts.py | 34 +-- sx/sxc/pages/utils.py | 60 +++--- test/sxc/pages/renders.py | 14 +- 61 files changed, 1352 insertions(+), 1208 deletions(-) diff --git a/account/bp/account/routes.py b/account/bp/account/routes.py index ca0f3bb..9129c3c 100644 --- a/account/bp/account/routes.py +++ b/account/bp/account/routes.py @@ -13,7 +13,7 @@ from sqlalchemy import select from shared.models import UserNewsletter from shared.infrastructure.fragments import fetch_fragments -from shared.sx.helpers import sx_response, render_to_sx +from shared.sx.helpers import sx_response, sx_call def register(url_prefix="/"): @@ -66,7 +66,7 @@ def register(url_prefix="/"): translate = "translate-x-6" if un.subscribed else "translate-x-1" checked = "true" if un.subscribed else "false" - return sx_response(await render_to_sx( + return sx_response(sx_call( "account-newsletter-toggle", id=f"nl-{nid}", url=toggle_url, hdrs=f'{{"X-CSRFToken": "{csrf}"}}', diff --git a/account/bp/auth/routes.py b/account/bp/auth/routes.py index a3cfdea..29f6edb 100644 --- a/account/bp/auth/routes.py +++ b/account/bp/auth/routes.py @@ -47,11 +47,11 @@ ACCOUNT_SESSION_KEY = "account_sid" async def _render_auth_page(component: str, title: str, **kwargs) -> str: """Render an auth page with root layout — replaces sx_components helpers.""" - from shared.sx.helpers import render_to_sx, full_page_sx, root_header_sx + from shared.sx.helpers import sx_call, full_page_sx, root_header_sx from shared.sx.page import get_template_context ctx = await get_template_context() hdr = await root_header_sx(ctx) - content = await render_to_sx(component, **{k: v for k, v in kwargs.items() if v}) + content = sx_call(component, **{k: v for k, v in kwargs.items() if v}) return await full_page_sx(ctx, header_rows=hdr, content=content, meta_html=f"{title}") diff --git a/blog/bp/blog/routes.py b/blog/bp/blog/routes.py index a9a0ed1..4c43513 100644 --- a/blog/bp/blog/routes.py +++ b/blog/bp/blog/routes.py @@ -21,7 +21,7 @@ from .services.pages_data import pages_data from shared.browser.app.redis_cacher import cache_page, invalidate_tag_cache from shared.browser.app.utils.htmx import is_htmx_request from shared.browser.app.authz import require_admin -from shared.sx.helpers import sx_response, render_to_sx +from shared.sx.helpers import sx_response, sx_call from shared.utils import host_url def register(url_prefix, title): @@ -67,7 +67,7 @@ def register(url_prefix, title): from shared.sx.helpers import root_header_sx, full_page_sx from shared.sx.parser import SxExpr root_hdr = await root_header_sx(tctx) - blog_hdr = await render_to_sx("menu-row-sx", + blog_hdr = sx_call("menu-row-sx", id="blog-row", level=1, link_label_content=SxExpr("(div)"), child_id="blog-header-child") @@ -132,7 +132,7 @@ def register(url_prefix, title): from shared.sx.page import get_template_context from shared.sx.helpers import ( - render_to_sx, root_header_sx, full_page_sx, oob_page_sx, + sx_call, root_header_sx, full_page_sx, oob_page_sx, post_header_sx, oob_header_sx, mobile_menu_sx, post_mobile_nav_sx, mobile_root_nav_sx, ) @@ -143,11 +143,11 @@ def register(url_prefix, title): tctx.update(ctx) post = ctx.get("post", {}) - content = await render_to_sx("blog-home-main", + content = sx_call("blog-home-main", html_content=post.get("html", ""), sx_content=SxExpr(post.get("sx_content", "")) if post.get("sx_content") else None) meta_data = services.blog_page.post_meta_data(post, ctx.get("base_title", "")) - meta = await render_to_sx("blog-meta", **meta_data) + meta = sx_call("blog-meta", **meta_data) if not is_htmx_request(): root_hdr = await root_header_sx(tctx) @@ -171,12 +171,12 @@ def register(url_prefix, title): """Blog listing — moved from / to /index.""" from shared.services.registry import services from shared.sx.helpers import ( - render_to_sx, root_header_sx, full_page_sx, oob_page_sx, oob_header_sx, + sx_call, root_header_sx, full_page_sx, oob_page_sx, oob_header_sx, ) from shared.sx.parser import SxExpr - async def _blog_hdr(ctx, oob=False): - return await render_to_sx("menu-row-sx", + def _blog_hdr(ctx, oob=False): + return sx_call("menu-row-sx", id="blog-row", level=1, link_label_content=SxExpr("(div)"), child_id="blog-header-child", oob=oob) @@ -184,16 +184,16 @@ def register(url_prefix, title): data = await services.blog_page.index_data(g.s) # Render content, aside, and filter via .sx defcomps - content = await render_to_sx("blog-index-main-content", **data) - aside = await render_to_sx("blog-index-aside-content", **data) - filter_sx = await render_to_sx("blog-index-filter-content", **data) + content = sx_call("blog-index-main-content", **data) + aside = sx_call("blog-index-aside-content", **data) + filter_sx = sx_call("blog-index-filter-content", **data) from shared.sx.page import get_template_context tctx = await get_template_context() if not is_htmx_request(): root_hdr = await root_header_sx(tctx) - blog_hdr = await _blog_hdr(tctx) + blog_hdr = _blog_hdr(tctx) header_rows = "(<> " + root_hdr + " " + blog_hdr + ")" html = await full_page_sx(tctx, header_rows=header_rows, content=content, aside=aside, filter=filter_sx) @@ -203,7 +203,7 @@ def register(url_prefix, title): return sx_response(content) else: root_hdr = await root_header_sx(tctx) - blog_hdr = await _blog_hdr(tctx) + blog_hdr = _blog_hdr(tctx) rows = "(<> " + root_hdr + " " + blog_hdr + ")" header_oob = await oob_header_sx("root-header-child", "blog-header-child", rows) sx_src = await oob_page_sx(oobs=header_oob, content=content, @@ -231,7 +231,7 @@ def register(url_prefix, title): from shared.sx.page import get_template_context from sxc.pages.renders import render_editor_panel tctx = await get_template_context() - tctx["editor_html"] = await render_editor_panel(save_error="Invalid JSON in editor content.") + tctx["editor_html"] = render_editor_panel(save_error="Invalid JSON in editor content.") html = await _render_new_post_page(tctx) return await make_response(html, 400) @@ -240,7 +240,7 @@ def register(url_prefix, title): from shared.sx.page import get_template_context from sxc.pages.renders import render_editor_panel tctx = await get_template_context() - tctx["editor_html"] = await render_editor_panel(save_error=reason) + tctx["editor_html"] = render_editor_panel(save_error=reason) html = await _render_new_post_page(tctx) return await make_response(html, 400) @@ -287,7 +287,7 @@ def register(url_prefix, title): from shared.sx.page import get_template_context from sxc.pages.renders import render_editor_panel tctx = await get_template_context() - tctx["editor_html"] = await render_editor_panel(save_error="Invalid JSON in editor content.", is_page=True) + tctx["editor_html"] = render_editor_panel(save_error="Invalid JSON in editor content.", is_page=True) tctx["is_page"] = True html = await _render_new_post_page(tctx) return await make_response(html, 400) @@ -297,7 +297,7 @@ def register(url_prefix, title): from shared.sx.page import get_template_context from sxc.pages.renders import render_editor_panel tctx = await get_template_context() - tctx["editor_html"] = await render_editor_panel(save_error=reason, is_page=True) + tctx["editor_html"] = render_editor_panel(save_error=reason, is_page=True) tctx["is_page"] = True html = await _render_new_post_page(tctx) return await make_response(html, 400) diff --git a/blog/bp/menu_items/routes.py b/blog/bp/menu_items/routes.py index 1ffb89a..48c3117 100644 --- a/blog/bp/menu_items/routes.py +++ b/blog/bp/menu_items/routes.py @@ -13,12 +13,12 @@ from .services.menu_items import ( MenuItemError, ) from markupsafe import escape -from shared.sx.helpers import sx_response, render_to_sx +from shared.sx.helpers import sx_response, sx_call from shared.sx.parser import SxExpr from shared.browser.app.csrf import generate_csrf_token -async def _render_menu_items_list(menu_items): +def _render_menu_items_list(menu_items): """Serialize ORM menu items and render via .sx defcomp.""" csrf = generate_csrf_token() items = [] @@ -32,8 +32,8 @@ async def _render_menu_items_list(menu_items): "delete_url": url_for("menu_items.delete_menu_item_route", item_id=item.id), }) new_url = url_for("menu_items.new_menu_item") - return await render_to_sx("blog-menu-items-content", - menu_items=items, new_url=new_url, csrf=csrf) + return sx_call("blog-menu-items-content", + menu_items=items, new_url=new_url, csrf=csrf) def _render_menu_item_form(menu_item=None) -> str: @@ -120,16 +120,16 @@ document.addEventListener('click', function(e) {{ return html -async def _render_page_search_results(pages, query, page, has_more) -> str: +def _render_page_search_results(pages, query, page, has_more) -> str: """Render page search results.""" if not pages and query: - return await render_to_sx("page-search-empty", query=query) + return sx_call("page-search-empty", query=query) if not pages: return "" items = [] for post in pages: - items.append(await render_to_sx("page-search-item", + items.append(sx_call("page-search-item", id=post.id, title=post.title, slug=post.slug, feature_image=post.feature_image or None)) @@ -137,22 +137,22 @@ async def _render_page_search_results(pages, query, page, has_more) -> str: sentinel = "" if has_more: search_url = url_for("menu_items.search_pages_route") - sentinel = await render_to_sx("page-search-sentinel", + sentinel = sx_call("page-search-sentinel", url=search_url, query=query, next_page=page + 1) items_sx = "(<> " + " ".join(items) + ")" - return await render_to_sx("page-search-results", + return sx_call("page-search-results", items=SxExpr(items_sx), sentinel=SxExpr(sentinel) if sentinel else None) -async def _render_menu_items_nav_oob(menu_items) -> str: +def _render_menu_items_nav_oob(menu_items) -> str: """Render OOB nav update for menu items.""" from quart import request as qrequest if not menu_items: - return await render_to_sx("blog-nav-empty", wrapper_id="menu-items-nav-wrapper") + return sx_call("blog-nav-empty", wrapper_id="menu-items-nav-wrapper") first_seg = qrequest.path.strip("/").split("/")[0] if qrequest else "" @@ -185,23 +185,23 @@ async def _render_menu_items_nav_oob(menu_items) -> str: href = f"/{item_slug}/" selected = "true" if item_slug == first_seg else "false" - img_sx = await render_to_sx("img-or-placeholder", src=fi, alt=label, + img_sx = sx_call("img-or-placeholder", src=fi, alt=label, size_cls="w-8 h-8 rounded-full object-cover flex-shrink-0") if item_slug != "cart": - item_parts.append(await render_to_sx("blog-nav-item-link", + item_parts.append(sx_call("blog-nav-item-link", href=href, hx_get=f"/{item_slug}/", selected=selected, nav_cls=nav_button_cls, img=SxExpr(img_sx), label=label, )) else: - item_parts.append(await render_to_sx("blog-nav-item-plain", + item_parts.append(sx_call("blog-nav-item-plain", href=href, selected=selected, nav_cls=nav_button_cls, img=SxExpr(img_sx), label=label, )) items_sx = "(<> " + " ".join(item_parts) + ")" if item_parts else "" - return await render_to_sx("scroll-nav-wrapper", + return sx_call("scroll-nav-wrapper", wrapper_id="menu-items-nav-wrapper", container_id=container_id, arrow_cls=arrow_cls, left_hs=f"on click set #{container_id}.scrollLeft to #{container_id}.scrollLeft - 200", @@ -214,9 +214,9 @@ async def _render_menu_items_nav_oob(menu_items) -> str: def register(): bp = Blueprint("menu_items", __name__, url_prefix='/settings/menu_items') - async def get_menu_items_nav_oob_async(menu_items): + def get_menu_items_nav_oob_sync(menu_items): """Helper to generate OOB update for root nav menu items""" - return await _render_menu_items_nav_oob(menu_items) + return _render_menu_items_nav_oob(menu_items) @bp.get("/new/") @require_admin @@ -245,8 +245,8 @@ def register(): # Get updated list and nav OOB menu_items = await get_all_menu_items(g.s) - html = await _render_menu_items_list(menu_items) - nav_oob = await get_menu_items_nav_oob_async(menu_items) + html = _render_menu_items_list(menu_items) + nav_oob = get_menu_items_nav_oob_sync(menu_items) return sx_response(html + nav_oob) except MenuItemError as e: @@ -283,8 +283,8 @@ def register(): # Get updated list and nav OOB menu_items = await get_all_menu_items(g.s) - html = await _render_menu_items_list(menu_items) - nav_oob = await get_menu_items_nav_oob_async(menu_items) + html = _render_menu_items_list(menu_items) + nav_oob = get_menu_items_nav_oob_sync(menu_items) return sx_response(html + nav_oob) except MenuItemError as e: @@ -303,8 +303,8 @@ def register(): # Get updated list and nav OOB menu_items = await get_all_menu_items(g.s) - html = await _render_menu_items_list(menu_items) - nav_oob = await get_menu_items_nav_oob_async(menu_items) + html = _render_menu_items_list(menu_items) + nav_oob = get_menu_items_nav_oob_sync(menu_items) return sx_response(html + nav_oob) @bp.get("/pages/search/") @@ -318,7 +318,7 @@ def register(): pages, total = await search_pages(g.s, query, page, per_page) has_more = (page * per_page) < total - return sx_response(await _render_page_search_results(pages, query, page, has_more)) + return sx_response(_render_page_search_results(pages, query, page, has_more)) @bp.post("/reorder/") @require_admin @@ -342,8 +342,8 @@ def register(): # Get updated list and nav OOB menu_items = await get_all_menu_items(g.s) - html = await _render_menu_items_list(menu_items) - nav_oob = await get_menu_items_nav_oob_async(menu_items) + html = _render_menu_items_list(menu_items) + nav_oob = get_menu_items_nav_oob_sync(menu_items) return sx_response(html + nav_oob) return bp diff --git a/blog/bp/post/admin/routes.py b/blog/bp/post/admin/routes.py index 21ad526..9828f48 100644 --- a/blog/bp/post/admin/routes.py +++ b/blog/bp/post/admin/routes.py @@ -11,7 +11,7 @@ from quart import ( ) from shared.browser.app.authz import require_admin, require_post_author from markupsafe import escape -from shared.sx.helpers import sx_response, render_to_sx +from shared.sx.helpers import sx_response, sx_call from shared.sx.parser import SxExpr, serialize as sx_serialize from shared.utils import host_url @@ -60,10 +60,10 @@ def _post_to_edit_dict(post) -> dict: return d -async def _render_features(features, post, result): +def _render_features(features, post, result): """Render features panel via .sx defcomp.""" slug = post.get("slug", "") - return await render_to_sx("blog-features-panel-content", + return sx_call("blog-features-panel-content", features_url=host_url(url_for("blog.post.admin.update_features", slug=slug)), calendar_checked=bool(features.get("calendar")), market_checked=bool(features.get("market")), @@ -187,7 +187,7 @@ def _render_calendar_view( return _raw_html_sx(html) -async def _render_associated_entries(all_calendars, associated_entry_ids, post_slug: str) -> str: +def _render_associated_entries(all_calendars, associated_entry_ids, post_slug: str) -> str: """Render the associated entries panel.""" from shared.browser.app.csrf import generate_csrf_token from quart import url_for as qurl @@ -216,13 +216,13 @@ async def _render_associated_entries(all_calendars, associated_entry_ids, post_s toggle_url = host_url(qurl("blog.post.admin.toggle_entry", slug=post_slug, entry_id=e_id)) - img_sx = await render_to_sx("blog-entry-image", src=cal_fi, title=cal_title) + 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(await render_to_sx("blog-associated-entry", + 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}"}}', @@ -231,16 +231,16 @@ async def _render_associated_entries(all_calendars, associated_entry_ids, post_s )) if has_entries: - content_sx = await render_to_sx("blog-associated-entries-content", + content_sx = sx_call("blog-associated-entries-content", items=SxExpr("(<> " + " ".join(entry_items) + ")"), ) else: - content_sx = await render_to_sx("blog-associated-entries-empty") + content_sx = sx_call("blog-associated-entries-empty") - return await render_to_sx("blog-associated-entries-panel", content=SxExpr(content_sx)) + return sx_call("blog-associated-entries-panel", content=SxExpr(content_sx)) -async def _render_nav_entries_oob(associated_entries, calendars, post: dict) -> str: +def _render_nav_entries_oob(associated_entries, calendars, post: dict) -> str: """Render the OOB nav entries swap.""" entries_list = [] if associated_entries and hasattr(associated_entries, "entries"): @@ -249,7 +249,7 @@ async def _render_nav_entries_oob(associated_entries, calendars, post: dict) -> has_items = bool(entries_list or calendars) if not has_items: - return await render_to_sx("blog-nav-entries-empty") + return sx_call("blog-nav-entries-empty") select_colours = ( "[.hover-capable_&]:hover:bg-yellow-300" @@ -291,7 +291,7 @@ async def _render_nav_entries_oob(associated_entries, calendars, post: dict) -> entry_path = f"/{post_slug}/{cal_slug}/" date_str = "" - item_parts.append(await render_to_sx("calendar-entry-nav", + item_parts.append(sx_call("calendar-entry-nav", href=entry_path, nav_class=nav_cls, name=e_name, date_str=date_str, )) @@ -300,13 +300,13 @@ async def _render_nav_entries_oob(associated_entries, calendars, post: dict) -> cal_slug = getattr(calendar, "slug", "") cal_path = f"/{post_slug}/{cal_slug}/" - item_parts.append(await render_to_sx("blog-nav-calendar-item", + item_parts.append(sx_call("blog-nav-calendar-item", href=cal_path, nav_cls=nav_cls, name=cal_name, )) items_sx = "(<> " + " ".join(item_parts) + ")" if item_parts else "" - return await render_to_sx("scroll-nav-wrapper", + 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", @@ -353,7 +353,7 @@ def register(): }) features = result.get("features", {}) - html = await _render_features(features, post, result) + html = _render_features(features, post, result) return sx_response(html) @bp.put("/admin/sumup/") @@ -386,7 +386,7 @@ def register(): result = await call_action("blog", "update-page-config", payload=payload) features = result.get("features", {}) - html = await _render_features(features, post, result) + html = _render_features(features, post, result) return sx_response(html) @bp.get("/entries/calendar//") @@ -508,8 +508,8 @@ def register(): # Return the associated entries admin list + OOB update for nav entries post = g.post_data["post"] - admin_list = await _render_associated_entries(all_calendars, associated_entry_ids, post["slug"]) - nav_entries_html = await _render_nav_entries_oob(associated_entries, calendars, post) + admin_list = _render_associated_entries(all_calendars, associated_entry_ids, post["slug"]) + nav_entries_html = _render_nav_entries_oob(associated_entries, calendars, post) return sx_response(admin_list + nav_entries_html) @@ -686,7 +686,7 @@ def register(): slug = post.get("slug", "") create_url = host_url(url_for("blog.post.admin.create_market", slug=slug)) - html = await render_to_sx("blog-markets-panel-content", + html = sx_call("blog-markets-panel-content", markets=_serialize_markets(page_markets, slug), create_url=create_url) return sx_response(html) @@ -715,7 +715,7 @@ def register(): slug = post.get("slug", "") create_url = host_url(url_for("blog.post.admin.create_market", slug=slug)) - html = await render_to_sx("blog-markets-panel-content", + html = sx_call("blog-markets-panel-content", markets=_serialize_markets(page_markets, slug), create_url=create_url) return sx_response(html) @@ -738,7 +738,7 @@ def register(): slug = post.get("slug", "") create_url = host_url(url_for("blog.post.admin.create_market", slug=slug)) - html = await render_to_sx("blog-markets-panel-content", + html = sx_call("blog-markets-panel-content", markets=_serialize_markets(page_markets, slug), create_url=create_url) return sx_response(html) diff --git a/blog/bp/post/routes.py b/blog/bp/post/routes.py index e63be1f..c5808af 100644 --- a/blog/bp/post/routes.py +++ b/blog/bp/post/routes.py @@ -106,7 +106,7 @@ def register(): async def post_detail(slug: str): from shared.sx.page import get_template_context from shared.sx.helpers import ( - render_to_sx, root_header_sx, full_page_sx, oob_page_sx, + sx_call, root_header_sx, full_page_sx, oob_page_sx, post_header_sx, oob_header_sx, mobile_menu_sx, post_mobile_nav_sx, mobile_root_nav_sx, ) @@ -124,9 +124,9 @@ def register(): csrf = generate_csrf_token() svc = services.blog_page detail_data = svc.post_detail_data(post, user, rights, csrf, blog_url_base) - content = await render_to_sx("blog-post-detail-content", **detail_data) + content = sx_call("blog-post-detail-content", **detail_data) meta_data = svc.post_meta_data(post, tctx.get("base_title", "")) - meta = await render_to_sx("blog-meta", **meta_data) + meta = sx_call("blog-meta", **meta_data) if not is_htmx_request(): root_hdr = await root_header_sx(tctx) @@ -149,24 +149,24 @@ def register(): @clear_cache(tag="post.post_detail", tag_scope="user") async def like_toggle(slug: str): from shared.utils import host_url - from shared.sx.helpers import render_to_sx + from shared.sx.helpers import sx_call from shared.browser.app.csrf import generate_csrf_token like_url = host_url(url_for('blog.post.like_toggle', slug=slug)) csrf = generate_csrf_token() - async def _like_btn(liked): + def _like_btn(liked): if liked: colour, icon, label = "text-red-600", "fa-solid fa-heart", "Unlike this post" else: colour, icon, label = "text-stone-300", "fa-regular fa-heart", "Like this post" - return await render_to_sx("market-like-toggle-button", + return sx_call("market-like-toggle-button", colour=colour, action=like_url, hx_headers=f'{{"X-CSRFToken": "{csrf}"}}', label=label, icon_cls=icon) if not g.user: - return sx_response(await _like_btn(False), status=403) + return sx_response(_like_btn(False), status=403) post_id = g.post_data["post"]["id"] user_id = g.user.id @@ -175,7 +175,7 @@ def register(): "user_id": user_id, "target_type": "post", "target_id": post_id, }) - return sx_response(await _like_btn(result["liked"])) + return sx_response(_like_btn(result["liked"])) @bp.get("/w//") async def widget_paginate(slug: str, widget_domain: str): diff --git a/blog/bp/snippets/routes.py b/blog/bp/snippets/routes.py index 3b4b45b..b243b1f 100644 --- a/blog/bp/snippets/routes.py +++ b/blog/bp/snippets/routes.py @@ -3,7 +3,7 @@ from __future__ import annotations from quart import Blueprint, request, g, abort from shared.browser.app.authz import require_login -from shared.sx.helpers import sx_response, render_to_sx +from shared.sx.helpers import sx_response, sx_call from models import Snippet @@ -14,7 +14,7 @@ async def _render_snippets(): """Render snippets list via service data + .sx defcomp.""" from shared.services.registry import services data = await services.blog_page.snippets_data(g.s) - return await render_to_sx("blog-snippets-content", **data) + return sx_call("blog-snippets-content", **data) def register(): diff --git a/blog/sxc/pages/helpers.py b/blog/sxc/pages/helpers.py index bc4b900..9e2e2b8 100644 --- a/blog/sxc/pages/helpers.py +++ b/blog/sxc/pages/helpers.py @@ -126,22 +126,22 @@ def _register_blog_helpers() -> None: # --- Editor helpers --- -async def _h_editor_content(**kw): +def _h_editor_content(**kw): from .renders import render_editor_panel - return await render_editor_panel() + return render_editor_panel() -async def _h_editor_page_content(**kw): +def _h_editor_page_content(**kw): from .renders import render_editor_panel - return await render_editor_panel(is_page=True) + return render_editor_panel(is_page=True) # --- Post admin helpers --- async def _h_post_admin_content(slug=None, **kw): await _ensure_post_data(slug) - from shared.sx.helpers import render_to_sx - return await render_to_sx("blog-admin-placeholder") + from shared.sx.helpers import sx_call + return sx_call("blog-admin-placeholder") async def _h_post_data_content(slug=None, **kw): @@ -264,32 +264,32 @@ async def _h_post_preview_content(slug=None, **kw): await _ensure_post_data(slug) from quart import g from shared.services.registry import services - from shared.sx.helpers import render_to_sx + from shared.sx.helpers import sx_call from shared.sx.parser import SxExpr preview = await services.blog_page.preview_data(g.s) sections: list[str] = [] if preview.get("sx_pretty"): - sections.append(await render_to_sx("blog-preview-section", + sections.append(sx_call("blog-preview-section", title="S-Expression Source", content=SxExpr(preview["sx_pretty"]))) if preview.get("json_pretty"): - sections.append(await render_to_sx("blog-preview-section", + sections.append(sx_call("blog-preview-section", title="Lexical JSON", content=SxExpr(preview["json_pretty"]))) if preview.get("sx_rendered"): - rendered_sx = await render_to_sx("blog-preview-rendered", html=preview["sx_rendered"]) - sections.append(await render_to_sx("blog-preview-section", + rendered_sx = sx_call("blog-preview-rendered", html=preview["sx_rendered"]) + sections.append(sx_call("blog-preview-section", title="SX Rendered", content=SxExpr(rendered_sx))) if preview.get("lex_rendered"): - rendered_sx = await render_to_sx("blog-preview-rendered", html=preview["lex_rendered"]) - sections.append(await render_to_sx("blog-preview-section", + rendered_sx = sx_call("blog-preview-rendered", html=preview["lex_rendered"]) + sections.append(sx_call("blog-preview-section", title="Lexical Rendered", content=SxExpr(rendered_sx))) if not sections: - return await render_to_sx("blog-preview-empty") + return sx_call("blog-preview-empty") inner = " ".join(sections) - return await render_to_sx("blog-preview-panel", sections=SxExpr(f"(<> {inner})")) + return sx_call("blog-preview-panel", sections=SxExpr(f"(<> {inner})")) async def _h_post_entries_content(slug=None, **kw): @@ -315,7 +315,7 @@ async def _h_post_entries_content(slug=None, **kw): await g.s.refresh(calendar, ["entries", "post"]) # Associated entries list - assoc_html = await _render_associated_entries(all_calendars, associated_entry_ids, post_slug) + assoc_html = _render_associated_entries(all_calendars, associated_entry_ids, post_slug) # Calendar browser cal_items: list[str] = [] @@ -505,7 +505,7 @@ async def _h_post_edit_content(slug=None, **kw): from sqlalchemy.orm import selectinload from shared.infrastructure.data_client import fetch_data from shared.browser.app.csrf import generate_csrf_token - from shared.sx.helpers import render_to_sx + from shared.sx.helpers import sx_call from shared.sx.parser import SxExpr, serialize as sx_serialize from bp.post.admin.routes import _post_to_edit_dict @@ -584,9 +584,9 @@ async def _h_post_edit_content(slug=None, **kw): parts: list[str] = [] if save_error: - parts.append(await render_to_sx("blog-editor-error", error=save_error)) + parts.append(sx_call("blog-editor-error", error=save_error)) - parts.append(await render_to_sx("blog-editor-edit-form", + parts.append(sx_call("blog-editor-edit-form", csrf=csrf, updated_at=str(updated_at), title_val=title_val, @@ -603,9 +603,9 @@ async def _h_post_edit_content(slug=None, **kw): footer_extra=footer_extra_sx, )) - parts.append(await render_to_sx("blog-editor-publish-js", already_emailed=already_emailed)) - parts.append(await render_to_sx("blog-editor-styles", css_href=editor_css)) - parts.append(await render_to_sx("sx-editor-styles")) + parts.append(sx_call("blog-editor-publish-js", already_emailed=already_emailed)) + parts.append(sx_call("blog-editor-styles", css_href=editor_css)) + parts.append(sx_call("sx-editor-styles")) init_js = ( '(function() {' @@ -705,10 +705,10 @@ async def _h_post_edit_content(slug=None, **kw): ' }, 50); }' '})();' ) - parts.append(await render_to_sx("blog-editor-scripts", + parts.append(sx_call("blog-editor-scripts", js_src=editor_js, sx_editor_js_src=sx_editor_js, init_js=init_js)) - return await render_to_sx("blog-editor-panel", + return sx_call("blog-editor-panel", parts=SxExpr("(<> " + " ".join(parts) + ")")) diff --git a/blog/sxc/pages/layouts.py b/blog/sxc/pages/layouts.py index 36eb7f3..002fbbd 100644 --- a/blog/sxc/pages/layouts.py +++ b/blog/sxc/pages/layouts.py @@ -5,19 +5,19 @@ from typing import Any # --------------------------------------------------------------------------- -# Header helpers (moved from sx_components — thin render_to_sx wrappers) +# Header helpers (moved from sx_components — thin sx_call wrappers) # --------------------------------------------------------------------------- -async def _settings_header_sx(ctx: dict, *, oob: bool = False) -> str: - from shared.sx.helpers import render_to_sx +def _settings_header_sx(ctx: dict, *, oob: bool = False) -> str: + from shared.sx.helpers import sx_call from shared.sx.parser import SxExpr from quart import url_for as qurl settings_href = qurl("settings.defpage_settings_home") - label_sx = await render_to_sx("blog-admin-label") - nav_sx = await _settings_nav_sx(ctx) + label_sx = sx_call("blog-admin-label") + nav_sx = _settings_nav_sx(ctx) - return await render_to_sx("menu-row-sx", + return sx_call("menu-row-sx", id="root-settings-row", level=1, link_href=settings_href, link_label_content=SxExpr(label_sx), @@ -25,20 +25,20 @@ async def _settings_header_sx(ctx: dict, *, oob: bool = False) -> str: child_id="root-settings-header-child", oob=oob) -async def _settings_nav_sx(ctx: dict) -> str: - from shared.sx.helpers import render_to_sx - return await render_to_sx("blog-settings-nav") +def _settings_nav_sx(ctx: dict) -> str: + from shared.sx.helpers import sx_call + return sx_call("blog-settings-nav") -async 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: - from shared.sx.helpers import render_to_sx +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: + from shared.sx.helpers import sx_call from shared.sx.parser import SxExpr - label_sx = await render_to_sx("blog-sub-settings-label", + label_sx = sx_call("blog-sub-settings-label", icon=f"fa fa-{icon}", label=label) - return await render_to_sx("menu-row-sx", + return sx_call("menu-row-sx", id=row_id, level=2, link_href=href, link_label_content=SxExpr(label_sx), @@ -82,19 +82,19 @@ async def _settings_full(ctx: dict, **kw: Any) -> str: from shared.sx.helpers import render_to_sx_with_env from shared.sx.parser import SxExpr return await render_to_sx_with_env("settings-layout-full", {}, - settings_header=SxExpr(await _settings_header_sx(ctx))) + settings_header=SxExpr(_settings_header_sx(ctx))) async def _settings_oob(ctx: dict, **kw: Any) -> str: from shared.sx.helpers import render_to_sx_with_env, oob_header_sx from shared.sx.parser import SxExpr rows = await render_to_sx_with_env("settings-layout-full", {}, - settings_header=SxExpr(await _settings_header_sx(ctx))) + settings_header=SxExpr(_settings_header_sx(ctx))) return await oob_header_sx("root-header-child", "root-settings-header-child", rows) -async def _settings_mobile(ctx: dict, **kw: Any) -> str: - return await _settings_nav_sx(ctx) +def _settings_mobile(ctx: dict, **kw: Any) -> str: + return _settings_nav_sx(ctx) # --- Sub-settings helpers --- @@ -105,21 +105,21 @@ async def _sub_settings_full(ctx: dict, row_id: str, child_id: str, from shared.sx.parser import SxExpr from quart import url_for as qurl return await render_to_sx_with_env("sub-settings-layout-full", {}, - settings_header=SxExpr(await _settings_header_sx(ctx)), - sub_header=SxExpr(await _sub_settings_header_sx( + settings_header=SxExpr(_settings_header_sx(ctx)), + sub_header=SxExpr(_sub_settings_header_sx( row_id, child_id, qurl(endpoint), icon, label, ctx))) async def _sub_settings_oob(ctx: dict, row_id: str, child_id: str, endpoint: str, icon: str, label: str) -> str: - from shared.sx.helpers import oob_header_sx, render_to_sx + from shared.sx.helpers import oob_header_sx, sx_call from shared.sx.parser import SxExpr from quart import url_for as qurl - settings_hdr_oob = await _settings_header_sx(ctx, oob=True) - sub_hdr = await _sub_settings_header_sx( + settings_hdr_oob = _settings_header_sx(ctx, oob=True) + sub_hdr = _sub_settings_header_sx( row_id, child_id, qurl(endpoint), icon, label, ctx) sub_oob = await oob_header_sx("root-settings-header-child", child_id, sub_hdr) - return await render_to_sx("sub-settings-layout-oob", + return sx_call("sub-settings-layout-oob", settings_header_oob=SxExpr(settings_hdr_oob), sub_header_oob=SxExpr(sub_oob)) @@ -180,8 +180,8 @@ async def _tag_group_edit_full(ctx: dict, **kw: Any) -> str: from shared.sx.parser import SxExpr g_id = (request.view_args or {}).get("id") return await render_to_sx_with_env("sub-settings-layout-full", {}, - settings_header=SxExpr(await _settings_header_sx(ctx)), - sub_header=SxExpr(await _sub_settings_header_sx( + settings_header=SxExpr(_settings_header_sx(ctx)), + sub_header=SxExpr(_sub_settings_header_sx( "tag-groups-row", "tag-groups-header-child", qurl("defpage_tag_group_edit", id=g_id), "tags", "Tag Groups", ctx))) @@ -189,15 +189,15 @@ async def _tag_group_edit_full(ctx: dict, **kw: Any) -> str: async def _tag_group_edit_oob(ctx: dict, **kw: Any) -> str: from quart import request, url_for as qurl - from shared.sx.helpers import oob_header_sx, render_to_sx + from shared.sx.helpers import oob_header_sx, sx_call from shared.sx.parser import SxExpr g_id = (request.view_args or {}).get("id") - settings_hdr_oob = await _settings_header_sx(ctx, oob=True) - sub_hdr = await _sub_settings_header_sx( + settings_hdr_oob = _settings_header_sx(ctx, oob=True) + sub_hdr = _sub_settings_header_sx( "tag-groups-row", "tag-groups-header-child", qurl("defpage_tag_group_edit", id=g_id), "tags", "Tag Groups", ctx) sub_oob = await oob_header_sx("root-settings-header-child", "tag-groups-header-child", sub_hdr) - return await render_to_sx("sub-settings-layout-oob", + return sx_call("sub-settings-layout-oob", settings_header_oob=SxExpr(settings_hdr_oob), sub_header_oob=SxExpr(sub_oob)) diff --git a/blog/sxc/pages/renders.py b/blog/sxc/pages/renders.py index fd08d62..3cd1833 100644 --- a/blog/sxc/pages/renders.py +++ b/blog/sxc/pages/renders.py @@ -2,12 +2,12 @@ from __future__ import annotations -async def render_editor_panel(save_error: str | None = None, is_page: bool = False) -> str: +def render_editor_panel(save_error: str | None = None, is_page: bool = False) -> str: """Build the WYSIWYG editor panel HTML for new post/page creation.""" import os from quart import url_for as qurl, current_app from shared.browser.app.csrf import generate_csrf_token - from shared.sx.helpers import render_to_sx + from shared.sx.helpers import sx_call csrf = generate_csrf_token() asset_url_fn = current_app.jinja_env.globals.get("asset_url", lambda p: "") @@ -28,15 +28,15 @@ async def render_editor_panel(save_error: str | None = None, is_page: bool = Fal parts: list[str] = [] if save_error: - parts.append(await render_to_sx("blog-editor-error", error=str(save_error))) + parts.append(sx_call("blog-editor-error", error=str(save_error))) - parts.append(await render_to_sx("blog-editor-form", + parts.append(sx_call("blog-editor-form", csrf=csrf, title_placeholder=title_placeholder, create_label=create_label, )) - parts.append(await render_to_sx("blog-editor-styles", css_href=editor_css)) - parts.append(await render_to_sx("sx-editor-styles")) + parts.append(sx_call("blog-editor-styles", css_href=editor_css)) + parts.append(sx_call("sx-editor-styles")) init_js = ( "console.log('[EDITOR-DEBUG] init script running');\n" @@ -167,11 +167,11 @@ async def render_editor_panel(save_error: str | None = None, is_page: bool = Fal " }\n" "})();\n" ) - parts.append(await render_to_sx("blog-editor-scripts", + parts.append(sx_call("blog-editor-scripts", js_src=editor_js, sx_editor_js_src=sx_editor_js, init_js=init_js)) from shared.sx.parser import SxExpr - return await render_to_sx("blog-editor-panel", + return sx_call("blog-editor-panel", parts=SxExpr("(<> " + " ".join(parts) + ")")) if parts else "" diff --git a/cart/bp/orders/routes.py b/cart/bp/orders/routes.py index 22150a2..db1abab 100644 --- a/cart/bp/orders/routes.py +++ b/cart/bp/orders/routes.py @@ -154,7 +154,7 @@ def register(url_prefix: str) -> Blueprint: ) resp = await make_response(html) elif page > 1: - sx_src = await render_orders_rows( + sx_src = render_orders_rows( ctx, orders, page, total_pages, url_for, qs_fn, ) resp = sx_response(sx_src) diff --git a/cart/bp/page_admin/routes.py b/cart/bp/page_admin/routes.py index 934e003..8fa9dbf 100644 --- a/cart/bp/page_admin/routes.py +++ b/cart/bp/page_admin/routes.py @@ -49,7 +49,7 @@ def register(): from shared.sx.page import get_template_context from sxc.pages.renders import render_cart_payments_panel ctx = await get_template_context() - html = await render_cart_payments_panel(ctx) + html = render_cart_payments_panel(ctx) return sx_response(html) return bp diff --git a/cart/sxc/pages/layouts.py b/cart/sxc/pages/layouts.py index 9b4c981..536257f 100644 --- a/cart/sxc/pages/layouts.py +++ b/cart/sxc/pages/layouts.py @@ -57,9 +57,9 @@ async def _post_header_sx(ctx: dict, page_post: Any, *, oob: bool = False) -> st return await _shared_post_header_sx(ctx, oob=oob) -async def _cart_header_sx(ctx: dict, *, oob: bool = False) -> str: - from shared.sx.helpers import render_to_sx, call_url - return await render_to_sx( +def _cart_header_sx(ctx: dict, *, oob: bool = False) -> str: + from shared.sx.helpers import sx_call, call_url + return sx_call( "menu-row-sx", id="cart-row", level=1, colour="sky", link_href=call_url(ctx, "cart_url", "/"), @@ -68,15 +68,15 @@ async def _cart_header_sx(ctx: dict, *, oob: bool = False) -> str: ) -async def _page_cart_header_sx(ctx: dict, page_post: Any, *, oob: bool = False) -> str: - from shared.sx.helpers import render_to_sx, call_url +def _page_cart_header_sx(ctx: dict, page_post: Any, *, oob: bool = False) -> str: + from shared.sx.helpers import sx_call, call_url slug = page_post.slug if page_post else "" title = ((page_post.title if page_post else None) or "")[:160] - label_sx = await render_to_sx("cart-page-label", + label_sx = sx_call("cart-page-label", feature_image=page_post.feature_image if page_post else None, title=title) - nav_sx = await render_to_sx("cart-all-carts-link", href=call_url(ctx, "cart_url", "/")) - return await render_to_sx( + nav_sx = sx_call("cart-all-carts-link", href=call_url(ctx, "cart_url", "/")) + return sx_call( "menu-row-sx", id="page-cart-row", level=2, colour="sky", link_href=call_url(ctx, "cart_url", f"/{slug}/"), @@ -102,8 +102,8 @@ async def _cart_page_full(ctx: dict, **kw: Any) -> str: page_post = ctx.get("page_post") env = {} return await render_to_sx_with_env("cart-page-layout-full", env, - cart_row=SxExpr(await _cart_header_sx(ctx)), - page_cart_row=SxExpr(await _page_cart_header_sx(ctx, page_post)), + cart_row=SxExpr(_cart_header_sx(ctx)), + page_cart_row=SxExpr(_page_cart_header_sx(ctx, page_post)), ) @@ -113,8 +113,8 @@ async def _cart_page_oob(ctx: dict, **kw: Any) -> str: env = {} return await render_to_sx_with_env("cart-page-layout-oob", env, root_header_oob=SxExpr(await root_header_sx(ctx, oob=True)), - cart_row_oob=SxExpr(await _cart_header_sx(ctx, oob=True)), - page_cart_row=SxExpr(await _page_cart_header_sx(ctx, page_post)), + cart_row_oob=SxExpr(_cart_header_sx(ctx, oob=True)), + page_cart_row=SxExpr(_page_cart_header_sx(ctx, page_post)), ) diff --git a/cart/sxc/pages/renders.py b/cart/sxc/pages/renders.py index 125d53c..f4f0f98 100644 --- a/cart/sxc/pages/renders.py +++ b/cart/sxc/pages/renders.py @@ -7,7 +7,7 @@ from .utils import _serialize_order, _serialize_calendar_entry async def render_orders_page(ctx, orders, page, total_pages, search, search_count, url_for_fn, qs_fn): - from shared.sx.helpers import render_to_sx, render_to_sx_with_env, search_desktop_sx, search_mobile_sx, full_page_sx + from shared.sx.helpers import sx_call, render_to_sx_with_env, search_desktop_sx, search_mobile_sx, full_page_sx from shared.utils import route_prefix ctx["search"] = search ctx["search_count"] = search_count @@ -15,18 +15,18 @@ async def render_orders_page(ctx, orders, page, total_pages, search, search_coun list_url = pfx + url_for_fn("orders.list_orders") detail_url_prefix = pfx + url_for_fn("orders.order.order_detail", order_id=0).rsplit("0/", 1)[0] order_dicts = [_serialize_order(o) for o in orders] - content = await render_to_sx("orders-list-content", orders=order_dicts, + content = sx_call("orders-list-content", orders=order_dicts, page=page, total_pages=total_pages, rows_url=list_url, detail_url_prefix=detail_url_prefix) header_rows = await render_to_sx_with_env("cart-orders-layout-full", {}, list_url=list_url, ) - filt = await render_to_sx("order-list-header", search_mobile=SxExpr(await search_mobile_sx(ctx))) + filt = sx_call("order-list-header", search_mobile=SxExpr(await search_mobile_sx(ctx))) return await full_page_sx(ctx, header_rows=header_rows, filter=filt, aside=await search_desktop_sx(ctx), content=content) -async def render_orders_rows(ctx, orders, page, total_pages, url_for_fn, qs_fn): - from shared.sx.helpers import render_to_sx +def render_orders_rows(ctx, orders, page, total_pages, url_for_fn, qs_fn): + from shared.sx.helpers import sx_call from shared.utils import route_prefix pfx = route_prefix() list_url = pfx + url_for_fn("orders.list_orders") @@ -34,22 +34,22 @@ async def render_orders_rows(ctx, orders, page, total_pages, url_for_fn, qs_fn): order_dicts = [_serialize_order(o) for o in orders] parts = [] for od in order_dicts: - parts.append(await render_to_sx("order-row-pair", order=od, detail_url_prefix=detail_url_prefix)) + parts.append(sx_call("order-row-pair", order=od, detail_url_prefix=detail_url_prefix)) next_scroll = "" if page < total_pages: next_url = list_url + qs_fn(page=page + 1) - next_scroll = await render_to_sx("infinite-scroll", url=next_url, page=page, + next_scroll = sx_call("infinite-scroll", url=next_url, page=page, total_pages=total_pages, id_prefix="orders", colspan=5) else: - next_scroll = await render_to_sx("order-end-row") - return await render_to_sx("cart-orders-rows", + next_scroll = sx_call("order-end-row") + return sx_call("cart-orders-rows", rows=SxExpr("(<> " + " ".join(parts) + ")"), next_scroll=SxExpr(next_scroll), ) async def render_orders_oob(ctx, orders, page, total_pages, search, search_count, url_for_fn, qs_fn): - from shared.sx.helpers import render_to_sx, render_to_sx_with_env, search_desktop_sx, search_mobile_sx, oob_page_sx + from shared.sx.helpers import sx_call, render_to_sx_with_env, search_desktop_sx, search_mobile_sx, oob_page_sx from shared.utils import route_prefix ctx["search"] = search ctx["search_count"] = search_count @@ -57,17 +57,17 @@ async def render_orders_oob(ctx, orders, page, total_pages, search, search_count list_url = pfx + url_for_fn("orders.list_orders") detail_url_prefix = pfx + url_for_fn("orders.order.order_detail", order_id=0).rsplit("0/", 1)[0] order_dicts = [_serialize_order(o) for o in orders] - content = await render_to_sx("orders-list-content", orders=order_dicts, + content = sx_call("orders-list-content", orders=order_dicts, page=page, total_pages=total_pages, rows_url=list_url, detail_url_prefix=detail_url_prefix) oobs = await render_to_sx_with_env("cart-orders-layout-oob", {}, list_url=list_url, ) - filt = await render_to_sx("order-list-header", search_mobile=SxExpr(await search_mobile_sx(ctx))) + filt = sx_call("order-list-header", search_mobile=SxExpr(await search_mobile_sx(ctx))) return await oob_page_sx(oobs=oobs, filter=filt, aside=await search_desktop_sx(ctx), content=content) async def render_order_page(ctx, order, calendar_entries, url_for_fn): - from shared.sx.helpers import render_to_sx, render_to_sx_with_env, full_page_sx + from shared.sx.helpers import sx_call, render_to_sx_with_env, full_page_sx from shared.utils import route_prefix from shared.browser.app.csrf import generate_csrf_token pfx = route_prefix() @@ -77,8 +77,8 @@ async def render_order_page(ctx, order, calendar_entries, url_for_fn): pay_url = pfx + url_for_fn("orders.order.order_pay", order_id=order.id) order_data = _serialize_order(order) cal_data = [_serialize_calendar_entry(e) for e in (calendar_entries or [])] - main = await render_to_sx("order-detail-content", order=order_data, calendar_entries=cal_data) - filt = await render_to_sx("order-detail-filter-content", order=order_data, + main = sx_call("order-detail-content", order=order_data, calendar_entries=cal_data) + filt = sx_call("order-detail-filter-content", order=order_data, list_url=list_url, recheck_url=recheck_url, pay_url=pay_url, csrf=generate_csrf_token()) header_rows = await render_to_sx_with_env("cart-order-detail-layout-full", {}, list_url=list_url, detail_url=detail_url, @@ -88,7 +88,7 @@ async def render_order_page(ctx, order, calendar_entries, url_for_fn): async def render_order_oob(ctx, order, calendar_entries, url_for_fn): - from shared.sx.helpers import render_to_sx, render_to_sx_with_env, oob_page_sx + from shared.sx.helpers import sx_call, render_to_sx_with_env, oob_page_sx from shared.utils import route_prefix from shared.browser.app.csrf import generate_csrf_token pfx = route_prefix() @@ -98,8 +98,8 @@ async def render_order_oob(ctx, order, calendar_entries, url_for_fn): pay_url = pfx + url_for_fn("orders.order.order_pay", order_id=order.id) order_data = _serialize_order(order) cal_data = [_serialize_calendar_entry(e) for e in (calendar_entries or [])] - main = await render_to_sx("order-detail-content", order=order_data, calendar_entries=cal_data) - filt = await render_to_sx("order-detail-filter-content", order=order_data, + main = sx_call("order-detail-content", order=order_data, calendar_entries=cal_data) + filt = sx_call("order-detail-filter-content", order=order_data, list_url=list_url, recheck_url=recheck_url, pay_url=pay_url, csrf=generate_csrf_token()) oobs = await render_to_sx_with_env("cart-order-detail-layout-oob", {}, detail_url=detail_url, @@ -109,19 +109,19 @@ async def render_order_oob(ctx, order, calendar_entries, url_for_fn): async def render_checkout_error_page(ctx, error=None, order=None): - from shared.sx.helpers import render_to_sx, render_to_sx_with_env, full_page_sx + from shared.sx.helpers import sx_call, render_to_sx_with_env, full_page_sx from shared.infrastructure.urls import cart_url err_msg = error or "Unexpected error while creating the hosted checkout session." - order_sx = await render_to_sx("checkout-error-order-id", oid=f"#{order.id}") if order else None + order_sx = sx_call("checkout-error-order-id", oid=f"#{order.id}") if order else None hdr = await render_to_sx_with_env("layout-root-full", {}) - filt = await render_to_sx("checkout-error-header") - content = await render_to_sx("checkout-error-content", msg=err_msg, + filt = sx_call("checkout-error-header") + content = sx_call("checkout-error-content", msg=err_msg, order=SxExpr(order_sx) if order_sx else None, back_url=cart_url("/")) return await full_page_sx(ctx, header_rows=hdr, filter=filt, content=content) -async def render_cart_payments_panel(ctx): - from shared.sx.helpers import render_to_sx +def render_cart_payments_panel(ctx): + from shared.sx.helpers import sx_call page_config = ctx.get("page_config") pc_data = None if page_config: @@ -130,4 +130,4 @@ async def render_cart_payments_panel(ctx): "sumup_merchant_code": getattr(page_config, "sumup_merchant_code", None) or "", "sumup_checkout_prefix": getattr(page_config, "sumup_checkout_prefix", None) or "", } - return await render_to_sx("cart-payments-content", page_config=pc_data) + return sx_call("cart-payments-content", page_config=pc_data) diff --git a/events/bp/all_events/routes.py b/events/bp/all_events/routes.py index 1fbc60b..83f6dc6 100644 --- a/events/bp/all_events/routes.py +++ b/events/bp/all_events/routes.py @@ -85,7 +85,7 @@ def register() -> Blueprint: entries, has_more, pending_tickets, page_info = await _load_entries(page) from sxc.pages.renders import render_all_events_cards - sx_src = await render_all_events_cards(entries, has_more, pending_tickets, page_info, page, view) + sx_src = render_all_events_cards(entries, has_more, pending_tickets, page_info, page, view) return sx_response(sx_src) @bp.post("/all-tickets/adjust") @@ -126,7 +126,7 @@ def register() -> Blueprint: frag_params["session_id"] = ident["session_id"] from sxc.pages.tickets import render_ticket_widget - widget_html = await render_ticket_widget(entry, qty, "/all-tickets/adjust") + widget_html = render_ticket_widget(entry, qty, "/all-tickets/adjust") mini_html = await fetch_fragment("cart", "cart-mini", params=frag_params, required=False) return sx_response(widget_html + (mini_html or "")) diff --git a/events/bp/calendar/admin/routes.py b/events/bp/calendar/admin/routes.py index 123e3ff..7a53274 100644 --- a/events/bp/calendar/admin/routes.py +++ b/events/bp/calendar/admin/routes.py @@ -19,7 +19,7 @@ def register(): @require_admin async def calendar_description_edit(calendar_slug: str, **kwargs): from sxc.pages.renders import render_calendar_description_edit - html = await render_calendar_description_edit(g.calendar) + html = render_calendar_description_edit(g.calendar) return sx_response(html) @@ -35,7 +35,7 @@ def register(): await g.s.flush() from sxc.pages.renders import render_calendar_description - html = await render_calendar_description(g.calendar, oob=True) + html = render_calendar_description(g.calendar, oob=True) return sx_response(html) @@ -43,7 +43,7 @@ def register(): @require_admin async def calendar_description_view(calendar_slug: str, **kwargs): from sxc.pages.renders import render_calendar_description - html = await render_calendar_description(g.calendar) + html = render_calendar_description(g.calendar) return sx_response(html) return bp diff --git a/events/bp/calendar/routes.py b/events/bp/calendar/routes.py index 2d3e661..7d77f27 100644 --- a/events/bp/calendar/routes.py +++ b/events/bp/calendar/routes.py @@ -201,7 +201,7 @@ def register(): from shared.sx.page import get_template_context from sxc.pages.calendar import _calendar_admin_main_panel_html ctx = await get_template_context() - html = await _calendar_admin_main_panel_html(ctx) + html = _calendar_admin_main_panel_html(ctx) return sx_response(html) @@ -220,7 +220,7 @@ def register(): from shared.sx.page import get_template_context from sxc.pages.renders import render_calendars_list_panel ctx = await get_template_context() - html = await render_calendars_list_panel(ctx) + html = render_calendars_list_panel(ctx) if post_data: from shared.services.entry_associations import get_associated_entries @@ -236,7 +236,7 @@ def register(): ).scalars().all() associated_entries = await get_associated_entries(post_id) - nav_oob = await render_post_nav_entries_oob(associated_entries, cals, post_data["post"]) + nav_oob = render_post_nav_entries_oob(associated_entries, cals, post_data["post"]) html = html + nav_oob return sx_response(html) diff --git a/events/bp/calendar_entries/routes.py b/events/bp/calendar_entries/routes.py index b211bef..f9c2f19 100644 --- a/events/bp/calendar_entries/routes.py +++ b/events/bp/calendar_entries/routes.py @@ -259,7 +259,7 @@ def register(): } from sxc.pages.renders import render_day_main_panel - html = await render_day_main_panel(ctx) + html = render_day_main_panel(ctx) mini_html = await fetch_fragment("cart", "cart-mini", params=frag_params, required=False) return sx_response(html + (mini_html or "")) @@ -280,12 +280,12 @@ def register(): day_slots = list(result.scalars()) from sxc.pages.entries import render_entry_add_form - return sx_response(await render_entry_add_form(g.calendar, day, month, year, day_slots)) + return sx_response(render_entry_add_form(g.calendar, day, month, year, day_slots)) @bp.get("/add-button/") async def add_button(day: int, month: int, year: int, **kwargs): from sxc.pages.entries import render_entry_add_button - return sx_response(await render_entry_add_button(g.calendar, day, month, year)) + return sx_response(render_entry_add_button(g.calendar, day, month, year)) diff --git a/events/bp/calendar_entry/routes.py b/events/bp/calendar_entry/routes.py index 7f81c77..8813b06 100644 --- a/events/bp/calendar_entry/routes.py +++ b/events/bp/calendar_entry/routes.py @@ -112,7 +112,7 @@ def register(): # Render OOB nav from sxc.pages.entries import render_day_entries_nav_oob - return await render_day_entries_nav_oob(visible.confirmed_entries, calendar, day_date) + return render_day_entries_nav_oob(visible.confirmed_entries, calendar, day_date) async def get_post_nav_oob(entry_id: int): """Helper to generate OOB update for post entries nav when entry state changes""" @@ -149,7 +149,7 @@ def register(): # Render OOB nav for this post from sxc.pages.entries import render_post_nav_entries_oob - nav_oob = await render_post_nav_entries_oob(associated_entries, calendars, post) + nav_oob = render_post_nav_entries_oob(associated_entries, calendars, post) nav_oobs.append(nav_oob) return "".join(nav_oobs) @@ -257,7 +257,7 @@ def register(): day_slots = list(result.scalars()) from sxc.pages.entries import render_entry_edit_form - return sx_response(await render_entry_edit_form(g.entry, g.calendar, day, month, year, day_slots)) + return sx_response(render_entry_edit_form(g.entry, g.calendar, day, month, year, day_slots)) @bp.put("/") @require_admin @@ -423,7 +423,7 @@ def register(): from sxc.pages.entries import _entry_main_panel_html tctx = await get_template_context() - html = await _entry_main_panel_html(tctx) + html = _entry_main_panel_html(tctx) return sx_response(html + nav_oob) @@ -449,7 +449,7 @@ def register(): # Re-read entry to get updated state await g.s.refresh(g.entry) from sxc.pages.entries import render_entry_optioned - html = await render_entry_optioned(g.entry, g.calendar, day, month, year) + html = render_entry_optioned(g.entry, g.calendar, day, month, year) return sx_response(html + day_nav_oob + post_nav_oob) @bp.post("/decline/") @@ -474,7 +474,7 @@ def register(): # Re-read entry to get updated state await g.s.refresh(g.entry) from sxc.pages.entries import render_entry_optioned - html = await render_entry_optioned(g.entry, g.calendar, day, month, year) + html = render_entry_optioned(g.entry, g.calendar, day, month, year) return sx_response(html + day_nav_oob + post_nav_oob) @bp.post("/provisional/") @@ -499,7 +499,7 @@ def register(): # Re-read entry to get updated state await g.s.refresh(g.entry) from sxc.pages.entries import render_entry_optioned - html = await render_entry_optioned(g.entry, g.calendar, day, month, year) + html = render_entry_optioned(g.entry, g.calendar, day, month, year) return sx_response(html + day_nav_oob + post_nav_oob) @bp.post("/tickets/") @@ -543,7 +543,7 @@ def register(): # Return just the tickets fragment (targeted by hx-target="#entry-tickets-...") await g.s.refresh(g.entry) from sxc.pages.entries import render_entry_tickets_config - html = await render_entry_tickets_config(g.entry, g.calendar, request.view_args.get("day"), request.view_args.get("month"), request.view_args.get("year")) + html = render_entry_tickets_config(g.entry, g.calendar, request.view_args.get("day"), request.view_args.get("month"), request.view_args.get("year")) return sx_response(html) @bp.get("/posts/search/") @@ -559,7 +559,7 @@ def register(): va = request.view_args or {} from sxc.pages.entries import render_post_search_results - return sx_response(await render_post_search_results( + return sx_response(render_post_search_results( search_posts, query, page, total_pages, g.entry, g.calendar, va.get("day"), va.get("month"), va.get("year"), @@ -594,8 +594,8 @@ def register(): # Return updated posts list + OOB nav update from sxc.pages.entries import render_entry_posts_panel, render_entry_posts_nav_oob va = request.view_args or {} - html = await render_entry_posts_panel(entry_posts, g.entry, g.calendar, va.get("day"), va.get("month"), va.get("year")) - nav_oob = await render_entry_posts_nav_oob(entry_posts) + html = render_entry_posts_panel(entry_posts, g.entry, g.calendar, va.get("day"), va.get("month"), va.get("year")) + nav_oob = render_entry_posts_nav_oob(entry_posts) return sx_response(html + nav_oob) @bp.delete("/posts//") @@ -616,8 +616,8 @@ def register(): # Return updated posts list + OOB nav update from sxc.pages.entries import render_entry_posts_panel, render_entry_posts_nav_oob va = request.view_args or {} - html = await render_entry_posts_panel(entry_posts, g.entry, g.calendar, va.get("day"), va.get("month"), va.get("year")) - nav_oob = await render_entry_posts_nav_oob(entry_posts) + html = render_entry_posts_panel(entry_posts, g.entry, g.calendar, va.get("day"), va.get("month"), va.get("year")) + nav_oob = render_entry_posts_nav_oob(entry_posts) return sx_response(html + nav_oob) return bp diff --git a/events/bp/calendars/routes.py b/events/bp/calendars/routes.py index 0bc3661..2bb5565 100644 --- a/events/bp/calendars/routes.py +++ b/events/bp/calendars/routes.py @@ -69,7 +69,7 @@ def register(): from shared.sx.page import get_template_context from sxc.pages.renders import render_calendars_list_panel ctx = await get_template_context() - html = await render_calendars_list_panel(ctx) + html = render_calendars_list_panel(ctx) # Blog-embedded mode: also update post nav if post_data: @@ -85,7 +85,7 @@ def register(): ).scalars().all() associated_entries = await get_associated_entries(post_id) - nav_oob = await render_post_nav_entries_oob(associated_entries, cals, post_data["post"]) + nav_oob = render_post_nav_entries_oob(associated_entries, cals, post_data["post"]) html = html + nav_oob return sx_response(html) diff --git a/events/bp/markets/routes.py b/events/bp/markets/routes.py index 415f6c0..319649d 100644 --- a/events/bp/markets/routes.py +++ b/events/bp/markets/routes.py @@ -44,7 +44,7 @@ def register(): from shared.sx.page import get_template_context from sxc.pages.renders import render_markets_list_panel ctx = await get_template_context() - return sx_response(await render_markets_list_panel(ctx)) + return sx_response(render_markets_list_panel(ctx)) @bp.delete("//") @require_admin @@ -57,6 +57,6 @@ def register(): from shared.sx.page import get_template_context from sxc.pages.renders import render_markets_list_panel ctx = await get_template_context() - return sx_response(await render_markets_list_panel(ctx)) + return sx_response(render_markets_list_panel(ctx)) return bp diff --git a/events/bp/page/routes.py b/events/bp/page/routes.py index 0b88755..ecbbe5b 100644 --- a/events/bp/page/routes.py +++ b/events/bp/page/routes.py @@ -66,7 +66,7 @@ def register() -> Blueprint: entries, has_more, pending_tickets = await _load_entries(post["id"], page) from sxc.pages.renders import render_page_summary_cards - sx_src = await render_page_summary_cards(entries, has_more, pending_tickets, {}, page, view, post) + sx_src = render_page_summary_cards(entries, has_more, pending_tickets, {}, page, view, post) return sx_response(sx_src) @bp.post("/tickets/adjust") @@ -107,7 +107,7 @@ def register() -> Blueprint: frag_params["session_id"] = ident["session_id"] from sxc.pages.tickets import render_ticket_widget - widget_html = await render_ticket_widget(entry, qty, f"/{g.post_slug}/tickets/adjust") + widget_html = render_ticket_widget(entry, qty, f"/{g.post_slug}/tickets/adjust") mini_html = await fetch_fragment("cart", "cart-mini", params=frag_params, required=False) return sx_response(widget_html + (mini_html or "")) diff --git a/events/bp/slot/routes.py b/events/bp/slot/routes.py index 5b157b1..b2585e4 100644 --- a/events/bp/slot/routes.py +++ b/events/bp/slot/routes.py @@ -36,7 +36,7 @@ def register(): if not slot: return await make_response("Not found", 404) from sxc.pages.slots import render_slot_edit_form - return sx_response(await render_slot_edit_form(slot, g.calendar)) + return sx_response(render_slot_edit_form(slot, g.calendar)) @bp.get("/view/") @require_admin @@ -45,7 +45,7 @@ def register(): if not slot: return await make_response("Not found", 404) from sxc.pages.slots import render_slot_main_panel - return sx_response(await render_slot_main_panel(slot, g.calendar)) + return sx_response(render_slot_main_panel(slot, g.calendar)) @bp.delete("/") @require_admin @@ -54,7 +54,7 @@ def register(): await svc_delete_slot(g.s, slot_id) slots = await svc_list_slots(g.s, g.calendar.id) from sxc.pages.slots import render_slots_table - return sx_response(await render_slots_table(slots, g.calendar)) + return sx_response(render_slots_table(slots, g.calendar)) @bp.put("/") @require_admin @@ -136,7 +136,7 @@ def register(): ), 422 from sxc.pages.slots import render_slot_main_panel - return sx_response(await render_slot_main_panel(slot, g.calendar, oob=True)) + return sx_response(render_slot_main_panel(slot, g.calendar, oob=True)) diff --git a/events/bp/slots/routes.py b/events/bp/slots/routes.py index 61f1ae7..d3bfee3 100644 --- a/events/bp/slots/routes.py +++ b/events/bp/slots/routes.py @@ -111,19 +111,19 @@ def register(): # Success → re-render the slots table slots = await svc_list_slots(g.s, g.calendar.id) from sxc.pages.slots import render_slots_table - return sx_response(await render_slots_table(slots, g.calendar)) + return sx_response(render_slots_table(slots, g.calendar)) @bp.get("/add") @require_admin async def add_form(**kwargs): from sxc.pages.slots import render_slot_add_form - return sx_response(await render_slot_add_form(g.calendar)) + return sx_response(render_slot_add_form(g.calendar)) @bp.get("/add-button") @require_admin async def add_button(**kwargs): from sxc.pages.slots import render_slot_add_button - return sx_response(await render_slot_add_button(g.calendar)) + return sx_response(render_slot_add_button(g.calendar)) return bp diff --git a/events/bp/ticket_admin/routes.py b/events/bp/ticket_admin/routes.py index 88d14b8..6db06ae 100644 --- a/events/bp/ticket_admin/routes.py +++ b/events/bp/ticket_admin/routes.py @@ -54,7 +54,7 @@ def register() -> Blueprint: tickets = await get_tickets_for_entry(g.s, entry_id) from sxc.pages.tickets import render_entry_tickets_admin - html = await render_entry_tickets_admin(entry, tickets) + html = render_entry_tickets_admin(entry, tickets) return sx_response(html) @bp.get("/lookup/") @@ -71,9 +71,9 @@ def register() -> Blueprint: ticket = await get_ticket_by_code(g.s, code) from sxc.pages.tickets import render_lookup_result if not ticket: - return sx_response(await render_lookup_result(None, "Ticket not found")) + return sx_response(render_lookup_result(None, "Ticket not found")) - return sx_response(await render_lookup_result(ticket, None)) + return sx_response(render_lookup_result(ticket, None)) @bp.post("//checkin/") @require_admin @@ -84,9 +84,9 @@ def register() -> Blueprint: from sxc.pages.tickets import render_checkin_result if not success: - return sx_response(await render_checkin_result(False, error, None)) + return sx_response(render_checkin_result(False, error, None)) ticket = await get_ticket_by_code(g.s, code) - return sx_response(await render_checkin_result(True, None, ticket)) + return sx_response(render_checkin_result(True, None, ticket)) return bp diff --git a/events/bp/ticket_type/routes.py b/events/bp/ticket_type/routes.py index 64f2624..ba72d8a 100644 --- a/events/bp/ticket_type/routes.py +++ b/events/bp/ticket_type/routes.py @@ -32,7 +32,7 @@ def register(): from sxc.pages.tickets import render_ticket_type_edit_form va = request.view_args or {} - return sx_response(await render_ticket_type_edit_form( + return sx_response(render_ticket_type_edit_form( ticket_type, g.entry, g.calendar, va.get("day"), va.get("month"), va.get("year"), )) @@ -47,7 +47,7 @@ def register(): from sxc.pages.tickets import render_ticket_type_main_panel va = request.view_args or {} - return sx_response(await render_ticket_type_main_panel( + return sx_response(render_ticket_type_main_panel( ticket_type, g.entry, g.calendar, va.get("day"), va.get("month"), va.get("year"), )) @@ -114,7 +114,7 @@ def register(): # Return updated view with OOB flag from sxc.pages.tickets import render_ticket_type_main_panel va = request.view_args or {} - return sx_response(await render_ticket_type_main_panel( + return sx_response(render_ticket_type_main_panel( ticket_type, g.entry, g.calendar, va.get("day"), va.get("month"), va.get("year"), oob=True, @@ -133,7 +133,7 @@ def register(): ticket_types = await svc_list_ticket_types(g.s, g.entry.id) from sxc.pages.tickets import render_ticket_types_table va = request.view_args or {} - return sx_response(await render_ticket_types_table( + return sx_response(render_ticket_types_table( ticket_types, g.entry, g.calendar, va.get("day"), va.get("month"), va.get("year"), )) diff --git a/events/bp/ticket_types/routes.py b/events/bp/ticket_types/routes.py index 05f55b4..abd4a1f 100644 --- a/events/bp/ticket_types/routes.py +++ b/events/bp/ticket_types/routes.py @@ -95,7 +95,7 @@ def register(): ticket_types = await svc_list_ticket_types(g.s, g.entry.id) from sxc.pages.tickets import render_ticket_types_table va = request.view_args or {} - return sx_response(await render_ticket_types_table( + return sx_response(render_ticket_types_table( ticket_types, g.entry, g.calendar, va.get("day"), va.get("month"), va.get("year"), )) @@ -106,7 +106,7 @@ def register(): """Show the add ticket type form.""" from sxc.pages.tickets import render_ticket_type_add_form va = request.view_args or {} - return sx_response(await render_ticket_type_add_form( + return sx_response(render_ticket_type_add_form( g.entry, g.calendar, va.get("day"), va.get("month"), va.get("year"), )) @@ -117,7 +117,7 @@ def register(): """Show the add ticket type button.""" from sxc.pages.tickets import render_ticket_type_add_button va = request.view_args or {} - return sx_response(await render_ticket_type_add_button( + return sx_response(render_ticket_type_add_button( g.entry, g.calendar, va.get("day"), va.get("month"), va.get("year"), )) diff --git a/events/bp/tickets/routes.py b/events/bp/tickets/routes.py index f480e32..e7a6e36 100644 --- a/events/bp/tickets/routes.py +++ b/events/bp/tickets/routes.py @@ -127,7 +127,7 @@ def register() -> Blueprint: cart_count = summary.count + summary.calendar_count + summary.ticket_count from sxc.pages.tickets import render_buy_result - return sx_response(await render_buy_result(entry, created, remaining, cart_count)) + return sx_response(render_buy_result(entry, created, remaining, cart_count)) @bp.post("/adjust/") @clear_cache(tag="calendars", tag_scope="all") @@ -250,7 +250,7 @@ def register() -> Blueprint: cart_count = summary.count + summary.calendar_count + summary.ticket_count from sxc.pages.tickets import render_adjust_response - return sx_response(await render_adjust_response( + return sx_response(render_adjust_response( entry, ticket_remaining, ticket_sold_count, user_ticket_count, user_ticket_counts_by_type, cart_count, )) diff --git a/events/sxc/pages/calendar.py b/events/sxc/pages/calendar.py index 28bb587..d4d90a4 100644 --- a/events/sxc/pages/calendar.py +++ b/events/sxc/pages/calendar.py @@ -2,7 +2,7 @@ from __future__ import annotations from shared.sx.helpers import ( - call_url, render_to_sx, render_to_sx_with_env, + call_url, sx_call, render_to_sx_with_env, post_admin_header_sx, ) from shared.sx.parser import SxExpr @@ -23,7 +23,7 @@ async def _post_header_sx(ctx: dict, *, oob: bool = False) -> str: return await post_header_sx(ctx, oob=oob) -async def _post_nav_sx(ctx: dict) -> str: +def _post_nav_sx(ctx: dict) -> str: """Post desktop nav: calendar links + container nav (markets, etc.).""" from quart import url_for, g @@ -37,7 +37,7 @@ async def _post_nav_sx(ctx: dict) -> str: cal_name = getattr(cal, "name", "") if hasattr(cal, "name") else cal.get("name", "") href = url_for("calendar.get", calendar_slug=cal_slug) is_sel = (cal_slug == current_cal_slug) - parts.append(await render_to_sx("nav-link", href=href, icon="fa fa-calendar", + parts.append(sx_call("nav-link", href=href, icon="fa fa-calendar", label=cal_name, select_colours=select_colours, is_selected=is_sel)) # Container nav fragments (markets, etc.) @@ -72,13 +72,13 @@ async def _post_nav_sx(ctx: dict) -> str: # Calendars header # --------------------------------------------------------------------------- -async def _calendars_header_sx(ctx: dict, *, oob: bool = False) -> str: +def _calendars_header_sx(ctx: dict, *, oob: bool = False) -> str: """Build the calendars section header row.""" from quart import url_for link_href = url_for("calendars.home") - return await render_to_sx("menu-row-sx", id="calendars-row", level=3, + return sx_call("menu-row-sx", id="calendars-row", level=3, link_href=link_href, - link_label_content=SxExpr(await render_to_sx("events-calendars-label")), + link_label_content=SxExpr(sx_call("events-calendars-label")), child_id="calendars-header-child", oob=oob) @@ -86,7 +86,7 @@ async def _calendars_header_sx(ctx: dict, *, oob: bool = False) -> str: # Calendar header # --------------------------------------------------------------------------- -async def _calendar_header_sx(ctx: dict, *, oob: bool = False) -> str: +def _calendar_header_sx(ctx: dict, *, oob: bool = False) -> str: """Build a single calendar's header row.""" from quart import url_for calendar = ctx.get("calendar") @@ -97,18 +97,18 @@ async def _calendar_header_sx(ctx: dict, *, oob: bool = False) -> str: cal_desc = getattr(calendar, "description", "") or "" link_href = url_for("calendar.get", calendar_slug=cal_slug) - label_html = await render_to_sx("events-calendar-label", + label_html = sx_call("events-calendar-label", name=cal_name, description=cal_desc) # Desktop nav: slots + admin - nav_html = await _calendar_nav_sx(ctx) + nav_html = _calendar_nav_sx(ctx) - return await render_to_sx("menu-row-sx", id="calendar-row", level=3, + return sx_call("menu-row-sx", id="calendar-row", level=3, link_href=link_href, link_label_content=SxExpr(label_html), nav=SxExpr(nav_html) if nav_html else None, child_id="calendar-header-child", oob=oob) -async def _calendar_nav_sx(ctx: dict) -> str: +def _calendar_nav_sx(ctx: dict) -> str: """Calendar desktop nav: Slots + admin link.""" from quart import url_for calendar = ctx.get("calendar") @@ -121,11 +121,11 @@ async def _calendar_nav_sx(ctx: dict) -> str: parts = [] slots_href = url_for("defpage_slots_listing", calendar_slug=cal_slug) - parts.append(await render_to_sx("nav-link", href=slots_href, icon="fa fa-clock", + parts.append(sx_call("nav-link", href=slots_href, icon="fa fa-clock", label="Slots", select_colours=select_colours)) if is_admin: admin_href = url_for("defpage_calendar_admin", calendar_slug=cal_slug) - parts.append(await render_to_sx("nav-link", href=admin_href, icon="fa fa-cog", + parts.append(sx_call("nav-link", href=admin_href, icon="fa fa-cog", select_colours=select_colours)) return "(<> " + " ".join(parts) + ")" if parts else "" @@ -134,7 +134,7 @@ async def _calendar_nav_sx(ctx: dict) -> str: # Day header # --------------------------------------------------------------------------- -async def _day_header_sx(ctx: dict, *, oob: bool = False) -> str: +def _day_header_sx(ctx: dict, *, oob: bool = False) -> str: """Build day detail header row.""" from quart import url_for calendar = ctx.get("calendar") @@ -152,17 +152,17 @@ async def _day_header_sx(ctx: dict, *, oob: bool = False) -> str: month=day_date.month, day=day_date.day, ) - label_html = await render_to_sx("events-day-label", + label_html = sx_call("events-day-label", date_str=day_date.strftime("%A %d %B %Y")) - nav_html = await _day_nav_sx(ctx) + nav_html = _day_nav_sx(ctx) - return await render_to_sx("menu-row-sx", id="day-row", level=4, + return sx_call("menu-row-sx", id="day-row", level=4, link_href=link_href, link_label_content=SxExpr(label_html), nav=SxExpr(nav_html) if nav_html else None, child_id="day-header-child", oob=oob) -async def _day_nav_sx(ctx: dict) -> str: +def _day_nav_sx(ctx: dict) -> str: """Day desktop nav: confirmed entries scrolling menu + admin link.""" from quart import url_for calendar = ctx.get("calendar") @@ -189,11 +189,11 @@ async def _day_nav_sx(ctx: dict) -> str: ) start = entry.start_at.strftime("%H:%M") if entry.start_at else "" end = f" \u2013 {entry.end_at.strftime('%H:%M')}" if entry.end_at else "" - entry_links.append(await render_to_sx("events-day-entry-link", + entry_links.append(sx_call("events-day-entry-link", href=href, name=entry.name, time_str=f"{start}{end}")) inner = "".join(entry_links) - parts.append(await render_to_sx("events-day-entries-nav", inner=SxExpr(inner))) + parts.append(sx_call("events-day-entries-nav", inner=SxExpr(inner))) if is_admin and day_date: admin_href = url_for( @@ -203,7 +203,7 @@ async def _day_nav_sx(ctx: dict) -> str: month=day_date.month, day=day_date.day, ) - parts.append(await render_to_sx("nav-link", href=admin_href, icon="fa fa-cog")) + parts.append(sx_call("nav-link", href=admin_href, icon="fa fa-cog")) return "".join(parts) @@ -211,7 +211,7 @@ async def _day_nav_sx(ctx: dict) -> str: # Day admin header # --------------------------------------------------------------------------- -async def _day_admin_header_sx(ctx: dict, *, oob: bool = False) -> str: +def _day_admin_header_sx(ctx: dict, *, oob: bool = False) -> str: """Build day admin header row.""" from quart import url_for calendar = ctx.get("calendar") @@ -229,7 +229,7 @@ async def _day_admin_header_sx(ctx: dict, *, oob: bool = False) -> str: month=day_date.month, day=day_date.day, ) - return await render_to_sx("menu-row-sx", id="day-admin-row", level=5, + return sx_call("menu-row-sx", id="day-admin-row", level=5, link_href=link_href, link_label="admin", icon="fa fa-cog", child_id="day-admin-header-child", oob=oob) @@ -238,7 +238,7 @@ async def _day_admin_header_sx(ctx: dict, *, oob: bool = False) -> str: # Calendar admin header # --------------------------------------------------------------------------- -async def _calendar_admin_header_sx(ctx: dict, *, oob: bool = False) -> str: +def _calendar_admin_header_sx(ctx: dict, *, oob: bool = False) -> str: """Build calendar admin header row with nav links.""" from quart import url_for calendar = ctx.get("calendar") @@ -252,11 +252,11 @@ async def _calendar_admin_header_sx(ctx: dict, *, oob: bool = False) -> str: ("calendar.admin.calendar_description_edit", "description"), ]: href = url_for(endpoint, calendar_slug=cal_slug) - nav_parts.append(await render_to_sx("nav-link", href=href, label=label, + nav_parts.append(sx_call("nav-link", href=href, label=label, select_colours=select_colours)) nav_html = "".join(nav_parts) - return await render_to_sx("menu-row-sx", id="calendar-admin-row", level=4, + return sx_call("menu-row-sx", id="calendar-admin-row", level=4, link_label="admin", icon="fa fa-cog", nav=SxExpr(nav_html) if nav_html else None, child_id="calendar-admin-header-child", oob=oob) @@ -265,13 +265,13 @@ async def _calendar_admin_header_sx(ctx: dict, *, oob: bool = False) -> str: # Markets header # --------------------------------------------------------------------------- -async def _markets_header_sx(ctx: dict, *, oob: bool = False) -> str: +def _markets_header_sx(ctx: dict, *, oob: bool = False) -> str: """Build the markets section header row.""" from quart import url_for link_href = url_for("defpage_events_markets") - return await render_to_sx("menu-row-sx", id="markets-row", level=3, + return sx_call("menu-row-sx", id="markets-row", level=3, link_href=link_href, - link_label_content=SxExpr(await render_to_sx("events-markets-label")), + link_label_content=SxExpr(sx_call("events-markets-label")), child_id="markets-header-child", oob=oob) @@ -279,7 +279,7 @@ async def _markets_header_sx(ctx: dict, *, oob: bool = False) -> str: # Calendars main panel # --------------------------------------------------------------------------- -async def _calendars_main_panel_sx(ctx: dict) -> str: +def _calendars_main_panel_sx(ctx: dict) -> str: """Render the calendars list + create form panel.""" from quart import url_for rights = ctx.get("rights") or {} @@ -294,18 +294,18 @@ async def _calendars_main_panel_sx(ctx: dict) -> str: form_html = "" if can_create: create_url = url_for("calendars.create_calendar") - form_html = await render_to_sx("crud-create-form", + form_html = sx_call("crud-create-form", create_url=create_url, csrf=csrf, errors_id="cal-create-errors", list_id="calendars-list", placeholder="e.g. Events, Gigs, Meetings", btn_label="Add calendar") - list_html = await _calendars_list_sx(ctx, calendars) - return await render_to_sx("crud-panel", + list_html = _calendars_list_sx(ctx, calendars) + return sx_call("crud-panel", form=SxExpr(form_html), list=SxExpr(list_html), list_id="calendars-list") -async def _calendars_list_sx(ctx: dict, calendars: list) -> str: +def _calendars_list_sx(ctx: dict, calendars: list) -> str: """Render the calendars list items.""" from quart import url_for from shared.utils import route_prefix @@ -314,7 +314,7 @@ async def _calendars_list_sx(ctx: dict, calendars: list) -> str: prefix = route_prefix() if not calendars: - return await render_to_sx("empty-state", message="No calendars yet. Create one above.", + return sx_call("empty-state", message="No calendars yet. Create one above.", cls="text-gray-500 mt-4") parts = [] @@ -324,7 +324,7 @@ async def _calendars_list_sx(ctx: dict, calendars: list) -> str: href = prefix + url_for("calendar.get", calendar_slug=cal_slug) del_url = url_for("calendar.delete", calendar_slug=cal_slug) csrf_hdr = f'{{"X-CSRFToken":"{csrf}"}}' - parts.append(await render_to_sx("crud-item", + parts.append(sx_call("crud-item", href=href, name=cal_name, slug=cal_slug, del_url=del_url, csrf_hdr=csrf_hdr, list_id="calendars-list", @@ -337,7 +337,7 @@ async def _calendars_list_sx(ctx: dict, calendars: list) -> str: # Calendar month grid # --------------------------------------------------------------------------- -async def _calendar_main_panel_html(ctx: dict) -> str: +def _calendar_main_panel_html(ctx: dict) -> str: """Render the calendar month grid.""" from quart import url_for from quart import session as qsession @@ -375,10 +375,10 @@ async def _calendar_main_panel_html(ctx: dict) -> str: ("\u2039", prev_month_year, prev_month), ]: href = nav_link(yr, mn) - nav_arrows.append(await render_to_sx("events-calendar-nav-arrow", + nav_arrows.append(sx_call("events-calendar-nav-arrow", pill_cls=pill_cls, href=href, label=label)) - nav_arrows.append(await render_to_sx("events-calendar-month-label", + nav_arrows.append(sx_call("events-calendar-month-label", month_name=month_name, year=str(year))) for label, yr, mn in [ @@ -386,13 +386,13 @@ async def _calendar_main_panel_html(ctx: dict) -> str: ("\u00bb", next_year, month), ]: href = nav_link(yr, mn) - nav_arrows.append(await render_to_sx("events-calendar-nav-arrow", + nav_arrows.append(sx_call("events-calendar-nav-arrow", pill_cls=pill_cls, href=href, label=label)) # Weekday headers wd_parts = [] for wd in weekday_names: - wd_parts.append(await render_to_sx("events-calendar-weekday", name=wd)) + wd_parts.append(sx_call("events-calendar-weekday", name=wd)) wd_html = "".join(wd_parts) # Day cells @@ -423,9 +423,9 @@ async def _calendar_main_panel_html(ctx: dict) -> str: calendar_slug=cal_slug, year=day_date.year, month=day_date.month, day=day_date.day, ) - day_short_html = await render_to_sx("events-calendar-day-short", + day_short_html = sx_call("events-calendar-day-short", day_str=day_date.strftime("%a")) - day_num_html = await render_to_sx("events-calendar-day-num", + day_num_html = sx_call("events-calendar-day-num", pill_cls=pill_cls, href=day_href, num=str(day_date.day)) @@ -443,12 +443,12 @@ async def _calendar_main_panel_html(ctx: dict) -> str: else: bg_cls = "bg-sky-100 text-sky-800" if is_mine else "bg-stone-100 text-stone-700" state_label = (e.state or "pending").replace("_", " ") - entry_badges.append(await render_to_sx("events-calendar-entry-badge", + entry_badges.append(sx_call("events-calendar-entry-badge", bg_cls=bg_cls, name=e.name, state_label=state_label)) badges_html = "(<> " + "".join(entry_badges) + ")" if entry_badges else "" - cells.append(await render_to_sx("events-calendar-cell", + cells.append(sx_call("events-calendar-cell", cell_cls=cell_cls, day_short=SxExpr(day_short_html), day_num=SxExpr(day_num_html), badges=SxExpr(badges_html) if badges_html else None)) @@ -456,7 +456,7 @@ async def _calendar_main_panel_html(ctx: dict) -> str: cells_html = "(<> " + "".join(cells) + ")" arrows_html = "(<> " + "".join(nav_arrows) + ")" wd_html = "(<> " + wd_html + ")" - return await render_to_sx("events-calendar-grid", + return sx_call("events-calendar-grid", arrows=SxExpr(arrows_html), weekdays=SxExpr(wd_html), cells=SxExpr(cells_html)) @@ -465,7 +465,7 @@ async def _calendar_main_panel_html(ctx: dict) -> str: # Day main panel # --------------------------------------------------------------------------- -async def _day_main_panel_html(ctx: dict) -> str: +def _day_main_panel_html(ctx: dict) -> str: """Render the day entries table + add button.""" from quart import url_for @@ -488,10 +488,10 @@ async def _day_main_panel_html(ctx: dict) -> str: if day_entries: row_parts = [] for entry in day_entries: - row_parts.append(await _day_row_html(ctx, entry)) + row_parts.append(_day_row_html(ctx, entry)) rows_html = "".join(row_parts) else: - rows_html = await render_to_sx("events-day-empty-row") + rows_html = sx_call("events-day-empty-row") add_url = url_for( "calendar.day.calendar_entries.add_form", @@ -499,12 +499,12 @@ async def _day_main_panel_html(ctx: dict) -> str: day=day, month=month, year=year, ) - return await render_to_sx("events-day-table", + return sx_call("events-day-table", list_container=list_container, rows=SxExpr(rows_html), pre_action=pre_action, add_url=add_url) -async def _day_row_html(ctx: dict, entry) -> str: +def _day_row_html(ctx: dict, entry) -> str: """Render a single day table row.""" from quart import url_for calendar = ctx.get("calendar") @@ -523,7 +523,7 @@ async def _day_row_html(ctx: dict, entry) -> str: ) # Name - name_html = await render_to_sx("events-day-row-name", + name_html = sx_call("events-day-row-name", href=entry_href, pill_cls=pill_cls, name=entry.name) # Slot/Time @@ -532,38 +532,38 @@ async def _day_row_html(ctx: dict, entry) -> str: slot_href = url_for("defpage_slot_detail", calendar_slug=cal_slug, slot_id=slot.id) time_start = slot.time_start.strftime("%H:%M") if slot.time_start else "" time_end = f" \u2192 {slot.time_end.strftime('%H:%M')}" if slot.time_end else "" - slot_html = await render_to_sx("events-day-row-slot", + slot_html = sx_call("events-day-row-slot", href=slot_href, pill_cls=pill_cls, slot_name=slot.name, time_str=f"({time_start}{time_end})") else: start = entry.start_at.strftime("%H:%M") if entry.start_at else "" end = f" \u2192 {entry.end_at.strftime('%H:%M')}" if entry.end_at else "" - slot_html = await render_to_sx("events-day-row-time", start=start, end=end) + slot_html = sx_call("events-day-row-time", start=start, end=end) # State state = getattr(entry, "state", "pending") or "pending" - state_badge = await _entry_state_badge_html(state) - state_td = await render_to_sx("events-day-row-state", + state_badge = _entry_state_badge_html(state) + state_td = sx_call("events-day-row-state", state_id=f"entry-state-{entry.id}", badge=SxExpr(state_badge)) # Cost cost = getattr(entry, "cost", None) cost_str = f"\u00a3{cost:.2f}" if cost is not None else "\u00a30.00" - cost_td = await render_to_sx("events-day-row-cost", cost_str=cost_str) + cost_td = sx_call("events-day-row-cost", cost_str=cost_str) # Tickets tp = getattr(entry, "ticket_price", None) if tp is not None: tc = getattr(entry, "ticket_count", None) tc_str = f"{tc} tickets" if tc is not None else "Unlimited" - tickets_td = await render_to_sx("events-day-row-tickets", + tickets_td = sx_call("events-day-row-tickets", price_str=f"\u00a3{tp:.2f}", count_str=tc_str) else: - tickets_td = await render_to_sx("events-day-row-no-tickets") + tickets_td = sx_call("events-day-row-no-tickets") - actions_td = await render_to_sx("events-day-row-actions") + actions_td = sx_call("events-day-row-actions") - return await render_to_sx("events-day-row", + return sx_call("events-day-row", tr_cls=tr_cls, name=SxExpr(name_html), slot=SxExpr(slot_html), state=SxExpr(state_td), cost=SxExpr(cost_td), tickets=SxExpr(tickets_td), actions=SxExpr(actions_td)) @@ -573,16 +573,16 @@ async def _day_row_html(ctx: dict, entry) -> str: # Day admin main panel # --------------------------------------------------------------------------- -async def _day_admin_main_panel_html(ctx: dict) -> str: +def _day_admin_main_panel_html(ctx: dict) -> str: """Render day admin panel (placeholder nav).""" - return await render_to_sx("events-day-admin-panel") + return sx_call("events-day-admin-panel") # --------------------------------------------------------------------------- # Calendar admin main panel # --------------------------------------------------------------------------- -async def _calendar_admin_main_panel_html(ctx: dict) -> str: +def _calendar_admin_main_panel_html(ctx: dict) -> str: """Render calendar admin config panel with description editor.""" from quart import url_for calendar = ctx.get("calendar") @@ -595,17 +595,17 @@ async def _calendar_admin_main_panel_html(ctx: dict) -> str: hx_select = ctx.get("hx_select_search", "#main-panel") desc_edit_url = url_for("calendar.admin.calendar_description_edit", calendar_slug=cal_slug) - description_html = await _calendar_description_display_html(calendar, desc_edit_url) + description_html = _calendar_description_display_html(calendar, desc_edit_url) - return await render_to_sx("events-calendar-admin-panel", + return sx_call("events-calendar-admin-panel", description_content=SxExpr(description_html), csrf=csrf, description=desc) -async def _calendar_description_display_html(calendar, edit_url: str) -> str: +def _calendar_description_display_html(calendar, edit_url: str) -> str: """Render calendar description display with edit button.""" desc = getattr(calendar, "description", "") or "" - return await render_to_sx("events-calendar-description-display", + return sx_call("events-calendar-description-display", description=desc, edit_url=edit_url) @@ -613,7 +613,7 @@ async def _calendar_description_display_html(calendar, edit_url: str) -> str: # Markets main panel # --------------------------------------------------------------------------- -async def _markets_main_panel_html(ctx: dict) -> str: +def _markets_main_panel_html(ctx: dict) -> str: """Render markets list + create form panel.""" from quart import url_for rights = ctx.get("rights") or {} @@ -627,18 +627,18 @@ async def _markets_main_panel_html(ctx: dict) -> str: form_html = "" if can_create: create_url = url_for("markets.create_market") - form_html = await render_to_sx("crud-create-form", + form_html = sx_call("crud-create-form", create_url=create_url, csrf=csrf, errors_id="market-create-errors", list_id="markets-list", placeholder="e.g. Farm Shop, Bakery", btn_label="Add market") - list_html = await _markets_list_html(ctx, markets) - return await render_to_sx("crud-panel", + list_html = _markets_list_html(ctx, markets) + return sx_call("crud-panel", form=SxExpr(form_html), list=SxExpr(list_html), list_id="markets-list") -async def _markets_list_html(ctx: dict, markets: list) -> str: +def _markets_list_html(ctx: dict, markets: list) -> str: """Render markets list items.""" from quart import url_for csrf_token = ctx.get("csrf_token") @@ -647,7 +647,7 @@ async def _markets_list_html(ctx: dict, markets: list) -> str: slug = post.get("slug", "") if not markets: - return await render_to_sx("empty-state", message="No markets yet. Create one above.", + return sx_call("empty-state", message="No markets yet. Create one above.", cls="text-gray-500 mt-4") parts = [] @@ -657,7 +657,7 @@ async def _markets_list_html(ctx: dict, markets: list) -> str: market_href = call_url(ctx, "market_url", f"/{slug}/{m_slug}/") del_url = url_for("markets.delete_market", market_slug=m_slug) csrf_hdr = f'{{"X-CSRFToken":"{csrf}"}}' - parts.append(await render_to_sx("crud-item", + parts.append(sx_call("crud-item", href=market_href, name=m_name, slug=m_slug, del_url=del_url, csrf_hdr=csrf_hdr, diff --git a/events/sxc/pages/entries.py b/events/sxc/pages/entries.py index 5c9bdec..b8cd573 100644 --- a/events/sxc/pages/entries.py +++ b/events/sxc/pages/entries.py @@ -3,7 +3,7 @@ from __future__ import annotations from markupsafe import escape -from shared.sx.helpers import render_to_sx, render_to_sx_with_env +from shared.sx.helpers import sx_call from shared.sx.parser import SxExpr from .utils import ( @@ -16,7 +16,7 @@ from .utils import ( # All events / page summary entry cards # --------------------------------------------------------------------------- -async def _entry_card_html(entry, page_info: dict, pending_tickets: dict, +def _entry_card_html(entry, page_info: dict, pending_tickets: dict, ticket_url: str, events_url_fn, *, is_page_scoped: bool = False, post: dict | None = None) -> str: """Render a list card for one event entry.""" @@ -35,36 +35,36 @@ async def _entry_card_html(entry, page_info: dict, pending_tickets: dict, # Title (linked or plain) if entry_href: - title_html = await render_to_sx("events-entry-title-linked", + title_html = sx_call("events-entry-title-linked", href=entry_href, name=entry.name) else: - title_html = await render_to_sx("events-entry-title-plain", name=entry.name) + title_html = sx_call("events-entry-title-plain", name=entry.name) # Badges badges_html = "" if page_title and (not is_page_scoped or page_title != (post or {}).get("title")): page_href = events_url_fn(f"/{page_slug}/") - badges_html += await render_to_sx("events-entry-page-badge", + badges_html += sx_call("events-entry-page-badge", href=page_href, title=page_title) cal_name = getattr(entry, "calendar_name", "") if cal_name: - badges_html += await render_to_sx("events-entry-cal-badge", name=cal_name) + badges_html += sx_call("events-entry-cal-badge", name=cal_name) # Time line time_parts = "" if day_href and not is_page_scoped: - time_parts += await render_to_sx("events-entry-time-linked", + time_parts += sx_call("events-entry-time-linked", href=day_href, date_str=entry.start_at.strftime("%a %-d %b")) elif not is_page_scoped: - time_parts += await render_to_sx("events-entry-time-plain", + time_parts += sx_call("events-entry-time-plain", date_str=entry.start_at.strftime("%a %-d %b")) time_parts += entry.start_at.strftime("%H:%M") if entry.end_at: time_parts += f' \u2013 {entry.end_at.strftime("%H:%M")}' cost = getattr(entry, "cost", None) - cost_html = await render_to_sx("events-entry-cost", + cost_html = sx_call("events-entry-cost", cost=f"\u00a3{cost:.2f}") if cost else "" # Ticket widget @@ -72,16 +72,16 @@ async def _entry_card_html(entry, page_info: dict, pending_tickets: dict, widget_html = "" if tp is not None: qty = pending_tickets.get(entry.id, 0) - widget_html = await render_to_sx("events-entry-widget-wrapper", - widget=SxExpr(await _ticket_widget_html(entry, qty, ticket_url, ctx={}))) + widget_html = sx_call("events-entry-widget-wrapper", + widget=SxExpr(_ticket_widget_html(entry, qty, ticket_url, ctx={}))) - return await render_to_sx("events-entry-card", + return sx_call("events-entry-card", title=SxExpr(title_html), badges=SxExpr(badges_html), time_parts=SxExpr(time_parts), cost=SxExpr(cost_html), widget=SxExpr(widget_html)) -async def _entry_card_tile_html(entry, page_info: dict, pending_tickets: dict, +def _entry_card_tile_html(entry, page_info: dict, pending_tickets: dict, ticket_url: str, events_url_fn, *, is_page_scoped: bool = False, post: dict | None = None) -> str: """Render a tile card for one event entry.""" @@ -100,25 +100,25 @@ async def _entry_card_tile_html(entry, page_info: dict, pending_tickets: dict, # Title if entry_href: - title_html = await render_to_sx("events-entry-title-tile-linked", + title_html = sx_call("events-entry-title-tile-linked", href=entry_href, name=entry.name) else: - title_html = await render_to_sx("events-entry-title-tile-plain", name=entry.name) + title_html = sx_call("events-entry-title-tile-plain", name=entry.name) # Badges badges_html = "" if page_title and (not is_page_scoped or page_title != (post or {}).get("title")): page_href = events_url_fn(f"/{page_slug}/") - badges_html += await render_to_sx("events-entry-page-badge", + badges_html += sx_call("events-entry-page-badge", href=page_href, title=page_title) cal_name = getattr(entry, "calendar_name", "") if cal_name: - badges_html += await render_to_sx("events-entry-cal-badge", name=cal_name) + badges_html += sx_call("events-entry-cal-badge", name=cal_name) # Time time_html = "" if day_href: - time_html += (await render_to_sx("events-entry-time-linked", + time_html += (sx_call("events-entry-time-linked", href=day_href, date_str=entry.start_at.strftime("%a %-d %b"))).replace(" · ", "") else: @@ -128,7 +128,7 @@ async def _entry_card_tile_html(entry, page_info: dict, pending_tickets: dict, time_html += f' \u2013 {entry.end_at.strftime("%H:%M")}' cost = getattr(entry, "cost", None) - cost_html = await render_to_sx("events-entry-cost", + cost_html = sx_call("events-entry-cost", cost=f"\u00a3{cost:.2f}") if cost else "" # Ticket widget @@ -136,16 +136,16 @@ async def _entry_card_tile_html(entry, page_info: dict, pending_tickets: dict, widget_html = "" if tp is not None: qty = pending_tickets.get(entry.id, 0) - widget_html = await render_to_sx("events-entry-tile-widget-wrapper", - widget=SxExpr(await _ticket_widget_html(entry, qty, ticket_url, ctx={}))) + widget_html = sx_call("events-entry-tile-widget-wrapper", + widget=SxExpr(_ticket_widget_html(entry, qty, ticket_url, ctx={}))) - return await render_to_sx("events-entry-card-tile", + return sx_call("events-entry-card-tile", title=SxExpr(title_html), badges=SxExpr(badges_html), time=SxExpr(time_html), cost=SxExpr(cost_html), widget=SxExpr(widget_html)) -async def _entry_cards_html(entries, page_info, pending_tickets, ticket_url, +def _entry_cards_html(entries, page_info, pending_tickets, ticket_url, events_url_fn, view, page, has_more, next_url, *, is_page_scoped=False, post=None) -> str: """Render entry cards (list or tile) with sentinel.""" @@ -153,23 +153,23 @@ async def _entry_cards_html(entries, page_info, pending_tickets, ticket_url, last_date = None for entry in entries: if view == "tile": - parts.append(await _entry_card_tile_html( + parts.append(_entry_card_tile_html( entry, page_info, pending_tickets, ticket_url, events_url_fn, is_page_scoped=is_page_scoped, post=post, )) else: entry_date = entry.start_at.strftime("%A %-d %B %Y") if entry.start_at else "" if entry_date != last_date: - parts.append(await render_to_sx("events-date-separator", + parts.append(sx_call("events-date-separator", date_str=entry_date)) last_date = entry_date - parts.append(await _entry_card_html( + parts.append(_entry_card_html( entry, page_info, pending_tickets, ticket_url, events_url_fn, is_page_scoped=is_page_scoped, post=post, )) if has_more: - parts.append(await render_to_sx("sentinel-simple", + parts.append(sx_call("sentinel-simple", id=f"sentinel-{page}", next_url=next_url)) return "".join(parts) @@ -178,27 +178,27 @@ async def _entry_cards_html(entries, page_info, pending_tickets, ticket_url, # All events / page summary main panels # --------------------------------------------------------------------------- -async def _events_main_panel_html(ctx: dict, entries, has_more, pending_tickets, page_info, +def _events_main_panel_html(ctx: dict, entries, has_more, pending_tickets, page_info, page, view, ticket_url, next_url, events_url_fn, *, is_page_scoped=False, post=None) -> str: """Render the events main panel with view toggle + cards.""" - toggle = await _view_toggle_html(ctx, view) + toggle = _view_toggle_html(ctx, view) if entries: - cards = await _entry_cards_html( + cards = _entry_cards_html( entries, page_info, pending_tickets, ticket_url, events_url_fn, view, page, has_more, next_url, is_page_scoped=is_page_scoped, post=post, ) 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") - body = await render_to_sx("events-grid", grid_cls=grid_cls, cards=SxExpr(cards)) + body = sx_call("events-grid", grid_cls=grid_cls, cards=SxExpr(cards)) else: - body = await render_to_sx("empty-state", icon="fa fa-calendar-xmark", + body = sx_call("empty-state", icon="fa fa-calendar-xmark", message="No upcoming events", cls="px-3 py-12 text-center text-stone-400") - return await render_to_sx("events-main-panel-body", + return sx_call("events-main-panel-body", toggle=SxExpr(toggle), body=SxExpr(body)) @@ -206,7 +206,7 @@ async def _events_main_panel_html(ctx: dict, entries, has_more, pending_tickets, # Entry main panel # --------------------------------------------------------------------------- -async def _entry_main_panel_html(ctx: dict) -> str: +def _entry_main_panel_html(ctx: dict) -> str: """Render the entry detail panel (name, slot, time, state, cost, tickets, buy form, date, posts, options + edit button).""" from quart import url_for @@ -228,63 +228,63 @@ async def _entry_main_panel_html(ctx: dict) -> str: eid = entry.id state = getattr(entry, "state", "pending") or "pending" - async def _field(label, content_html): - return await render_to_sx("events-entry-field", label=label, content=SxExpr(content_html)) + def _field(label, content_html): + return sx_call("events-entry-field", label=label, content=SxExpr(content_html)) # Name - name_html = await _field("Name", await render_to_sx("events-entry-name-field", name=entry.name)) + name_html = _field("Name", sx_call("events-entry-name-field", name=entry.name)) # Slot slot = getattr(entry, "slot", None) if slot: flex_label = "(flexible)" if getattr(slot, "flexible", False) else "(fixed)" - slot_inner = await render_to_sx("events-entry-slot-assigned", + slot_inner = sx_call("events-entry-slot-assigned", slot_name=slot.name, flex_label=flex_label) else: - slot_inner = await render_to_sx("events-entry-slot-none") - slot_html = await _field("Slot", slot_inner) + slot_inner = sx_call("events-entry-slot-none") + slot_html = _field("Slot", slot_inner) # Time Period start_str = entry.start_at.strftime("%H:%M") if entry.start_at else "" end_str = f" \u2013 {entry.end_at.strftime('%H:%M')}" if entry.end_at else " \u2013 open-ended" - time_html = await _field("Time Period", await render_to_sx("events-entry-time-field", + time_html = _field("Time Period", sx_call("events-entry-time-field", time_str=start_str + end_str)) # State - state_html = await _field("State", await render_to_sx("events-entry-state-field", + state_html = _field("State", sx_call("events-entry-state-field", entry_id=str(eid), - badge=SxExpr(await _entry_state_badge_html(state)))) + badge=SxExpr(_entry_state_badge_html(state)))) # Cost cost = getattr(entry, "cost", None) cost_str = f"{cost:.2f}" if cost is not None else "0.00" - cost_html = await _field("Cost", await render_to_sx("events-entry-cost-field", + cost_html = _field("Cost", sx_call("events-entry-cost-field", cost=f"\u00a3{cost_str}")) # Ticket Configuration (admin) - tickets_html = await _field("Tickets", await render_to_sx("events-entry-tickets-field", + tickets_html = _field("Tickets", sx_call("events-entry-tickets-field", entry_id=str(eid), - tickets_config=SxExpr(await render_entry_tickets_config(entry, calendar, day, month, year)))) + tickets_config=SxExpr(render_entry_tickets_config(entry, calendar, day, month, year)))) # Buy Tickets (public-facing) ticket_remaining = ctx.get("ticket_remaining") ticket_sold_count = ctx.get("ticket_sold_count", 0) user_ticket_count = ctx.get("user_ticket_count", 0) user_ticket_counts_by_type = ctx.get("user_ticket_counts_by_type") or {} - buy_html = await render_buy_form( + buy_html = render_buy_form( entry, ticket_remaining, ticket_sold_count, user_ticket_count, user_ticket_counts_by_type, ) # Date date_str = entry.start_at.strftime("%A, %B %d, %Y") if entry.start_at else "" - date_html = await _field("Date", await render_to_sx("events-entry-date-field", date_str=date_str)) + date_html = _field("Date", sx_call("events-entry-date-field", date_str=date_str)) # Associated Posts entry_posts = ctx.get("entry_posts") or [] - posts_html = await _field("Associated Posts", await render_to_sx("events-entry-posts-field", + posts_html = _field("Associated Posts", sx_call("events-entry-posts-field", entry_id=str(eid), - posts_panel=SxExpr(await render_entry_posts_panel(entry_posts, entry, calendar, day, month, year)))) + posts_panel=SxExpr(render_entry_posts_panel(entry_posts, entry, calendar, day, month, year)))) # Options and Edit Button edit_url = url_for( @@ -293,14 +293,14 @@ async def _entry_main_panel_html(ctx: dict) -> str: day=day, month=month, year=year, ) - return await render_to_sx("events-entry-panel", + return sx_call("events-entry-panel", entry_id=str(eid), list_container=list_container, name=SxExpr(name_html), slot=SxExpr(slot_html), time=SxExpr(time_html), state=SxExpr(state_html), cost=SxExpr(cost_html), tickets=SxExpr(tickets_html), buy=SxExpr(buy_html), date=SxExpr(date_html), posts=SxExpr(posts_html), - options=SxExpr(await _entry_options_html(entry, calendar, day, month, year)), + options=SxExpr(_entry_options_html(entry, calendar, day, month, year)), pre_action=pre_action, edit_url=edit_url) @@ -308,7 +308,7 @@ async def _entry_main_panel_html(ctx: dict) -> str: # Entry header row # --------------------------------------------------------------------------- -async def _entry_header_html(ctx: dict, *, oob: bool = False) -> str: +def _entry_header_html(ctx: dict, *, oob: bool = False) -> str: """Build entry detail header row.""" from quart import url_for @@ -329,19 +329,19 @@ async def _entry_header_html(ctx: dict, *, oob: bool = False) -> str: year=year, month=month, day=day, entry_id=entry.id, ) - label_html = await render_to_sx("events-entry-label", + label_html = sx_call("events-entry-label", entry_id=str(entry.id), - title=SxExpr(await _entry_title_html(entry)), - times=SxExpr(await _entry_times_html(entry))) + title=SxExpr(_entry_title_html(entry)), + times=SxExpr(_entry_times_html(entry))) - nav_html = await _entry_nav_html(ctx) + nav_html = _entry_nav_html(ctx) - return await render_to_sx("menu-row-sx", id="entry-row", level=5, + return sx_call("menu-row-sx", id="entry-row", level=5, link_href=link_href, link_label_content=SxExpr(label_html), nav=SxExpr(nav_html) if nav_html else None, child_id="entry-header-child", oob=oob) -async def _entry_times_html(entry) -> str: +def _entry_times_html(entry) -> str: """Render entry times label.""" start = entry.start_at end = entry.end_at @@ -349,14 +349,14 @@ async def _entry_times_html(entry) -> str: return "" start_str = start.strftime("%H:%M") end_str = f" \u2192 {end.strftime('%H:%M')}" if end else "" - return await render_to_sx("events-entry-times", time_str=start_str + end_str) + return sx_call("events-entry-times", time_str=start_str + end_str) # --------------------------------------------------------------------------- # Entry nav (desktop + admin link) # --------------------------------------------------------------------------- -async def _entry_nav_html(ctx: dict) -> str: +def _entry_nav_html(ctx: dict) -> str: """Entry desktop nav: associated posts scrolling menu + admin link.""" from quart import url_for @@ -387,12 +387,12 @@ async def _entry_nav_html(ctx: dict) -> str: feat = getattr(ep, "feature_image", None) href = blog_url_fn(f"/{slug}/") if blog_url_fn else f"/{slug}/" if feat: - img_html = await render_to_sx("events-post-img", src=feat, alt=title) + img_html = sx_call("events-post-img", src=feat, alt=title) else: - img_html = await render_to_sx("events-post-img-placeholder") - post_links += await render_to_sx("events-entry-nav-post-link", + img_html = sx_call("events-post-img-placeholder") + post_links += sx_call("events-entry-nav-post-link", href=href, img=SxExpr(img_html), title=title) - parts.append((await render_to_sx("events-entry-posts-nav-oob", + parts.append((sx_call("events-entry-posts-nav-oob", items=SxExpr(post_links))).replace(' :hx-swap-oob "true"', '')) # Admin link @@ -403,7 +403,7 @@ async def _entry_nav_html(ctx: dict) -> str: day=day, month=month, year=year, entry_id=entry.id, ) - parts.append(await render_to_sx("events-entry-admin-link", href=admin_url)) + parts.append(sx_call("events-entry-admin-link", href=admin_url)) return "".join(parts) @@ -412,26 +412,26 @@ async def _entry_nav_html(ctx: dict) -> str: # Entry optioned (confirm/decline/provisional response) # --------------------------------------------------------------------------- -async def render_entry_optioned(entry, calendar, day, month, year) -> str: +def render_entry_optioned(entry, calendar, day, month, year) -> str: """Render entry options buttons + OOB title & state swaps.""" - options = await _entry_options_html(entry, calendar, day, month, year) - title = await _entry_title_html(entry) - state = await _entry_state_badge_html(getattr(entry, "state", "pending") or "pending") + options = _entry_options_html(entry, calendar, day, month, year) + title = _entry_title_html(entry) + state = _entry_state_badge_html(getattr(entry, "state", "pending") or "pending") - return options + await render_to_sx("events-entry-optioned-oob", + return options + sx_call("events-entry-optioned-oob", entry_id=str(entry.id), title=SxExpr(title), state=SxExpr(state)) -async def _entry_title_html(entry) -> str: +def _entry_title_html(entry) -> str: """Render entry title (icon + name + state badge).""" state = getattr(entry, "state", "pending") or "pending" - return await render_to_sx("events-entry-title", + return sx_call("events-entry-title", name=entry.name, - badge=SxExpr(await _entry_state_badge_html(state))) + badge=SxExpr(_entry_state_badge_html(state))) -async def _entry_options_html(entry, calendar, day, month, year) -> str: +def _entry_options_html(entry, calendar, day, month, year) -> str: """Render confirm/decline/provisional buttons based on entry state.""" from quart import url_for, g from shared.browser.app.csrf import generate_csrf_token @@ -445,13 +445,13 @@ async def _entry_options_html(entry, calendar, day, month, year) -> str: state = getattr(entry, "state", "pending") or "pending" target = f"#calendar_entry_options_{eid}" - async def _make_button(action_name, label, confirm_title, confirm_text, *, trigger_type="submit"): + def _make_button(action_name, label, confirm_title, confirm_text, *, trigger_type="submit"): url = url_for( f"calendar.day.calendar_entries.calendar_entry.{action_name}", calendar_slug=cal_slug, day=day, month=month, year=year, entry_id=eid, ) btn_type = "button" if trigger_type == "button" else "submit" - return await render_to_sx("events-entry-option-button", + return sx_call("events-entry-option-button", url=url, target=target, csrf=csrf, btn_type=btn_type, action_btn=action_btn, confirm_title=confirm_title, confirm_text=confirm_text, label=label, @@ -459,22 +459,22 @@ async def _entry_options_html(entry, calendar, day, month, year) -> str: buttons_html = "" if state == "provisional": - buttons_html += await _make_button( + buttons_html += _make_button( "confirm_entry", "confirm", "Confirm entry?", "Are you sure you want to confirm this entry?", ) - buttons_html += await _make_button( + buttons_html += _make_button( "decline_entry", "decline", "Decline entry?", "Are you sure you want to decline this entry?", ) elif state == "confirmed": - buttons_html += await _make_button( + buttons_html += _make_button( "provisional_entry", "provisional", "Provisional entry?", "Are you sure you want to provisional this entry?", trigger_type="button", ) - return await render_to_sx("events-entry-options", + return sx_call("events-entry-options", entry_id=str(eid), buttons=SxExpr(buttons_html)) @@ -482,7 +482,7 @@ async def _entry_options_html(entry, calendar, day, month, year) -> str: # Entry tickets config (display + form) # --------------------------------------------------------------------------- -async def render_entry_tickets_config(entry, calendar, day, month, year) -> str: +def render_entry_tickets_config(entry, calendar, day, month, year) -> str: """Render ticket config display + edit form for admin entry view.""" from quart import url_for from shared.browser.app.csrf import generate_csrf_token @@ -499,11 +499,11 @@ async def render_entry_tickets_config(entry, calendar, day, month, year) -> str: if tp is not None: tc_str = f"{tc} tickets" if tc is not None else "Unlimited" - display_html = await render_to_sx("events-ticket-config-display", + display_html = sx_call("events-ticket-config-display", price_str=f"\u00a3{tp:.2f}", count_str=tc_str, show_js=show_js) else: - display_html = await render_to_sx("events-ticket-config-none", show_js=show_js) + display_html = sx_call("events-ticket-config-none", show_js=show_js) update_url = url_for( "calendar.day.calendar_entries.calendar_entry.update_tickets", @@ -513,7 +513,7 @@ async def render_entry_tickets_config(entry, calendar, day, month, year) -> str: tp_val = f"{tp:.2f}" if tp is not None else "" tc_val = str(tc) if tc is not None else "" - form_html = await render_to_sx("events-ticket-config-form", + form_html = sx_call("events-ticket-config-form", entry_id=eid_s, hidden_cls=hidden_cls, update_url=update_url, csrf=csrf, price_val=tp_val, count_val=tc_val, hide_js=hide_js) @@ -524,7 +524,7 @@ async def render_entry_tickets_config(entry, calendar, day, month, year) -> str: # Entry posts panel # --------------------------------------------------------------------------- -async def render_entry_posts_panel(entry_posts, entry, calendar, day, month, year) -> str: +def render_entry_posts_panel(entry_posts, entry, calendar, day, month, year) -> str: """Render associated posts list with remove buttons and search input.""" from quart import url_for from shared.browser.app.csrf import generate_csrf_token @@ -541,28 +541,28 @@ async def render_entry_posts_panel(entry_posts, entry, calendar, day, month, yea ep_title = getattr(ep, "title", "") ep_id = getattr(ep, "id", 0) feat = getattr(ep, "feature_image", None) - img_html = (await render_to_sx("events-post-img", src=feat, alt=ep_title) - if feat else await render_to_sx("events-post-img-placeholder")) + img_html = (sx_call("events-post-img", src=feat, alt=ep_title) + if feat else sx_call("events-post-img-placeholder")) del_url = url_for( "calendar.day.calendar_entries.calendar_entry.remove_post", calendar_slug=cal_slug, day=day, month=month, year=year, entry_id=eid, post_id=ep_id, ) - items += await render_to_sx("events-entry-post-item", + items += sx_call("events-entry-post-item", img=SxExpr(img_html), title=ep_title, del_url=del_url, entry_id=eid_s, csrf_hdr=f'{{"X-CSRFToken": "{csrf}"}}') - posts_html = await render_to_sx("events-entry-posts-list", items=SxExpr(items)) + posts_html = sx_call("events-entry-posts-list", items=SxExpr(items)) else: - posts_html = await render_to_sx("events-entry-posts-none") + posts_html = sx_call("events-entry-posts-none") search_url = url_for( "calendar.day.calendar_entries.calendar_entry.search_posts", calendar_slug=cal_slug, day=day, month=month, year=year, entry_id=eid, ) - return await render_to_sx("events-entry-posts-panel", + return sx_call("events-entry-posts-panel", posts=SxExpr(posts_html), search_url=search_url, entry_id=eid_s) @@ -571,7 +571,7 @@ async def render_entry_posts_panel(entry_posts, entry, calendar, day, month, yea # Entry posts nav OOB # --------------------------------------------------------------------------- -async def render_entry_posts_nav_oob(entry_posts) -> str: +def render_entry_posts_nav_oob(entry_posts) -> str: """Render OOB nav for entry posts (scrolling menu).""" from quart import g styles = getattr(g, "styles", None) or {} @@ -579,7 +579,7 @@ async def render_entry_posts_nav_oob(entry_posts) -> str: blog_url_fn = getattr(g, "blog_url", None) if not entry_posts: - return await render_to_sx("events-entry-posts-nav-oob-empty") + return sx_call("events-entry-posts-nav-oob-empty") items = "" for ep in entry_posts: @@ -587,20 +587,20 @@ async def render_entry_posts_nav_oob(entry_posts) -> str: title = getattr(ep, "title", "") feat = getattr(ep, "feature_image", None) href = blog_url_fn(f"/{slug}/") if blog_url_fn else f"/{slug}/" - img_html = (await render_to_sx("events-post-img", src=feat, alt=title) - if feat else await render_to_sx("events-post-img-placeholder")) - items += await render_to_sx("events-entry-nav-post", + img_html = (sx_call("events-post-img", src=feat, alt=title) + if feat else sx_call("events-post-img-placeholder")) + items += sx_call("events-entry-nav-post", href=href, nav_btn=nav_btn, img=SxExpr(img_html), title=title) - return await render_to_sx("events-entry-posts-nav-oob", items=SxExpr(items)) + return sx_call("events-entry-posts-nav-oob", items=SxExpr(items)) # --------------------------------------------------------------------------- # Day entries nav OOB # --------------------------------------------------------------------------- -async def render_day_entries_nav_oob(confirmed_entries, calendar, day_date) -> str: +def render_day_entries_nav_oob(confirmed_entries, calendar, day_date) -> str: """Render OOB nav for confirmed entries in a day.""" from quart import url_for, g @@ -609,7 +609,7 @@ async def render_day_entries_nav_oob(confirmed_entries, calendar, day_date) -> s cal_slug = getattr(calendar, "slug", "") if not confirmed_entries: - return await render_to_sx("events-day-entries-nav-oob-empty") + return sx_call("events-day-entries-nav-oob-empty") items = "" for entry in confirmed_entries: @@ -621,18 +621,18 @@ async def render_day_entries_nav_oob(confirmed_entries, calendar, day_date) -> s ) start = entry.start_at.strftime("%H:%M") if entry.start_at else "" end = f" \u2013 {entry.end_at.strftime('%H:%M')}" if entry.end_at else "" - items += await render_to_sx("events-day-nav-entry", + items += sx_call("events-day-nav-entry", href=href, nav_btn=nav_btn, name=entry.name, time_str=start + end) - return await render_to_sx("events-day-entries-nav-oob", items=SxExpr(items)) + return sx_call("events-day-entries-nav-oob", items=SxExpr(items)) # --------------------------------------------------------------------------- # Post nav entries OOB # --------------------------------------------------------------------------- -async def render_post_nav_entries_oob(associated_entries, calendars, post) -> str: +def render_post_nav_entries_oob(associated_entries, calendars, post) -> str: """Render OOB nav for associated entries and calendars of a post.""" from quart import g from shared.infrastructure.urls import events_url @@ -644,7 +644,7 @@ async def render_post_nav_entries_oob(associated_entries, calendars, post) -> st has_items = has_entries or calendars if not has_items: - return await render_to_sx("events-post-nav-oob-empty") + return sx_call("events-post-nav-oob-empty") slug = post.get("slug", "") if isinstance(post, dict) else getattr(post, "slug", "") @@ -659,7 +659,7 @@ async def render_post_nav_entries_oob(associated_entries, calendars, post) -> st href = events_url(entry_path) time_str = entry.start_at.strftime("%b %d, %Y at %H:%M") end_str = f" \u2013 {entry.end_at.strftime('%H:%M')}" if entry.end_at else "" - items += await render_to_sx("events-post-nav-entry", + items += sx_call("events-post-nav-entry", href=href, nav_btn=nav_btn, name=entry.name, time_str=time_str + end_str) @@ -667,7 +667,7 @@ async def render_post_nav_entries_oob(associated_entries, calendars, post) -> st for cal in calendars: cs = getattr(cal, "slug", "") local_href = events_url(f"/{slug}/{cs}/") - items += await render_to_sx("events-post-nav-calendar", + items += sx_call("events-post-nav-calendar", href=local_href, nav_btn=nav_btn, name=cal.name) hs = ("on load or scroll " @@ -675,7 +675,7 @@ async def render_post_nav_entries_oob(associated_entries, calendars, post) -> st "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") - return await render_to_sx("events-post-nav-wrapper", + return sx_call("events-post-nav-wrapper", items=SxExpr(items), hyperscript=hs) @@ -683,23 +683,23 @@ async def render_post_nav_entries_oob(associated_entries, calendars, post) -> st # Calendar description display + edit form # --------------------------------------------------------------------------- -async def render_calendar_description(calendar, *, oob: bool = False) -> str: +def render_calendar_description(calendar, *, oob: bool = False) -> str: """Render calendar description display with edit button, optionally with OOB title.""" from quart import url_for from .calendar import _calendar_description_display_html cal_slug = getattr(calendar, "slug", "") edit_url = url_for("calendar.admin.calendar_description_edit", calendar_slug=cal_slug) - html = await _calendar_description_display_html(calendar, edit_url) + html = _calendar_description_display_html(calendar, edit_url) if oob: desc = getattr(calendar, "description", "") or "" - html += await render_to_sx("events-calendar-description-title-oob", + html += sx_call("events-calendar-description-title-oob", description=desc) return html -async def render_calendar_description_edit(calendar) -> str: +def render_calendar_description_edit(calendar) -> str: """Render calendar description edit form.""" from quart import url_for from shared.browser.app.csrf import generate_csrf_token @@ -710,7 +710,7 @@ async def render_calendar_description_edit(calendar) -> str: save_url = url_for("calendar.admin.calendar_description_save", calendar_slug=cal_slug) cancel_url = url_for("calendar.admin.calendar_description_view", calendar_slug=cal_slug) - return await render_to_sx("events-calendar-description-edit-form", + return sx_call("events-calendar-description-edit-form", save_url=save_url, cancel_url=cancel_url, csrf=csrf, description=desc) @@ -719,7 +719,7 @@ async def render_calendar_description_edit(calendar) -> str: # Entry admin page / OOB # --------------------------------------------------------------------------- -async def _entry_admin_header_html(ctx: dict, *, oob: bool = False) -> str: +def _entry_admin_header_html(ctx: dict, *, oob: bool = False) -> str: """Build the entry admin header row.""" from quart import url_for @@ -739,14 +739,14 @@ async def _entry_admin_header_html(ctx: dict, *, oob: bool = False) -> str: calendar_slug=cal_slug, day=day, month=month, year=year, entry_id=entry.id, ) # Nav: ticket_types link - nav_html = await _entry_admin_nav_html(ctx) + nav_html = _entry_admin_nav_html(ctx) - return await render_to_sx("menu-row-sx", id="entry-admin-row", level=6, + return sx_call("menu-row-sx", id="entry-admin-row", level=6, link_href=link_href, link_label="admin", icon="fa fa-cog", nav=SxExpr(nav_html) if nav_html else None, child_id="entry-admin-header-child", oob=oob) -async def _entry_admin_nav_html(ctx: dict) -> str: +def _entry_admin_nav_html(ctx: dict) -> str: """Entry admin nav: ticket_types link.""" from quart import url_for @@ -765,11 +765,11 @@ async def _entry_admin_nav_html(ctx: dict) -> str: href = url_for("calendar.day.calendar_entries.calendar_entry.ticket_types.get", calendar_slug=cal_slug, entry_id=entry.id, year=year, month=month, day=day) - return await render_to_sx("nav-link", href=href, label="ticket_types", + return sx_call("nav-link", href=href, label="ticket_types", select_colours=select_colours) -async def _entry_admin_main_panel_html(ctx: dict) -> str: +def _entry_admin_main_panel_html(ctx: dict) -> str: """Entry admin main panel: just a ticket_types link.""" from quart import url_for @@ -789,7 +789,7 @@ async def _entry_admin_main_panel_html(ctx: dict) -> str: href = url_for("calendar.day.calendar_entries.calendar_entry.ticket_types.get", calendar_slug=cal_slug, entry_id=entry.id, year=year, month=month, day=day) - return await render_to_sx("nav-link", href=href, label="ticket_types", + return sx_call("nav-link", href=href, label="ticket_types", select_colours=select_colours, aclass=nav_btn, is_selected=False) @@ -798,7 +798,7 @@ async def _entry_admin_main_panel_html(ctx: dict) -> str: # Post search results # --------------------------------------------------------------------------- -async def render_post_search_results(search_posts, search_query, page, total_pages, +def render_post_search_results(search_posts, search_query, page, total_pages, entry, calendar, day, month, year) -> str: """Render post search results (replaces _types/entry/_post_search_results.html).""" from quart import url_for @@ -816,11 +816,11 @@ async def render_post_search_results(search_posts, search_query, page, total_pag feat = getattr(sp, "feature_image", None) title = getattr(sp, "title", "") if feat: - img_html = await render_to_sx("events-post-img", src=feat, alt=title) + img_html = sx_call("events-post-img", src=feat, alt=title) else: - img_html = await render_to_sx("events-post-img-placeholder") + img_html = sx_call("events-post-img-placeholder") - parts.append(await render_to_sx("events-post-search-item", + parts.append(sx_call("events-post-search-item", post_url=post_url, entry_id=str(eid), csrf=csrf, post_id=str(sp.id), img=SxExpr(img_html), title=title)) @@ -830,10 +830,10 @@ async def render_post_search_results(search_posts, search_query, page, total_pag next_url = url_for("calendar.day.calendar_entries.calendar_entry.search_posts", calendar_slug=cal_slug, day=day, month=month, year=year, entry_id=eid, q=search_query, page=page + 1) - result += await render_to_sx("events-post-search-sentinel", + result += sx_call("events-post-search-sentinel", page=str(page), next_url=next_url) elif search_posts: - result += await render_to_sx("events-post-search-end") + result += sx_call("events-post-search-end") return result @@ -842,7 +842,7 @@ async def render_post_search_results(search_posts, search_query, page, total_pag # Entry edit form # --------------------------------------------------------------------------- -async def render_entry_edit_form(entry, calendar, day, month, year, day_slots) -> str: +def render_entry_edit_form(entry, calendar, day, month, year, day_slots) -> str: """Render entry edit form (replaces _types/entry/_edit.html).""" from quart import url_for, g from shared.browser.app.csrf import generate_csrf_token @@ -863,11 +863,11 @@ async def render_entry_edit_form(entry, calendar, day, month, year, day_slots) - # Slot picker if day_slots: - options_html = await _slot_options_html(day_slots, selected_slot_id=getattr(entry, "slot_id", None)) - slot_picker_html = await render_to_sx("events-slot-picker", + options_html = _slot_options_html(day_slots, selected_slot_id=getattr(entry, "slot_id", None)) + slot_picker_html = sx_call("events-slot-picker", id=f"entry-slot-{eid}", options=SxExpr(options_html)) else: - slot_picker_html = await render_to_sx("events-no-slots") + slot_picker_html = sx_call("events-no-slots") # Values start_val = entry.start_at.strftime("%H:%M") if entry.start_at else "" @@ -879,7 +879,7 @@ async def render_entry_edit_form(entry, calendar, day, month, year, day_slots) - tp_val = f"{tp:.2f}" if tp is not None else "" tc_val = str(tc) if tc is not None else "" - html = await render_to_sx("events-entry-edit-form", + html = sx_call("events-entry-edit-form", entry_id=str(eid), list_container=list_container, put_url=put_url, cancel_url=cancel_url, csrf=csrf, name_val=entry.name or "", slot_picker=SxExpr(slot_picker_html), @@ -893,7 +893,7 @@ async def render_entry_edit_form(entry, calendar, day, month, year, day_slots) - # Entry add form / button # --------------------------------------------------------------------------- -async def render_entry_add_form(calendar, day, month, year, day_slots) -> str: +def render_entry_add_form(calendar, day, month, year, day_slots) -> str: """Render entry add form (replaces _types/day/_add.html).""" from quart import url_for, g from shared.browser.app.csrf import generate_csrf_token @@ -912,13 +912,13 @@ async def render_entry_add_form(calendar, day, month, year, day_slots) -> str: # Slot picker if day_slots: - options_html = await _slot_options_html(day_slots) - slot_picker_html = await render_to_sx("events-slot-picker", + options_html = _slot_options_html(day_slots) + slot_picker_html = sx_call("events-slot-picker", id="entry-slot-new", options=SxExpr(options_html)) else: - slot_picker_html = await render_to_sx("events-no-slots") + slot_picker_html = sx_call("events-no-slots") - html = await render_to_sx("events-entry-add-form", + html = sx_call("events-entry-add-form", post_url=post_url, csrf=csrf, slot_picker=SxExpr(slot_picker_html), action_btn=action_btn, cancel_btn=cancel_btn, @@ -926,7 +926,7 @@ async def render_entry_add_form(calendar, day, month, year, day_slots) -> str: return html + _SLOT_PICKER_JS -async def render_entry_add_button(calendar, day, month, year) -> str: +def render_entry_add_button(calendar, day, month, year) -> str: """Render entry add button (replaces _types/day/_add_button.html).""" from quart import url_for, g @@ -936,14 +936,14 @@ async def render_entry_add_button(calendar, day, month, year) -> str: add_url = url_for("calendar.day.calendar_entries.add_form", calendar_slug=cal_slug, day=day, month=month, year=year) - return await render_to_sx("events-entry-add-button", pre_action=pre_action, add_url=add_url) + return sx_call("events-entry-add-button", pre_action=pre_action, add_url=add_url) # --------------------------------------------------------------------------- # Fragment: container cards entries # --------------------------------------------------------------------------- -async def render_fragment_container_cards(batch, post_ids, slug_map) -> str: +def render_fragment_container_cards(batch, post_ids, slug_map) -> str: """Render container cards entries (replaces fragments/container_cards_entries.html).""" from shared.infrastructure.urls import events_url @@ -963,12 +963,12 @@ async def render_fragment_container_cards(batch, post_ids, slug_map) -> str: time_str = entry.start_at.strftime("%H:%M") if entry.end_at: time_str += f" \u2013 {entry.end_at.strftime('%H:%M')}" - cards_html += await render_to_sx("events-frag-entry-card", + cards_html += sx_call("events-frag-entry-card", href=events_url(_entry_path), name=entry.name, date_str=entry.start_at.strftime("%a, %b %d"), time_str=time_str) - parts.append(await render_to_sx("events-frag-entries-widget", cards=SxExpr(cards_html))) + parts.append(sx_call("events-frag-entries-widget", cards=SxExpr(cards_html))) parts.append(f"") return "\n".join(parts) @@ -978,7 +978,7 @@ async def render_fragment_container_cards(batch, post_ids, slug_map) -> str: # Fragment: account page tickets # --------------------------------------------------------------------------- -async def render_fragment_account_tickets(tickets) -> str: +def render_fragment_account_tickets(tickets) -> str: """Render account page tickets (replaces fragments/account_page_tickets.html).""" from shared.infrastructure.urls import events_url @@ -993,25 +993,25 @@ async def render_fragment_account_tickets(tickets) -> str: type_name = "" if getattr(ticket, "ticket_type_name", None): type_name = f'· {escape(ticket.ticket_type_name)}' - badge_html = await render_to_sx("status-pill", + badge_html = sx_call("status-pill", status=getattr(ticket, "state", "")) - items_html += await render_to_sx("events-frag-ticket-item", + items_html += sx_call("events-frag-ticket-item", href=href, entry_name=ticket.entry_name, date_str=date_str, calendar_name=cal_name, type_name=type_name, badge=SxExpr(badge_html)) - body = await render_to_sx("events-frag-tickets-list", items=SxExpr(items_html)) + body = sx_call("events-frag-tickets-list", items=SxExpr(items_html)) else: - body = await render_to_sx("empty-state", message="No tickets yet.", + body = sx_call("empty-state", message="No tickets yet.", cls="text-sm text-stone-500") - return await render_to_sx("events-frag-tickets-panel", items=SxExpr(body)) + return sx_call("events-frag-tickets-panel", items=SxExpr(body)) # --------------------------------------------------------------------------- # Fragment: account page bookings # --------------------------------------------------------------------------- -async def render_fragment_account_bookings(bookings) -> str: +def render_fragment_account_bookings(bookings) -> str: """Render account page bookings (replaces fragments/account_page_bookings.html).""" if bookings: items_html = "" @@ -1027,16 +1027,16 @@ async def render_fragment_account_bookings(bookings) -> str: cost_str = "" if getattr(booking, "cost", None): cost_str = f'· £{escape(str(booking.cost))}' - badge_html = await render_to_sx("status-pill", + badge_html = sx_call("status-pill", status=getattr(booking, "state", "")) - items_html += await render_to_sx("events-frag-booking-item", + items_html += sx_call("events-frag-booking-item", name=booking.name, date_str=date_str + date_str_extra, calendar_name=cal_name, cost_str=cost_str, badge=SxExpr(badge_html)) - body = await render_to_sx("events-frag-bookings-list", items=SxExpr(items_html)) + body = sx_call("events-frag-bookings-list", items=SxExpr(items_html)) else: - body = await render_to_sx("empty-state", message="No bookings yet.", + body = sx_call("empty-state", message="No bookings yet.", cls="text-sm text-stone-500") - return await render_to_sx("events-frag-bookings-panel", items=SxExpr(body)) + return sx_call("events-frag-bookings-panel", items=SxExpr(body)) diff --git a/events/sxc/pages/helpers.py b/events/sxc/pages/helpers.py index 8604e05..ddf8dab 100644 --- a/events/sxc/pages/helpers.py +++ b/events/sxc/pages/helpers.py @@ -3,7 +3,7 @@ from __future__ import annotations from typing import Any -from shared.sx.helpers import render_to_sx +from shared.sx.helpers import sx_call from .utils import _clear_deeper_oob, _ensure_container_nav from .calendar import ( @@ -235,8 +235,8 @@ async def _cal_admin_full(ctx: dict, **kw: Any) -> str: return await render_to_sx_with_env("events-cal-admin-layout-full", {}, post_header=SxExpr(await _post_header_sx(ctx)), admin_header=SxExpr(await post_admin_header_sx(ctx, slug, selected="calendars")), - calendar_header=SxExpr(await _calendar_header_sx(ctx)), - calendar_admin_header=SxExpr(await _calendar_admin_header_sx(ctx)), + calendar_header=SxExpr(_calendar_header_sx(ctx)), + calendar_admin_header=SxExpr(_calendar_admin_header_sx(ctx)), ) @@ -247,9 +247,9 @@ async def _cal_admin_oob(ctx: dict, **kw: Any) -> str: slug = (ctx.get("post") or {}).get("slug", "") return await render_to_sx_with_env("events-cal-admin-layout-oob", {}, admin_oob=SxExpr(await post_admin_header_sx(ctx, slug, oob=True, selected="calendars")), - cal_oob=SxExpr(await _calendar_header_sx(ctx, oob=True)), + cal_oob=SxExpr(_calendar_header_sx(ctx, oob=True)), cal_admin_oob_wrap=SxExpr(await oob_header_sx("calendar-header-child", - "calendar-admin-header-child", await _calendar_admin_header_sx(ctx))), + "calendar-admin-header-child", _calendar_admin_header_sx(ctx))), clear_oob=SxExpr(_clear_deeper_oob("post-row", "post-header-child", "post-admin-row", "post-admin-header-child", "calendar-row", "calendar-header-child", @@ -270,7 +270,7 @@ async def _slots_oob(ctx: dict, **kw: Any) -> str: slug = (ctx.get("post") or {}).get("slug", "") return await render_to_sx_with_env("events-slots-layout-oob", {}, admin_oob=SxExpr(await post_admin_header_sx(ctx, slug, oob=True, selected="calendars")), - cal_admin_oob=SxExpr(await _calendar_admin_header_sx(ctx, oob=True)), + cal_admin_oob=SxExpr(_calendar_admin_header_sx(ctx, oob=True)), clear_oob=SxExpr(_clear_deeper_oob("post-row", "post-header-child", "post-admin-row", "post-admin-header-child", "calendar-row", "calendar-header-child", @@ -288,9 +288,9 @@ async def _slot_full(ctx: dict, **kw: Any) -> str: return await render_to_sx_with_env("events-slot-layout-full", {}, post_header=SxExpr(await _post_header_sx(ctx)), admin_header=SxExpr(await post_admin_header_sx(ctx, slug, selected="calendars")), - calendar_header=SxExpr(await _calendar_header_sx(ctx)), - calendar_admin_header=SxExpr(await _calendar_admin_header_sx(ctx)), - slot_header=SxExpr(await _slot_header_html(ctx)), + calendar_header=SxExpr(_calendar_header_sx(ctx)), + calendar_admin_header=SxExpr(_calendar_admin_header_sx(ctx)), + slot_header=SxExpr(_slot_header_html(ctx)), ) @@ -301,9 +301,9 @@ async def _slot_oob(ctx: dict, **kw: Any) -> str: slug = (ctx.get("post") or {}).get("slug", "") return await render_to_sx_with_env("events-slot-layout-oob", {}, admin_oob=SxExpr(await post_admin_header_sx(ctx, slug, oob=True, selected="calendars")), - cal_admin_oob=SxExpr(await _calendar_admin_header_sx(ctx, oob=True)), + cal_admin_oob=SxExpr(_calendar_admin_header_sx(ctx, oob=True)), slot_oob_wrap=SxExpr(await oob_header_sx("calendar-admin-header-child", - "slot-header-child", await _slot_header_html(ctx))), + "slot-header-child", _slot_header_html(ctx))), clear_oob=SxExpr(_clear_deeper_oob("post-row", "post-header-child", "post-admin-row", "post-admin-header-child", "calendar-row", "calendar-header-child", @@ -322,9 +322,9 @@ async def _day_admin_full(ctx: dict, **kw: Any) -> str: return await render_to_sx_with_env("events-day-admin-layout-full", {}, post_header=SxExpr(await _post_header_sx(ctx)), admin_header=SxExpr(await post_admin_header_sx(ctx, slug, selected="calendars")), - calendar_header=SxExpr(await _calendar_header_sx(ctx)), - day_header=SxExpr(await _day_header_sx(ctx)), - day_admin_header=SxExpr(await _day_admin_header_sx(ctx)), + calendar_header=SxExpr(_calendar_header_sx(ctx)), + day_header=SxExpr(_day_header_sx(ctx)), + day_admin_header=SxExpr(_day_admin_header_sx(ctx)), ) @@ -335,9 +335,9 @@ async def _day_admin_oob(ctx: dict, **kw: Any) -> str: slug = (ctx.get("post") or {}).get("slug", "") return await render_to_sx_with_env("events-day-admin-layout-oob", {}, admin_oob=SxExpr(await post_admin_header_sx(ctx, slug, oob=True, selected="calendars")), - cal_oob=SxExpr(await _calendar_header_sx(ctx, oob=True)), + cal_oob=SxExpr(_calendar_header_sx(ctx, oob=True)), day_admin_oob_wrap=SxExpr(await oob_header_sx("day-header-child", - "day-admin-header-child", await _day_admin_header_sx(ctx))), + "day-admin-header-child", _day_admin_header_sx(ctx))), clear_oob=SxExpr(_clear_deeper_oob("post-row", "post-header-child", "post-admin-row", "post-admin-header-child", "calendar-row", "calendar-header-child", @@ -353,9 +353,9 @@ async def _entry_full(ctx: dict, **kw: Any) -> str: from shared.sx.parser import SxExpr return await render_to_sx_with_env("events-entry-layout-full", {}, post_header=SxExpr(await _post_header_sx(ctx)), - calendar_header=SxExpr(await _calendar_header_sx(ctx)), - day_header=SxExpr(await _day_header_sx(ctx)), - entry_header=SxExpr(await _entry_header_html(ctx)), + calendar_header=SxExpr(_calendar_header_sx(ctx)), + day_header=SxExpr(_day_header_sx(ctx)), + entry_header=SxExpr(_entry_header_html(ctx)), ) @@ -363,9 +363,9 @@ async def _entry_oob(ctx: dict, **kw: Any) -> str: from shared.sx.helpers import render_to_sx_with_env, oob_header_sx from shared.sx.parser import SxExpr return await render_to_sx_with_env("events-entry-layout-oob", {}, - day_oob=SxExpr(await _day_header_sx(ctx, oob=True)), + day_oob=SxExpr(_day_header_sx(ctx, oob=True)), entry_oob_wrap=SxExpr(await oob_header_sx("day-header-child", - "entry-header-child", await _entry_header_html(ctx))), + "entry-header-child", _entry_header_html(ctx))), clear_oob=SxExpr(_clear_deeper_oob("post-row", "post-header-child", "calendar-row", "calendar-header-child", "day-row", "day-header-child", @@ -383,10 +383,10 @@ async def _entry_admin_full(ctx: dict, **kw: Any) -> str: return await render_to_sx_with_env("events-entry-admin-layout-full", {}, post_header=SxExpr(await _post_header_sx(ctx)), admin_header=SxExpr(await post_admin_header_sx(ctx, slug, selected="calendars")), - calendar_header=SxExpr(await _calendar_header_sx(ctx)), - day_header=SxExpr(await _day_header_sx(ctx)), - entry_header=SxExpr(await _entry_header_html(ctx)), - entry_admin_header=SxExpr(await _entry_admin_header_html(ctx)), + calendar_header=SxExpr(_calendar_header_sx(ctx)), + day_header=SxExpr(_day_header_sx(ctx)), + entry_header=SxExpr(_entry_header_html(ctx)), + entry_admin_header=SxExpr(_entry_admin_header_html(ctx)), ) @@ -397,9 +397,9 @@ async def _entry_admin_oob(ctx: dict, **kw: Any) -> str: slug = (ctx.get("post") or {}).get("slug", "") return await render_to_sx_with_env("events-entry-admin-layout-oob", {}, admin_oob=SxExpr(await post_admin_header_sx(ctx, slug, oob=True, selected="calendars")), - entry_oob=SxExpr(await _entry_header_html(ctx, oob=True)), + entry_oob=SxExpr(_entry_header_html(ctx, oob=True)), entry_admin_oob_wrap=SxExpr(await oob_header_sx("entry-header-child", - "entry-admin-header-child", await _entry_admin_header_html(ctx))), + "entry-admin-header-child", _entry_admin_header_html(ctx))), clear_oob=SxExpr(_clear_deeper_oob("post-row", "post-header-child", "post-admin-row", "post-admin-header-child", "calendar-row", "calendar-header-child", @@ -416,11 +416,11 @@ async def _ticket_types_full(ctx: dict, **kw: Any) -> str: from shared.sx.parser import SxExpr return await render_to_sx_with_env("events-ticket-types-layout-full", {}, post_header=SxExpr(await _post_header_sx(ctx)), - calendar_header=SxExpr(await _calendar_header_sx(ctx)), - day_header=SxExpr(await _day_header_sx(ctx)), - entry_header=SxExpr(await _entry_header_html(ctx)), - entry_admin_header=SxExpr(await _entry_admin_header_html(ctx)), - ticket_types_header=SxExpr(await _ticket_types_header_html(ctx)), + calendar_header=SxExpr(_calendar_header_sx(ctx)), + day_header=SxExpr(_day_header_sx(ctx)), + entry_header=SxExpr(_entry_header_html(ctx)), + entry_admin_header=SxExpr(_entry_admin_header_html(ctx)), + ticket_types_header=SxExpr(_ticket_types_header_html(ctx)), ) @@ -428,9 +428,9 @@ async def _ticket_types_oob(ctx: dict, **kw: Any) -> str: from shared.sx.helpers import render_to_sx_with_env, oob_header_sx from shared.sx.parser import SxExpr return await render_to_sx_with_env("events-ticket-types-layout-oob", {}, - entry_admin_oob=SxExpr(await _entry_admin_header_html(ctx, oob=True)), + entry_admin_oob=SxExpr(_entry_admin_header_html(ctx, oob=True)), ticket_types_oob_wrap=SxExpr(await oob_header_sx("entry-admin-header-child", - "ticket_types-header-child", await _ticket_types_header_html(ctx))), + "ticket_types-header-child", _ticket_types_header_html(ctx))), ) @@ -441,12 +441,12 @@ async def _ticket_type_full(ctx: dict, **kw: Any) -> str: from shared.sx.parser import SxExpr return await render_to_sx_with_env("events-ticket-type-layout-full", {}, post_header=SxExpr(await _post_header_sx(ctx)), - calendar_header=SxExpr(await _calendar_header_sx(ctx)), - day_header=SxExpr(await _day_header_sx(ctx)), - entry_header=SxExpr(await _entry_header_html(ctx)), - entry_admin_header=SxExpr(await _entry_admin_header_html(ctx)), - ticket_types_header=SxExpr(await _ticket_types_header_html(ctx)), - ticket_type_header=SxExpr(await _ticket_type_header_html(ctx)), + calendar_header=SxExpr(_calendar_header_sx(ctx)), + day_header=SxExpr(_day_header_sx(ctx)), + entry_header=SxExpr(_entry_header_html(ctx)), + entry_admin_header=SxExpr(_entry_admin_header_html(ctx)), + ticket_types_header=SxExpr(_ticket_types_header_html(ctx)), + ticket_type_header=SxExpr(_ticket_type_header_html(ctx)), ) @@ -454,9 +454,9 @@ async def _ticket_type_oob(ctx: dict, **kw: Any) -> str: from shared.sx.helpers import render_to_sx_with_env, oob_header_sx from shared.sx.parser import SxExpr return await render_to_sx_with_env("events-ticket-type-layout-oob", {}, - ticket_types_oob=SxExpr(await _ticket_types_header_html(ctx, oob=True)), + ticket_types_oob=SxExpr(_ticket_types_header_html(ctx, oob=True)), ticket_type_oob_wrap=SxExpr(await oob_header_sx("ticket_types-header-child", - "ticket_type-header-child", await _ticket_type_header_html(ctx))), + "ticket_type-header-child", _ticket_type_header_html(ctx))), ) @@ -467,7 +467,7 @@ async def _markets_full(ctx: dict, **kw: Any) -> str: from shared.sx.parser import SxExpr return await render_to_sx_with_env("events-markets-layout-full", {}, post_header=SxExpr(await _post_header_sx(ctx)), - markets_header=SxExpr(await _markets_header_sx(ctx)), + markets_header=SxExpr(_markets_header_sx(ctx)), ) @@ -477,7 +477,7 @@ async def _markets_oob(ctx: dict, **kw: Any) -> str: return await render_to_sx_with_env("events-markets-layout-oob", {}, post_oob=SxExpr(await _post_header_sx(ctx, oob=True)), markets_oob_wrap=SxExpr(await oob_header_sx("post-header-child", - "markets-header-child", await _markets_header_sx(ctx))), + "markets-header-child", _markets_header_sx(ctx))), ) @@ -510,14 +510,14 @@ async def _h_calendar_admin_content(calendar_slug=None, **kw): await _ensure_calendar(calendar_slug) from shared.sx.page import get_template_context ctx = await get_template_context() - return await _calendar_admin_main_panel_html(ctx) + return _calendar_admin_main_panel_html(ctx) async def _h_day_admin_content(calendar_slug=None, year=None, month=None, day=None, **kw): await _ensure_calendar(calendar_slug) if year is not None: await _ensure_day_data(int(year), int(month), int(day)) - return await _day_admin_main_panel_html({}) + return _day_admin_main_panel_html({}) async def _h_slots_content(calendar_slug=None, **kw): @@ -527,7 +527,7 @@ async def _h_slots_content(calendar_slug=None, **kw): from bp.slots.services.slots import list_slots as svc_list_slots slots = await svc_list_slots(g.s, calendar.id) if calendar else [] _add_to_defpage_ctx(slots=slots) - return await render_slots_table(slots, calendar) + return render_slots_table(slots, calendar) async def _h_slot_content(calendar_slug=None, slot_id=None, **kw): @@ -540,7 +540,7 @@ async def _h_slot_content(calendar_slug=None, slot_id=None, **kw): g.slot = slot _add_to_defpage_ctx(slot=slot) calendar = getattr(g, "calendar", None) - return await render_slot_main_panel(slot, calendar) + return render_slot_main_panel(slot, calendar) async def _h_entry_content(calendar_slug=None, entry_id=None, **kw): @@ -548,7 +548,7 @@ async def _h_entry_content(calendar_slug=None, entry_id=None, **kw): await _ensure_entry_context(entry_id) from shared.sx.page import get_template_context ctx = await get_template_context() - return await _entry_main_panel_html(ctx) + return _entry_main_panel_html(ctx) async def _h_entry_menu(calendar_slug=None, entry_id=None, **kw): @@ -556,7 +556,7 @@ async def _h_entry_menu(calendar_slug=None, entry_id=None, **kw): await _ensure_entry_context(entry_id) from shared.sx.page import get_template_context ctx = await get_template_context() - return await _entry_nav_html(ctx) + return _entry_nav_html(ctx) async def _h_entry_admin_content(calendar_slug=None, entry_id=None, **kw): @@ -564,12 +564,11 @@ async def _h_entry_admin_content(calendar_slug=None, entry_id=None, **kw): await _ensure_entry_context(entry_id) from shared.sx.page import get_template_context ctx = await get_template_context() - return await _entry_admin_main_panel_html(ctx) + return _entry_admin_main_panel_html(ctx) -async def _h_admin_menu(): - from shared.sx.helpers import render_to_sx - return await render_to_sx("events-admin-placeholder-nav") +def _h_admin_menu(): + return sx_call("events-admin-placeholder-nav") async def _h_ticket_types_content(calendar_slug=None, entry_id=None, @@ -582,7 +581,7 @@ async def _h_ticket_types_content(calendar_slug=None, entry_id=None, from bp.ticket_types.services.tickets import list_ticket_types as svc_list_ticket_types ticket_types = await svc_list_ticket_types(g.s, entry.id) if entry else [] _add_to_defpage_ctx(ticket_types=ticket_types) - return await render_ticket_types_table(ticket_types, entry, calendar, day, month, year) + return render_ticket_types_table(ticket_types, entry, calendar, day, month, year) async def _h_ticket_type_content(calendar_slug=None, entry_id=None, @@ -598,7 +597,7 @@ async def _h_ticket_type_content(calendar_slug=None, entry_id=None, _add_to_defpage_ctx(ticket_type=ticket_type) entry = getattr(g, "entry", None) calendar = getattr(g, "calendar", None) - return await render_ticket_type_main_panel(ticket_type, entry, calendar, day, month, year) + return render_ticket_type_main_panel(ticket_type, entry, calendar, day, month, year) async def _h_tickets_content(**kw): @@ -613,7 +612,7 @@ async def _h_tickets_content(**kw): ) from shared.sx.page import get_template_context ctx = await get_template_context() - return await _tickets_main_panel_html(ctx, tickets) + return _tickets_main_panel_html(ctx, tickets) async def _h_ticket_detail_content(code=None, **kw): @@ -635,7 +634,7 @@ async def _h_ticket_detail_content(code=None, **kw): abort(404) from shared.sx.page import get_template_context ctx = await get_template_context() - return await _ticket_detail_panel_html(ctx, ticket) + return _ticket_detail_panel_html(ctx, ticket) async def _h_ticket_admin_content(**kw): @@ -674,10 +673,10 @@ async def _h_ticket_admin_content(**kw): from shared.sx.page import get_template_context ctx = await get_template_context() - return await _ticket_admin_main_panel_html(ctx, tickets, stats) + return _ticket_admin_main_panel_html(ctx, tickets, stats) async def _h_markets_content(**kw): from shared.sx.page import get_template_context ctx = await get_template_context() - return await _markets_main_panel_html(ctx) + return _markets_main_panel_html(ctx) diff --git a/events/sxc/pages/layouts.py b/events/sxc/pages/layouts.py index 82717ab..5ebe088 100644 --- a/events/sxc/pages/layouts.py +++ b/events/sxc/pages/layouts.py @@ -42,8 +42,8 @@ async def _cal_admin_full(ctx: dict, **kw: Any) -> str: return await render_to_sx_with_env("events-cal-admin-layout-full", {}, post_header=SxExpr(await _post_header_sx(ctx)), admin_header=SxExpr(await post_admin_header_sx(ctx, slug, selected="calendars")), - calendar_header=SxExpr(await _calendar_header_sx(ctx)), - calendar_admin_header=SxExpr(await _calendar_admin_header_sx(ctx)), + calendar_header=SxExpr(_calendar_header_sx(ctx)), + calendar_admin_header=SxExpr(_calendar_admin_header_sx(ctx)), ) @@ -54,9 +54,9 @@ async def _cal_admin_oob(ctx: dict, **kw: Any) -> str: slug = (ctx.get("post") or {}).get("slug", "") return await render_to_sx_with_env("events-cal-admin-layout-oob", {}, admin_oob=SxExpr(await post_admin_header_sx(ctx, slug, oob=True, selected="calendars")), - cal_oob=SxExpr(await _calendar_header_sx(ctx, oob=True)), + cal_oob=SxExpr(_calendar_header_sx(ctx, oob=True)), cal_admin_oob_wrap=SxExpr(await oob_header_sx("calendar-header-child", - "calendar-admin-header-child", await _calendar_admin_header_sx(ctx))), + "calendar-admin-header-child", _calendar_admin_header_sx(ctx))), clear_oob=SxExpr(_clear_deeper_oob("post-row", "post-header-child", "post-admin-row", "post-admin-header-child", "calendar-row", "calendar-header-child", @@ -77,7 +77,7 @@ async def _slots_oob(ctx: dict, **kw: Any) -> str: slug = (ctx.get("post") or {}).get("slug", "") return await render_to_sx_with_env("events-slots-layout-oob", {}, admin_oob=SxExpr(await post_admin_header_sx(ctx, slug, oob=True, selected="calendars")), - cal_admin_oob=SxExpr(await _calendar_admin_header_sx(ctx, oob=True)), + cal_admin_oob=SxExpr(_calendar_admin_header_sx(ctx, oob=True)), clear_oob=SxExpr(_clear_deeper_oob("post-row", "post-header-child", "post-admin-row", "post-admin-header-child", "calendar-row", "calendar-header-child", @@ -95,9 +95,9 @@ async def _slot_full(ctx: dict, **kw: Any) -> str: return await render_to_sx_with_env("events-slot-layout-full", {}, post_header=SxExpr(await _post_header_sx(ctx)), admin_header=SxExpr(await post_admin_header_sx(ctx, slug, selected="calendars")), - calendar_header=SxExpr(await _calendar_header_sx(ctx)), - calendar_admin_header=SxExpr(await _calendar_admin_header_sx(ctx)), - slot_header=SxExpr(await _slot_header_html(ctx)), + calendar_header=SxExpr(_calendar_header_sx(ctx)), + calendar_admin_header=SxExpr(_calendar_admin_header_sx(ctx)), + slot_header=SxExpr(_slot_header_html(ctx)), ) @@ -108,9 +108,9 @@ async def _slot_oob(ctx: dict, **kw: Any) -> str: slug = (ctx.get("post") or {}).get("slug", "") return await render_to_sx_with_env("events-slot-layout-oob", {}, admin_oob=SxExpr(await post_admin_header_sx(ctx, slug, oob=True, selected="calendars")), - cal_admin_oob=SxExpr(await _calendar_admin_header_sx(ctx, oob=True)), + cal_admin_oob=SxExpr(_calendar_admin_header_sx(ctx, oob=True)), slot_oob_wrap=SxExpr(await oob_header_sx("calendar-admin-header-child", - "slot-header-child", await _slot_header_html(ctx))), + "slot-header-child", _slot_header_html(ctx))), clear_oob=SxExpr(_clear_deeper_oob("post-row", "post-header-child", "post-admin-row", "post-admin-header-child", "calendar-row", "calendar-header-child", @@ -129,9 +129,9 @@ async def _day_admin_full(ctx: dict, **kw: Any) -> str: return await render_to_sx_with_env("events-day-admin-layout-full", {}, post_header=SxExpr(await _post_header_sx(ctx)), admin_header=SxExpr(await post_admin_header_sx(ctx, slug, selected="calendars")), - calendar_header=SxExpr(await _calendar_header_sx(ctx)), - day_header=SxExpr(await _day_header_sx(ctx)), - day_admin_header=SxExpr(await _day_admin_header_sx(ctx)), + calendar_header=SxExpr(_calendar_header_sx(ctx)), + day_header=SxExpr(_day_header_sx(ctx)), + day_admin_header=SxExpr(_day_admin_header_sx(ctx)), ) @@ -142,9 +142,9 @@ async def _day_admin_oob(ctx: dict, **kw: Any) -> str: slug = (ctx.get("post") or {}).get("slug", "") return await render_to_sx_with_env("events-day-admin-layout-oob", {}, admin_oob=SxExpr(await post_admin_header_sx(ctx, slug, oob=True, selected="calendars")), - cal_oob=SxExpr(await _calendar_header_sx(ctx, oob=True)), + cal_oob=SxExpr(_calendar_header_sx(ctx, oob=True)), day_admin_oob_wrap=SxExpr(await oob_header_sx("day-header-child", - "day-admin-header-child", await _day_admin_header_sx(ctx))), + "day-admin-header-child", _day_admin_header_sx(ctx))), clear_oob=SxExpr(_clear_deeper_oob("post-row", "post-header-child", "post-admin-row", "post-admin-header-child", "calendar-row", "calendar-header-child", @@ -160,9 +160,9 @@ async def _entry_full(ctx: dict, **kw: Any) -> str: from shared.sx.parser import SxExpr return await render_to_sx_with_env("events-entry-layout-full", {}, post_header=SxExpr(await _post_header_sx(ctx)), - calendar_header=SxExpr(await _calendar_header_sx(ctx)), - day_header=SxExpr(await _day_header_sx(ctx)), - entry_header=SxExpr(await _entry_header_html(ctx)), + calendar_header=SxExpr(_calendar_header_sx(ctx)), + day_header=SxExpr(_day_header_sx(ctx)), + entry_header=SxExpr(_entry_header_html(ctx)), ) @@ -170,9 +170,9 @@ async def _entry_oob(ctx: dict, **kw: Any) -> str: from shared.sx.helpers import render_to_sx_with_env, oob_header_sx from shared.sx.parser import SxExpr return await render_to_sx_with_env("events-entry-layout-oob", {}, - day_oob=SxExpr(await _day_header_sx(ctx, oob=True)), + day_oob=SxExpr(_day_header_sx(ctx, oob=True)), entry_oob_wrap=SxExpr(await oob_header_sx("day-header-child", - "entry-header-child", await _entry_header_html(ctx))), + "entry-header-child", _entry_header_html(ctx))), clear_oob=SxExpr(_clear_deeper_oob("post-row", "post-header-child", "calendar-row", "calendar-header-child", "day-row", "day-header-child", @@ -190,10 +190,10 @@ async def _entry_admin_full(ctx: dict, **kw: Any) -> str: return await render_to_sx_with_env("events-entry-admin-layout-full", {}, post_header=SxExpr(await _post_header_sx(ctx)), admin_header=SxExpr(await post_admin_header_sx(ctx, slug, selected="calendars")), - calendar_header=SxExpr(await _calendar_header_sx(ctx)), - day_header=SxExpr(await _day_header_sx(ctx)), - entry_header=SxExpr(await _entry_header_html(ctx)), - entry_admin_header=SxExpr(await _entry_admin_header_html(ctx)), + calendar_header=SxExpr(_calendar_header_sx(ctx)), + day_header=SxExpr(_day_header_sx(ctx)), + entry_header=SxExpr(_entry_header_html(ctx)), + entry_admin_header=SxExpr(_entry_admin_header_html(ctx)), ) @@ -204,9 +204,9 @@ async def _entry_admin_oob(ctx: dict, **kw: Any) -> str: slug = (ctx.get("post") or {}).get("slug", "") return await render_to_sx_with_env("events-entry-admin-layout-oob", {}, admin_oob=SxExpr(await post_admin_header_sx(ctx, slug, oob=True, selected="calendars")), - entry_oob=SxExpr(await _entry_header_html(ctx, oob=True)), + entry_oob=SxExpr(_entry_header_html(ctx, oob=True)), entry_admin_oob_wrap=SxExpr(await oob_header_sx("entry-header-child", - "entry-admin-header-child", await _entry_admin_header_html(ctx))), + "entry-admin-header-child", _entry_admin_header_html(ctx))), clear_oob=SxExpr(_clear_deeper_oob("post-row", "post-header-child", "post-admin-row", "post-admin-header-child", "calendar-row", "calendar-header-child", @@ -223,11 +223,11 @@ async def _ticket_types_full(ctx: dict, **kw: Any) -> str: from shared.sx.parser import SxExpr return await render_to_sx_with_env("events-ticket-types-layout-full", {}, post_header=SxExpr(await _post_header_sx(ctx)), - calendar_header=SxExpr(await _calendar_header_sx(ctx)), - day_header=SxExpr(await _day_header_sx(ctx)), - entry_header=SxExpr(await _entry_header_html(ctx)), - entry_admin_header=SxExpr(await _entry_admin_header_html(ctx)), - ticket_types_header=SxExpr(await _ticket_types_header_html(ctx)), + calendar_header=SxExpr(_calendar_header_sx(ctx)), + day_header=SxExpr(_day_header_sx(ctx)), + entry_header=SxExpr(_entry_header_html(ctx)), + entry_admin_header=SxExpr(_entry_admin_header_html(ctx)), + ticket_types_header=SxExpr(_ticket_types_header_html(ctx)), ) @@ -235,9 +235,9 @@ async def _ticket_types_oob(ctx: dict, **kw: Any) -> str: from shared.sx.helpers import render_to_sx_with_env, oob_header_sx from shared.sx.parser import SxExpr return await render_to_sx_with_env("events-ticket-types-layout-oob", {}, - entry_admin_oob=SxExpr(await _entry_admin_header_html(ctx, oob=True)), + entry_admin_oob=SxExpr(_entry_admin_header_html(ctx, oob=True)), ticket_types_oob_wrap=SxExpr(await oob_header_sx("entry-admin-header-child", - "ticket_types-header-child", await _ticket_types_header_html(ctx))), + "ticket_types-header-child", _ticket_types_header_html(ctx))), ) @@ -248,12 +248,12 @@ async def _ticket_type_full(ctx: dict, **kw: Any) -> str: from shared.sx.parser import SxExpr return await render_to_sx_with_env("events-ticket-type-layout-full", {}, post_header=SxExpr(await _post_header_sx(ctx)), - calendar_header=SxExpr(await _calendar_header_sx(ctx)), - day_header=SxExpr(await _day_header_sx(ctx)), - entry_header=SxExpr(await _entry_header_html(ctx)), - entry_admin_header=SxExpr(await _entry_admin_header_html(ctx)), - ticket_types_header=SxExpr(await _ticket_types_header_html(ctx)), - ticket_type_header=SxExpr(await _ticket_type_header_html(ctx)), + calendar_header=SxExpr(_calendar_header_sx(ctx)), + day_header=SxExpr(_day_header_sx(ctx)), + entry_header=SxExpr(_entry_header_html(ctx)), + entry_admin_header=SxExpr(_entry_admin_header_html(ctx)), + ticket_types_header=SxExpr(_ticket_types_header_html(ctx)), + ticket_type_header=SxExpr(_ticket_type_header_html(ctx)), ) @@ -261,9 +261,9 @@ async def _ticket_type_oob(ctx: dict, **kw: Any) -> str: from shared.sx.helpers import render_to_sx_with_env, oob_header_sx from shared.sx.parser import SxExpr return await render_to_sx_with_env("events-ticket-type-layout-oob", {}, - ticket_types_oob=SxExpr(await _ticket_types_header_html(ctx, oob=True)), + ticket_types_oob=SxExpr(_ticket_types_header_html(ctx, oob=True)), ticket_type_oob_wrap=SxExpr(await oob_header_sx("ticket_types-header-child", - "ticket_type-header-child", await _ticket_type_header_html(ctx))), + "ticket_type-header-child", _ticket_type_header_html(ctx))), ) @@ -274,7 +274,7 @@ async def _markets_full(ctx: dict, **kw: Any) -> str: from shared.sx.parser import SxExpr return await render_to_sx_with_env("events-markets-layout-full", {}, post_header=SxExpr(await _post_header_sx(ctx)), - markets_header=SxExpr(await _markets_header_sx(ctx)), + markets_header=SxExpr(_markets_header_sx(ctx)), ) @@ -284,5 +284,5 @@ async def _markets_oob(ctx: dict, **kw: Any) -> str: return await render_to_sx_with_env("events-markets-layout-oob", {}, post_oob=SxExpr(await _post_header_sx(ctx, oob=True)), markets_oob_wrap=SxExpr(await oob_header_sx("post-header-child", - "markets-header-child", await _markets_header_sx(ctx))), + "markets-header-child", _markets_header_sx(ctx))), ) diff --git a/events/sxc/pages/renders.py b/events/sxc/pages/renders.py index 1684fc6..f478172 100644 --- a/events/sxc/pages/renders.py +++ b/events/sxc/pages/renders.py @@ -40,7 +40,7 @@ async def render_all_events_page(ctx: dict, entries, has_more, pending_tickets, ticket_url = url_for("all_events.adjust_ticket") next_url = prefix + url_for("all_events.entries_fragment", page=page + 1) + (f"?view={view}" if view != "list" else "") - content = await _events_main_panel_html( + content = _events_main_panel_html( ctx, entries, has_more, pending_tickets, page_info, page, view, ticket_url, next_url, events_url, ) @@ -59,15 +59,15 @@ async def render_all_events_oob(ctx: dict, entries, has_more, pending_tickets, ticket_url = url_for("all_events.adjust_ticket") next_url = prefix + url_for("all_events.entries_fragment", page=page + 1) + (f"?view={view}" if view != "list" else "") - content = await _events_main_panel_html( + content = _events_main_panel_html( ctx, entries, has_more, pending_tickets, page_info, page, view, ticket_url, next_url, events_url, ) return await oob_page_sx(content=content) -async def render_all_events_cards(entries, has_more, pending_tickets, - page_info, page, view) -> str: +def render_all_events_cards(entries, has_more, pending_tickets, + page_info, page, view) -> str: """Pagination fragment: all events cards only.""" from quart import url_for from shared.utils import route_prefix @@ -77,7 +77,7 @@ async def render_all_events_cards(entries, has_more, pending_tickets, ticket_url = url_for("all_events.adjust_ticket") next_url = prefix + url_for("all_events.entries_fragment", page=page + 1) + (f"?view={view}" if view != "list" else "") - return await _entry_cards_html( + return _entry_cards_html( entries, page_info, pending_tickets, ticket_url, events_url, view, page, has_more, next_url, ) @@ -99,7 +99,7 @@ async def render_page_summary_page(ctx: dict, entries, has_more, pending_tickets ticket_url = url_for("page_summary.adjust_ticket") next_url = prefix + url_for("page_summary.entries_fragment", page=page + 1) + (f"?view={view}" if view != "list" else "") - content = await _events_main_panel_html( + content = _events_main_panel_html( ctx, entries, has_more, pending_tickets, page_info, page, view, ticket_url, next_url, events_url, is_page_scoped=True, post=post, @@ -122,7 +122,7 @@ async def render_page_summary_oob(ctx: dict, entries, has_more, pending_tickets, ticket_url = url_for("page_summary.adjust_ticket") next_url = prefix + url_for("page_summary.entries_fragment", page=page + 1) + (f"?view={view}" if view != "list" else "") - content = await _events_main_panel_html( + content = _events_main_panel_html( ctx, entries, has_more, pending_tickets, page_info, page, view, ticket_url, next_url, events_url, is_page_scoped=True, post=post, @@ -133,8 +133,8 @@ async def render_page_summary_oob(ctx: dict, entries, has_more, pending_tickets, return await oob_page_sx(oobs=oobs, content=content) -async def render_page_summary_cards(entries, has_more, pending_tickets, - page_info, page, view, post) -> str: +def render_page_summary_cards(entries, has_more, pending_tickets, + page_info, page, view, post) -> str: """Pagination fragment: page-scoped events cards only.""" from quart import url_for from shared.utils import route_prefix @@ -144,7 +144,7 @@ async def render_page_summary_cards(entries, has_more, pending_tickets, ticket_url = url_for("page_summary.adjust_ticket") next_url = prefix + url_for("page_summary.entries_fragment", page=page + 1) + (f"?view={view}" if view != "list" else "") - return await _entry_cards_html( + return _entry_cards_html( entries, page_info, pending_tickets, ticket_url, events_url, view, page, has_more, next_url, is_page_scoped=True, post=post, @@ -157,7 +157,7 @@ async def render_page_summary_cards(entries, has_more, pending_tickets, async def render_calendars_page(ctx: dict) -> str: """Full page: calendars listing.""" - content = await _calendars_main_panel_sx(ctx) + content = _calendars_main_panel_sx(ctx) ctx = await _ensure_container_nav(ctx) slug = (ctx.get("post") or {}).get("slug", "") root_hdr = await render_to_sx_with_env("layout-root-full", {}) @@ -168,7 +168,7 @@ async def render_calendars_page(ctx: dict) -> str: async def render_calendars_oob(ctx: dict) -> str: """OOB response: calendars listing.""" - content = await _calendars_main_panel_sx(ctx) + content = _calendars_main_panel_sx(ctx) ctx = await _ensure_container_nav(ctx) slug = (ctx.get("post") or {}).get("slug", "") oobs = await post_admin_header_sx(ctx, slug, oob=True, selected="calendars") @@ -183,19 +183,19 @@ async def render_calendars_oob(ctx: dict) -> str: async def render_calendar_page(ctx: dict) -> str: """Full page: calendar month view.""" - content = await _calendar_main_panel_html(ctx) + content = _calendar_main_panel_html(ctx) hdr = await render_to_sx_with_env("layout-root-full", {}) - child = await _post_header_sx(ctx) + await _calendar_header_sx(ctx) + child = await _post_header_sx(ctx) + _calendar_header_sx(ctx) hdr += await header_child_sx(child) return await full_page_sx(ctx, header_rows=hdr, content=content) async def render_calendar_oob(ctx: dict) -> str: """OOB response: calendar month view.""" - content = await _calendar_main_panel_html(ctx) + content = _calendar_main_panel_html(ctx) oobs = await _post_header_sx(ctx, oob=True) oobs += await oob_header_sx("post-header-child", "calendar-header-child", - await _calendar_header_sx(ctx)) + _calendar_header_sx(ctx)) oobs += _clear_deeper_oob("post-row", "post-header-child", "calendar-row", "calendar-header-child") return await oob_page_sx(oobs=oobs, content=content) @@ -207,20 +207,20 @@ async def render_calendar_oob(ctx: dict) -> str: async def render_day_page(ctx: dict) -> str: """Full page: day detail.""" - content = await _day_main_panel_html(ctx) + content = _day_main_panel_html(ctx) hdr = await render_to_sx_with_env("layout-root-full", {}) child = (await _post_header_sx(ctx) - + await _calendar_header_sx(ctx) + await _day_header_sx(ctx)) + + _calendar_header_sx(ctx) + _day_header_sx(ctx)) hdr += await header_child_sx(child) return await full_page_sx(ctx, header_rows=hdr, content=content) async def render_day_oob(ctx: dict) -> str: """OOB response: day detail.""" - content = await _day_main_panel_html(ctx) - oobs = await _calendar_header_sx(ctx, oob=True) + content = _day_main_panel_html(ctx) + oobs = _calendar_header_sx(ctx, oob=True) oobs += await oob_header_sx("calendar-header-child", "day-header-child", - await _day_header_sx(ctx)) + _day_header_sx(ctx)) oobs += _clear_deeper_oob("post-row", "post-header-child", "calendar-row", "calendar-header-child", "day-row", "day-header-child") @@ -231,36 +231,36 @@ async def render_day_oob(ctx: dict) -> str: # Day main panel -- public API # --------------------------------------------------------------------------- -async def render_day_main_panel(ctx: dict) -> str: +def render_day_main_panel(ctx: dict) -> str: """Public wrapper for day main panel rendering.""" - return await _day_main_panel_html(ctx) + return _day_main_panel_html(ctx) # --------------------------------------------------------------------------- # Calendar description display + edit form # --------------------------------------------------------------------------- -async def render_calendar_description(calendar, *, oob: bool = False) -> str: +def render_calendar_description(calendar, *, oob: bool = False) -> str: """Render calendar description display with edit button, optionally with OOB title.""" from quart import url_for - from shared.sx.helpers import render_to_sx + from shared.sx.helpers import sx_call cal_slug = getattr(calendar, "slug", "") edit_url = url_for("calendar.admin.calendar_description_edit", calendar_slug=cal_slug) - html = await _calendar_description_display_html(calendar, edit_url) + html = _calendar_description_display_html(calendar, edit_url) if oob: desc = getattr(calendar, "description", "") or "" - html += await render_to_sx("events-calendar-description-title-oob", + html += sx_call("events-calendar-description-title-oob", description=desc) return html -async def render_calendar_description_edit(calendar) -> str: +def render_calendar_description_edit(calendar) -> str: """Render calendar description edit form.""" from quart import url_for from shared.browser.app.csrf import generate_csrf_token - from shared.sx.helpers import render_to_sx + from shared.sx.helpers import sx_call csrf = generate_csrf_token() cal_slug = getattr(calendar, "slug", "") desc = getattr(calendar, "description", "") or "" @@ -268,7 +268,7 @@ async def render_calendar_description_edit(calendar) -> str: save_url = url_for("calendar.admin.calendar_description_save", calendar_slug=cal_slug) cancel_url = url_for("calendar.admin.calendar_description_view", calendar_slug=cal_slug) - return await render_to_sx("events-calendar-description-edit-form", + return sx_call("events-calendar-description-edit-form", save_url=save_url, cancel_url=cancel_url, csrf=csrf, description=desc) @@ -277,11 +277,11 @@ async def render_calendar_description_edit(calendar) -> str: # Calendars / Markets list panels (for POST create / DELETE) # --------------------------------------------------------------------------- -async def render_calendars_list_panel(ctx: dict) -> str: +def render_calendars_list_panel(ctx: dict) -> str: """Render the calendars main panel HTML for POST/DELETE response.""" - return await _calendars_main_panel_sx(ctx) + return _calendars_main_panel_sx(ctx) -async def render_markets_list_panel(ctx: dict) -> str: +def render_markets_list_panel(ctx: dict) -> str: """Render the markets main panel HTML for POST/DELETE response.""" - return await _markets_main_panel_html(ctx) + return _markets_main_panel_html(ctx) diff --git a/events/sxc/pages/slots.py b/events/sxc/pages/slots.py index b3ad565..6661a90 100644 --- a/events/sxc/pages/slots.py +++ b/events/sxc/pages/slots.py @@ -2,7 +2,7 @@ from __future__ import annotations -from shared.sx.helpers import render_to_sx +from shared.sx.helpers import sx_call from shared.sx.parser import SxExpr @@ -111,7 +111,7 @@ _SLOT_PICKER_JS = """\ # Slot options (shared by entry edit + add forms) # --------------------------------------------------------------------------- -async def _slot_options_html(day_slots, selected_slot_id=None) -> str: +def _slot_options_html(day_slots, selected_slot_id=None) -> str: """Build slot