4 Commits

Author SHA1 Message Date
e085fe43b4 Replace sx_call() with render_to_sx() across all services
All checks were successful
Build and Deploy / build-and-deploy (push) Successful in 2m6s
Python no longer generates s-expression strings. All SX rendering now
goes through render_to_sx() which builds AST from native Python values
and evaluates via async_eval_to_sx() — no SX string literals in Python.

- Add render_to_sx()/render_to_html() infrastructure in shared/sx/helpers.py
- Add (abort status msg) IO primitive in shared/sx/primitives_io.py
- Convert all 9 services: ~650 sx_call() invocations replaced
- Convert shared helpers (root_header_sx, full_page_sx, etc.) to async
- Fix likes service import bug (likes.models → models)

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-04 00:08:33 +00:00
0554f8a113 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 <noreply@anthropic.com>
2026-03-03 23:00:58 +00:00
4e5f9ff16c 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 <noreply@anthropic.com>
2026-03-03 22:41:19 +00:00
193578ef88 Move SX construction from Python to .sx defcomps (phases 0-4)
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 <noreply@anthropic.com>
2026-03-03 22:36:34 +00:00
69 changed files with 3847 additions and 3856 deletions

View File

@@ -56,6 +56,6 @@ def register(url_prefix="/"):
await g.s.flush()
from sx.sx_components import render_newsletter_toggle
return sx_response(render_newsletter_toggle(un))
return sx_response(await render_newsletter_toggle(un))
return account_bp

View File

@@ -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)))))

View File

@@ -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")))))))

View File

@@ -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))))))

View File

@@ -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,
render_to_sx,
root_header_sx, full_page_sx,
)
@@ -21,101 +21,72 @@ 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 = await root_header_sx(ctx)
content = await render_to_sx("account-login-content",
error=error or None, email=email)
return await full_page_sx(ctx, header_rows=hdr,
content=content,
meta_html='<title>Login \u2014 Rose Ash</title>')
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 = await root_header_sx(ctx)
content = await render_to_sx("account-device-content",
error=error or None, code=code)
return await full_page_sx(ctx, header_rows=hdr,
content=content,
meta_html='<title>Authorize Device \u2014 Rose Ash</title>')
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 = await root_header_sx(ctx)
content = await render_to_sx("account-device-approved")
return await full_page_sx(ctx, header_rows=hdr,
content=content,
meta_html='<title>Device Authorized \u2014 Rose Ash</title>')
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 = await root_header_sx(ctx)
content = await render_to_sx("account-check-email-content",
email=email, email_error=email_error)
return await full_page_sx(ctx, header_rows=hdr,
content=content,
meta_html='<title>Check your email \u2014 Rose Ash</title>')
# ---------------------------------------------------------------------------
# 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
async 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,216 +95,13 @@ 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(
return await render_to_sx(
"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,
knob_cls=f"inline-block h-4 w-4 rounded-full bg-white shadow transform transition-transform {translate}",
)
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)
# ---------------------------------------------------------------------------
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.
SxExpr (from text/sx responses) is embedded as-is; plain strings
(from text/html) are wrapped in ``~rich-text``.
"""
from shared.sx.parser import SxExpr
if isinstance(frag, SxExpr):
return frag.source
s = str(frag) if frag else ""
if not s:
return ""
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='<title>Login \u2014 Rose Ash</title>')
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='<title>Authorize Device \u2014 Rose Ash</title>')
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='<title>Device Authorized \u2014 Rose Ash</title>')
# ---------------------------------------------------------------------------
# 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='<title>Check your email \u2014 Rose Ash</title>')
# ---------------------------------------------------------------------------
# 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('"', '\\"')

View File

@@ -26,30 +26,51 @@ def _register_account_layouts() -> None:
register_custom_layout("account", _account_full, _account_oob, _account_mobile)
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
async def _account_full(ctx: dict, **kw: Any) -> str:
from shared.sx.helpers import root_header_sx, header_child_sx, render_to_sx
root_hdr = root_header_sx(ctx)
hdr_child = header_child_sx(_auth_header_sx(ctx))
root_hdr = await root_header_sx(ctx)
auth_hdr = await render_to_sx("auth-header-row",
account_url=_call_url(ctx, "account_url", ""),
select_colours=ctx.get("select_colours", ""),
account_nav=_as_sx_nav(ctx),
)
hdr_child = await 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
async def _account_oob(ctx: dict, **kw: Any) -> str:
from shared.sx.helpers import root_header_sx, render_to_sx
return "(<> " + _auth_header_sx(ctx, oob=True) + " " + root_header_sx(ctx, oob=True) + ")"
auth_hdr = await render_to_sx("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 + " " + await 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
async def _account_mobile(ctx: dict, **kw: Any) -> str:
from shared.sx.helpers import mobile_menu_sx, mobile_root_nav_sx, render_to_sx
from shared.sx.parser import SxExpr
ctx = _inject_account_nav(ctx)
auth_section = sx_call("mobile-menu-section",
nav_items = await render_to_sx("auth-nav-items",
account_url=_call_url(ctx, "account_url", ""),
select_colours=ctx.get("select_colours", ""),
account_nav=_as_sx_nav(ctx),
)
auth_section = await render_to_sx("mobile-menu-section",
label="account", href="/", level=1, colour="sky",
items=SxExpr(_auth_nav_mobile_sx(ctx)))
return mobile_menu_sx(auth_section, mobile_root_nav_sx(ctx))
items=SxExpr(nav_items))
return mobile_menu_sx(auth_section, await mobile_root_nav_sx(ctx))
def _call_url(ctx: dict, key: str, path: str = "/") -> str:
fn = ctx.get(key)
if callable(fn):
return fn(path)
return str(fn or "") + path
def _inject_account_nav(ctx: dict) -> dict:
@@ -61,6 +82,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 component calls."""
from shared.sx.helpers import _as_sx
ctx = _inject_account_nav(ctx)
return _as_sx(ctx.get("account_nav"))
# ---------------------------------------------------------------------------
# Page helpers
# ---------------------------------------------------------------------------
@@ -69,22 +97,18 @@ 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 render_to_sx
result = await g.s.execute(
select(GhostNewsletter).order_by(GhostNewsletter.name)
@@ -102,20 +126,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 await render_to_sx("account-newsletters-content",
newsletter_list=newsletter_list,
account_url=account_url_str)
async def _h_fragment_content(slug=None, **kw):
@@ -130,5 +155,11 @@ async def _h_fragment_content(slug=None, **kw):
)
if not fragment_html:
abort(404)
from sx.sx_components import _fragment_content
return _fragment_content(fragment_html)
from shared.sx.parser import SxExpr
if isinstance(fragment_html, SxExpr):
return fragment_html.source
s = str(fragment_html) if fragment_html else ""
if not s:
return ""
from shared.sx.helpers import render_to_sx
return await render_to_sx("rich-text", html=s)

View File

@@ -8,7 +8,7 @@
:path "/"
:auth :login
:layout :account
:content (account-content))
:content (~account-dashboard-content))
;; ---------------------------------------------------------------------------
;; Newsletters

View File

@@ -235,7 +235,7 @@ def register(url_prefix, title):
from shared.sx.page import get_template_context
from sx.sx_components import render_new_post_page, render_editor_panel
tctx = await get_template_context()
tctx["editor_html"] = render_editor_panel(save_error="Invalid JSON in editor content.")
tctx["editor_html"] = await render_editor_panel(save_error="Invalid JSON in editor content.")
html = await render_new_post_page(tctx)
return await make_response(html, 400)
@@ -244,7 +244,7 @@ def register(url_prefix, title):
from shared.sx.page import get_template_context
from sx.sx_components import render_new_post_page, render_editor_panel
tctx = await get_template_context()
tctx["editor_html"] = render_editor_panel(save_error=reason)
tctx["editor_html"] = await render_editor_panel(save_error=reason)
html = await render_new_post_page(tctx)
return await make_response(html, 400)
@@ -291,7 +291,7 @@ def register(url_prefix, title):
from shared.sx.page import get_template_context
from sx.sx_components import render_new_post_page, render_editor_panel
tctx = await get_template_context()
tctx["editor_html"] = render_editor_panel(save_error="Invalid JSON in editor content.", is_page=True)
tctx["editor_html"] = await 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)
@@ -301,7 +301,7 @@ def register(url_prefix, title):
from shared.sx.page import get_template_context
from sx.sx_components import render_new_post_page, render_editor_panel
tctx = await get_template_context()
tctx["editor_html"] = render_editor_panel(save_error=reason, is_page=True)
tctx["editor_html"] = await 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)

View File

@@ -17,10 +17,10 @@ from shared.sx.helpers import sx_response
def register():
bp = Blueprint("menu_items", __name__, url_prefix='/settings/menu_items')
def get_menu_items_nav_oob_sync(menu_items):
async def get_menu_items_nav_oob_async(menu_items):
"""Helper to generate OOB update for root nav menu items"""
from sx.sx_components import render_menu_items_nav_oob
return render_menu_items_nav_oob(menu_items)
return await render_menu_items_nav_oob(menu_items)
@bp.get("/new/")
@require_admin
@@ -51,8 +51,8 @@ def register():
# Get updated list and nav OOB
menu_items = await get_all_menu_items(g.s)
from sx.sx_components import render_menu_items_list
html = render_menu_items_list(menu_items)
nav_oob = get_menu_items_nav_oob_sync(menu_items)
html = await render_menu_items_list(menu_items)
nav_oob = await get_menu_items_nav_oob_async(menu_items)
return sx_response(html + nav_oob)
except MenuItemError as e:
@@ -91,8 +91,8 @@ def register():
# Get updated list and nav OOB
menu_items = await get_all_menu_items(g.s)
from sx.sx_components import render_menu_items_list
html = render_menu_items_list(menu_items)
nav_oob = get_menu_items_nav_oob_sync(menu_items)
html = await render_menu_items_list(menu_items)
nav_oob = await get_menu_items_nav_oob_async(menu_items)
return sx_response(html + nav_oob)
except MenuItemError as e:
@@ -112,8 +112,8 @@ def register():
# Get updated list and nav OOB
menu_items = await get_all_menu_items(g.s)
from sx.sx_components import render_menu_items_list
html = render_menu_items_list(menu_items)
nav_oob = get_menu_items_nav_oob_sync(menu_items)
html = await render_menu_items_list(menu_items)
nav_oob = await get_menu_items_nav_oob_async(menu_items)
return sx_response(html + nav_oob)
@bp.get("/pages/search/")
@@ -128,7 +128,7 @@ def register():
has_more = (page * per_page) < total
from sx.sx_components import render_page_search_results
return sx_response(render_page_search_results(pages, query, page, has_more))
return sx_response(await render_page_search_results(pages, query, page, has_more))
@bp.post("/reorder/")
@require_admin
@@ -153,8 +153,8 @@ def register():
# Get updated list and nav OOB
menu_items = await get_all_menu_items(g.s)
from sx.sx_components import render_menu_items_list
html = render_menu_items_list(menu_items)
nav_oob = get_menu_items_nav_oob_sync(menu_items)
html = await render_menu_items_list(menu_items)
nav_oob = await get_menu_items_nav_oob_async(menu_items)
return sx_response(html + nav_oob)
return bp

View File

@@ -90,7 +90,7 @@ def register():
features = result.get("features", {})
from sx.sx_components import render_features_panel
html = render_features_panel(
html = await render_features_panel(
features, post,
sumup_configured=result.get("sumup_configured", False),
sumup_merchant_code=result.get("sumup_merchant_code") or "",
@@ -129,7 +129,7 @@ def register():
features = result.get("features", {})
from sx.sx_components import render_features_panel
html = render_features_panel(
html = await render_features_panel(
features, post,
sumup_configured=result.get("sumup_configured", False),
sumup_merchant_code=result.get("sumup_merchant_code") or "",
@@ -259,8 +259,8 @@ def register():
from sx.sx_components import render_associated_entries, render_nav_entries_oob
post = g.post_data["post"]
admin_list = render_associated_entries(all_calendars, associated_entry_ids, post["slug"])
nav_entries_html = render_nav_entries_oob(associated_entries, calendars, 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)
return sx_response(admin_list + nav_entries_html)
@@ -436,7 +436,7 @@ def register():
page_markets = await _fetch_page_markets(post_id)
from sx.sx_components import render_markets_panel
return sx_response(render_markets_panel(page_markets, post))
return sx_response(await render_markets_panel(page_markets, post))
@bp.post("/markets/new/")
@require_admin
@@ -462,7 +462,7 @@ def register():
page_markets = await _fetch_page_markets(post_id)
from sx.sx_components import render_markets_panel
return sx_response(render_markets_panel(page_markets, post))
return sx_response(await render_markets_panel(page_markets, post))
@bp.delete("/markets/<market_slug>/")
@require_admin
@@ -482,6 +482,6 @@ def register():
page_markets = await _fetch_page_markets(post_id)
from sx.sx_components import render_markets_panel
return sx_response(render_markets_panel(page_markets, post))
return sx_response(await render_markets_panel(page_markets, post))
return bp

View File

@@ -125,7 +125,7 @@ def register():
# Get post_id from g.post_data
if not g.user:
return sx_response(render_like_toggle_button(slug, False, like_url), status=403)
return sx_response(await render_like_toggle_button(slug, False, like_url), status=403)
post_id = g.post_data["post"]["id"]
user_id = g.user.id
@@ -135,7 +135,7 @@ def register():
})
liked = result["liked"]
return sx_response(render_like_toggle_button(slug, liked, like_url))
return sx_response(await render_like_toggle_button(slug, liked, like_url))
@bp.get("/w/<widget_domain>/")
async def widget_paginate(slug: str, widget_domain: str):

View File

@@ -47,7 +47,7 @@ def register():
snippets = await _visible_snippets(g.s)
from sx.sx_components import render_snippets_list
return sx_response(render_snippets_list(snippets, is_admin))
return sx_response(await render_snippets_list(snippets, is_admin))
@bp.patch("/<int:snippet_id>/visibility/")
@require_login
@@ -71,6 +71,6 @@ def register():
snippets = await _visible_snippets(g.s)
from sx.sx_components import render_snippets_list
return sx_response(render_snippets_list(snippets, True))
return sx_response(await render_snippets_list(snippets, True))
return bp

File diff suppressed because it is too large Load Diff

View File

@@ -129,148 +129,148 @@ def _register_blog_layouts() -> None:
# --- Blog layout (root + blog header) ---
def _blog_full(ctx: dict, **kw: Any) -> str:
async def _blog_full(ctx: dict, **kw: Any) -> str:
from shared.sx.helpers import root_header_sx
from sx.sx_components import _blog_header_sx
root_hdr = root_header_sx(ctx)
blog_hdr = _blog_header_sx(ctx)
root_hdr = await root_header_sx(ctx)
blog_hdr = await _blog_header_sx(ctx)
return "(<> " + root_hdr + " " + blog_hdr + ")"
def _blog_oob(ctx: dict, **kw: Any) -> str:
async def _blog_oob(ctx: dict, **kw: Any) -> str:
from shared.sx.helpers import root_header_sx, oob_header_sx
from sx.sx_components import _blog_header_sx
root_hdr = root_header_sx(ctx)
blog_hdr = _blog_header_sx(ctx)
root_hdr = await root_header_sx(ctx)
blog_hdr = await _blog_header_sx(ctx)
rows = "(<> " + root_hdr + " " + blog_hdr + ")"
return oob_header_sx("root-header-child", "blog-header-child", rows)
return await oob_header_sx("root-header-child", "blog-header-child", rows)
# --- Settings layout (root + settings header) ---
def _settings_full(ctx: dict, **kw: Any) -> str:
async def _settings_full(ctx: dict, **kw: Any) -> str:
from shared.sx.helpers import root_header_sx
from sx.sx_components import _settings_header_sx
root_hdr = root_header_sx(ctx)
settings_hdr = _settings_header_sx(ctx)
root_hdr = await root_header_sx(ctx)
settings_hdr = await _settings_header_sx(ctx)
return "(<> " + root_hdr + " " + settings_hdr + ")"
def _settings_oob(ctx: dict, **kw: Any) -> str:
async def _settings_oob(ctx: dict, **kw: Any) -> str:
from shared.sx.helpers import root_header_sx, oob_header_sx
from sx.sx_components import _settings_header_sx
root_hdr = root_header_sx(ctx)
settings_hdr = _settings_header_sx(ctx)
root_hdr = await root_header_sx(ctx)
settings_hdr = await _settings_header_sx(ctx)
rows = "(<> " + root_hdr + " " + settings_hdr + ")"
return oob_header_sx("root-header-child", "root-settings-header-child", rows)
return await oob_header_sx("root-header-child", "root-settings-header-child", rows)
def _settings_mobile(ctx: dict, **kw: Any) -> str:
async def _settings_mobile(ctx: dict, **kw: Any) -> str:
from sx.sx_components import _settings_nav_sx
return _settings_nav_sx(ctx)
return await _settings_nav_sx(ctx)
# --- Sub-settings helpers ---
def _sub_settings_full(ctx: dict, row_id: str, child_id: 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 root_header_sx
from sx.sx_components import _settings_header_sx, _sub_settings_header_sx
from quart import url_for as qurl
root_hdr = root_header_sx(ctx)
settings_hdr = _settings_header_sx(ctx)
sub_hdr = _sub_settings_header_sx(row_id, child_id,
root_hdr = await root_header_sx(ctx)
settings_hdr = await _settings_header_sx(ctx)
sub_hdr = await _sub_settings_header_sx(row_id, child_id,
qurl(endpoint), icon, label, ctx)
return "(<> " + root_hdr + " " + settings_hdr + " " + sub_hdr + ")"
def _sub_settings_oob(ctx: dict, row_id: str, child_id: str,
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
from sx.sx_components import _settings_header_sx, _sub_settings_header_sx
from quart import url_for as qurl
settings_hdr_oob = _settings_header_sx(ctx, oob=True)
sub_hdr = _sub_settings_header_sx(row_id, child_id,
settings_hdr_oob = await _settings_header_sx(ctx, oob=True)
sub_hdr = await _sub_settings_header_sx(row_id, child_id,
qurl(endpoint), icon, label, ctx)
sub_oob = oob_header_sx("root-settings-header-child", child_id, sub_hdr)
sub_oob = await oob_header_sx("root-settings-header-child", child_id, sub_hdr)
return "(<> " + settings_hdr_oob + " " + sub_oob + ")"
# --- Cache ---
def _cache_full(ctx: dict, **kw: Any) -> str:
return _sub_settings_full(ctx, "cache-row", "cache-header-child",
async def _cache_full(ctx: dict, **kw: Any) -> str:
return await _sub_settings_full(ctx, "cache-row", "cache-header-child",
"defpage_cache_page", "refresh", "Cache")
def _cache_oob(ctx: dict, **kw: Any) -> str:
return _sub_settings_oob(ctx, "cache-row", "cache-header-child",
async def _cache_oob(ctx: dict, **kw: Any) -> str:
return await _sub_settings_oob(ctx, "cache-row", "cache-header-child",
"defpage_cache_page", "refresh", "Cache")
# --- Snippets ---
def _snippets_full(ctx: dict, **kw: Any) -> str:
return _sub_settings_full(ctx, "snippets-row", "snippets-header-child",
async def _snippets_full(ctx: dict, **kw: Any) -> str:
return await _sub_settings_full(ctx, "snippets-row", "snippets-header-child",
"defpage_snippets_page", "puzzle-piece", "Snippets")
def _snippets_oob(ctx: dict, **kw: Any) -> str:
return _sub_settings_oob(ctx, "snippets-row", "snippets-header-child",
async def _snippets_oob(ctx: dict, **kw: Any) -> str:
return await _sub_settings_oob(ctx, "snippets-row", "snippets-header-child",
"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",
async def _menu_items_full(ctx: dict, **kw: Any) -> str:
return await _sub_settings_full(ctx, "menu_items-row", "menu_items-header-child",
"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",
async def _menu_items_oob(ctx: dict, **kw: Any) -> str:
return await _sub_settings_oob(ctx, "menu_items-row", "menu_items-header-child",
"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",
async def _tag_groups_full(ctx: dict, **kw: Any) -> str:
return await _sub_settings_full(ctx, "tag-groups-row", "tag-groups-header-child",
"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",
async def _tag_groups_oob(ctx: dict, **kw: Any) -> str:
return await _sub_settings_oob(ctx, "tag-groups-row", "tag-groups-header-child",
"defpage_tag_groups_page", "tags", "Tag Groups")
# --- Tag Group Edit ---
def _tag_group_edit_full(ctx: dict, **kw: Any) -> str:
async def _tag_group_edit_full(ctx: dict, **kw: Any) -> str:
from quart import request
g_id = (request.view_args or {}).get("id")
from quart import url_for as qurl
from shared.sx.helpers import root_header_sx
from sx.sx_components import _settings_header_sx, _sub_settings_header_sx
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",
root_hdr = await root_header_sx(ctx)
settings_hdr = await _settings_header_sx(ctx)
sub_hdr = await _sub_settings_header_sx("tag-groups-row", "tag-groups-header-child",
qurl("defpage_tag_group_edit", id=g_id),
"tags", "Tag Groups", ctx)
return "(<> " + root_hdr + " " + settings_hdr + " " + sub_hdr + ")"
def _tag_group_edit_oob(ctx: dict, **kw: Any) -> str:
async def _tag_group_edit_oob(ctx: dict, **kw: Any) -> str:
from quart import request
g_id = (request.view_args or {}).get("id")
from quart import url_for as qurl
from shared.sx.helpers import oob_header_sx
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",
settings_hdr_oob = await _settings_header_sx(ctx, oob=True)
sub_hdr = await _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 = oob_header_sx("root-settings-header-child", "tag-groups-header-child", sub_hdr)
sub_oob = await oob_header_sx("root-settings-header-child", "tag-groups-header-child", sub_hdr)
return "(<> " + settings_hdr_oob + " " + sub_oob + ")"
@@ -302,12 +302,12 @@ def _register_blog_helpers() -> None:
async def _h_editor_content(**kw):
from sx.sx_components import render_editor_panel
return render_editor_panel()
return await 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)
return await render_editor_panel(is_page=True)
# --- Post admin helpers ---
@@ -391,7 +391,7 @@ async def _h_post_preview_content(slug=None, **kw):
from sx.sx_components import _preview_main_panel_sx
tctx = await get_template_context()
tctx.update(preview_ctx)
return _preview_main_panel_sx(tctx)
return await _preview_main_panel_sx(tctx)
async def _h_post_entries_content(slug=None, **kw):
@@ -415,7 +415,7 @@ async def _h_post_entries_content(slug=None, **kw):
tctx = await get_template_context()
tctx["all_calendars"] = all_calendars
tctx["associated_entry_ids"] = associated_entry_ids
return _post_entries_content_sx(tctx)
return await _post_entries_content_sx(tctx)
async def _h_post_settings_content(slug=None, **kw):
@@ -468,7 +468,7 @@ async def _h_post_edit_content(slug=None, **kw):
tctx["save_success"] = save_success
tctx["save_error"] = save_error
tctx["newsletters"] = newsletters
return _post_edit_content_sx(tctx)
return await _post_edit_content_sx(tctx)
# --- Settings helpers ---
@@ -484,7 +484,7 @@ 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)
return await _cache_main_panel_sx(tctx)
# --- Snippets helper ---
@@ -506,7 +506,7 @@ async def _h_snippets_content(**kw):
tctx = await get_template_context()
tctx["snippets"] = rows
tctx["is_admin"] = is_admin
return _snippets_main_panel_sx(tctx)
return await _snippets_main_panel_sx(tctx)
# --- Menu Items helper ---
@@ -519,7 +519,7 @@ async def _h_menu_items_content(**kw):
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)
return await _menu_items_main_panel_sx(tctx)
# --- Tag Groups helpers ---
@@ -539,7 +539,7 @@ async def _h_tag_groups_content(**kw):
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)
return await _tag_groups_main_panel_sx(tctx)
async def _h_tag_group_edit_content(id=None, **kw):
@@ -571,4 +571,4 @@ async def _h_tag_group_edit_content(id=None, **kw):
"all_tags": all_tags,
"assigned_tag_ids": set(assigned_rows),
})
return _tag_groups_edit_main_panel_sx(tctx)
return await _tag_groups_edit_main_panel_sx(tctx)

View File

@@ -49,7 +49,7 @@ def register():
from shared.sx.page import get_template_context
from sx.sx_components import render_cart_payments_panel
ctx = await get_template_context()
html = render_cart_payments_panel(ctx)
html = await render_cart_payments_panel(ctx)
return sx_response(html)
return bp

View File

@@ -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)))

View File

@@ -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))))

View File

@@ -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)))

View File

@@ -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
@@ -16,9 +16,10 @@ from shared.sx.helpers import (
post_header_sx as _shared_post_header_sx,
search_desktop_sx, search_mobile_sx,
full_page_sx, oob_page_sx, header_child_sx,
sx_call, SxExpr,
render_to_sx,
)
from shared.infrastructure.urls import market_product_url, cart_url
from shared.sx.parser import SxExpr
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:
@@ -68,12 +69,12 @@ async def _post_header_sx(ctx: dict, page_post: Any, *, oob: bool = False) -> st
"""Build post-level header row from page_post DTO, using shared helper."""
ctx = _ensure_post_ctx(ctx, page_post)
ctx = await _ensure_container_nav(ctx)
return _shared_post_header_sx(ctx, oob=oob)
return await _shared_post_header_sx(ctx, oob=oob)
def _cart_header_sx(ctx: dict, *, oob: bool = False) -> str:
async def _cart_header_sx(ctx: dict, *, oob: bool = False) -> str:
"""Build the cart section header row."""
return sx_call(
return await render_to_sx(
"menu-row-sx",
id="cart-row", level=1, colour="sky",
link_href=call_url(ctx, "cart_url", "/"),
@@ -82,17 +83,17 @@ def _cart_header_sx(ctx: dict, *, oob: bool = False) -> str:
)
def _page_cart_header_sx(ctx: dict, page_post: Any, *, oob: bool = False) -> str:
async def _page_cart_header_sx(ctx: dict, page_post: Any, *, oob: bool = False) -> str:
"""Build the per-page cart header row."""
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(sx_call("cart-page-label-img", src=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) + ")"
nav_sx = sx_call("cart-all-carts-link", href=call_url(ctx, "cart_url", "/"))
return sx_call(
nav_sx = await render_to_sx("cart-all-carts-link", href=call_url(ctx, "cart_url", "/"))
return await render_to_sx(
"menu-row-sx",
id="page-cart-row", level=2, colour="sky",
link_href=call_url(ctx, "cart_url", f"/{slug}/"),
@@ -101,496 +102,77 @@ def _page_cart_header_sx(ctx: dict, page_post: Any, *, oob: bool = False) -> str
)
def _auth_header_sx(ctx: dict, *, oob: bool = False) -> str:
async 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,
return await render_to_sx(
"auth-header-row-simple",
account_url=call_url(ctx, "account_url", ""),
oob=oob,
)
def _orders_header_sx(ctx: dict, list_url: str) -> str:
async 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 await render_to_sx("orders-header-row", list_url=list_url)
async 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 await 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,31 +184,62 @@ 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 = await render_to_sx("orders-list-content",
orders=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)
orders_hdr = _orders_header_sx(ctx, list_url)
auth_child = sx_call(
hdr = await root_header_sx(ctx)
auth = await _auth_header_sx(ctx)
orders_hdr = await _orders_header_sx(ctx, list_url)
auth_child_inner = await render_to_sx("header-child-sx", id="auth-header-child", inner=SxExpr(orders_hdr))
auth_child = await render_to_sx(
"header-child-sx",
inner=SxExpr("(<> " + auth + " " + sx_call("header-child-sx", id="auth-header-child", inner=SxExpr(orders_hdr)) + ")"),
inner=SxExpr("(<> " + auth + " " + auth_child_inner + ")"),
)
header_rows = "(<> " + hdr + " " + auth_child + ")"
return full_page_sx(ctx, header_rows=header_rows,
filter=_orders_summary_sx(ctx),
aside=search_desktop_sx(ctx),
content=main)
filt = await render_to_sx("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: 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 = []
for od in order_dicts:
parts.append(await render_to_sx("order-row-pair",
order=od,
detail_url_prefix=detail_url_prefix))
if page < total_pages:
next_url = list_url + qs_fn(page=page + 1)
parts.append(await render_to_sx(
"infinite-scroll",
url=next_url, page=page, total_pages=total_pages,
id_prefix="orders", colspan=5,
))
else:
parts.append(await render_to_sx("order-end-row"))
return "(<> " + " ".join(parts) + ")"
async def render_orders_oob(ctx: dict, orders: list, page: int,
@@ -638,28 +251,36 @@ 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 = await render_to_sx("orders-list-content",
orders=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(
auth_oob = await _auth_header_sx(ctx, oob=True)
orders_hdr = await _orders_header_sx(ctx, list_url)
auth_child_oob = await render_to_sx(
"oob-header-sx",
parent_id="auth-header-child",
row=SxExpr(_orders_header_sx(ctx, list_url)),
row=SxExpr(orders_hdr),
)
root_oob = root_header_sx(ctx, oob=True)
root_oob = await root_header_sx(ctx, oob=True)
oobs = "(<> " + auth_oob + " " + auth_child_oob + " " + root_oob + ")"
return oob_page_sx(oobs=oobs,
filter=_orders_summary_sx(ctx),
aside=search_desktop_sx(ctx),
content=main)
filt = await render_to_sx("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)
# ---------------------------------------------------------------------------
# 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,24 +296,35 @@ 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 [])]
hdr = root_header_sx(ctx)
order_row = sx_call(
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())
hdr = await root_header_sx(ctx)
order_row = await render_to_sx(
"menu-row-sx",
id="order-row", level=3, colour="sky",
link_href=detail_url, link_label=f"Order {order.id}", icon="fa fa-gbp",
)
order_child = sx_call(
auth = await _auth_header_sx(ctx)
orders_hdr = await _orders_header_sx(ctx, list_url)
orders_child = await render_to_sx("header-child-sx", id="orders-header-child", inner=SxExpr(order_row))
auth_inner = "(<> " + orders_hdr + " " + orders_child + ")"
auth_child = await render_to_sx("header-child-sx", id="auth-header-child", inner=SxExpr(auth_inner))
order_child = await render_to_sx(
"header-child-sx",
inner=SxExpr("(<> " + _auth_header_sx(ctx) + " " + sx_call("header-child-sx", id="auth-header-child", inner=SxExpr(
"(<> " + _orders_header_sx(ctx, list_url) + " " + sx_call("header-child-sx", id="orders-header-child", inner=SxExpr(order_row)) + ")"
)) + ")"),
inner=SxExpr("(<> " + auth + " " + auth_child + ")"),
)
header_rows = "(<> " + hdr + " " + order_child + ")"
return full_page_sx(ctx, header_rows=header_rows, filter=filt, content=main)
return await full_page_sx(ctx, header_rows=header_rows, filter=filt, content=main)
async def render_order_oob(ctx: dict, order: Any,
@@ -708,100 +340,69 @@ 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 [])]
order_row_oob = sx_call(
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())
order_row_oob = await render_to_sx(
"menu-row-sx",
id="order-row", level=3, colour="sky",
link_href=detail_url, link_label=f"Order {order.id}", icon="fa fa-gbp",
oob=True,
)
orders_child_oob = sx_call("oob-header-sx",
orders_child_oob = await render_to_sx("oob-header-sx",
parent_id="orders-header-child",
row=SxExpr(order_row_oob))
root_oob = root_header_sx(ctx, oob=True)
root_oob = await root_header_sx(ctx, oob=True)
oobs = "(<> " + orders_child_oob + " " + root_oob + ")"
return oob_page_sx(oobs=oobs, filter=filt, content=main)
return await oob_page_sx(oobs=oobs, filter=filt, content=main)
# ---------------------------------------------------------------------------
# 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}")
order_sx = await render_to_sx("checkout-error-order-id", oid=f"#{order.id}")
back_url = cart_url("/")
return sx_call(
hdr = await root_header_sx(ctx)
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=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)
return await full_page_sx(ctx, header_rows=hdr, filter=filt, content=content)
# ---------------------------------------------------------------------------
# Page admin (/<page_slug>/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:
async 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 await render_to_sx("cart-payments-content",
page_config=pc_data)

View File

@@ -27,31 +27,35 @@ def _register_cart_layouts() -> None:
register_custom_layout("cart-admin", _cart_admin_full, _cart_admin_oob)
def _cart_page_full(ctx: dict, **kw: Any) -> str:
from shared.sx.helpers import root_header_sx, sx_call, SxExpr
async def _cart_page_full(ctx: dict, **kw: Any) -> str:
from shared.sx.helpers import root_header_sx, render_to_sx
from shared.sx.parser import SxExpr
from sx.sx_components import _cart_header_sx, _page_cart_header_sx
page_post = ctx.get("page_post")
root_hdr = root_header_sx(ctx)
child = _cart_header_sx(ctx)
page_hdr = _page_cart_header_sx(ctx, page_post)
nested = sx_call(
root_hdr = await root_header_sx(ctx)
child = await _cart_header_sx(ctx)
page_hdr = await _page_cart_header_sx(ctx, page_post)
inner_child = await render_to_sx("header-child-sx", id="cart-header-child", inner=SxExpr(page_hdr))
nested = await render_to_sx(
"header-child-sx",
inner=SxExpr("(<> " + child + " " + sx_call("header-child-sx", id="cart-header-child", inner=SxExpr(page_hdr)) + ")"),
inner=SxExpr("(<> " + child + " " + inner_child + ")"),
)
return "(<> " + root_hdr + " " + nested + ")"
def _cart_page_oob(ctx: dict, **kw: Any) -> str:
from shared.sx.helpers import root_header_sx, sx_call, SxExpr
async def _cart_page_oob(ctx: dict, **kw: Any) -> str:
from shared.sx.helpers import root_header_sx, render_to_sx
from shared.sx.parser import SxExpr
from sx.sx_components import _cart_header_sx, _page_cart_header_sx
page_post = ctx.get("page_post")
child_oob = sx_call("oob-header-sx",
page_hdr = await _page_cart_header_sx(ctx, page_post)
child_oob = await render_to_sx("oob-header-sx",
parent_id="cart-header-child",
row=SxExpr(_page_cart_header_sx(ctx, page_post)))
cart_hdr_oob = _cart_header_sx(ctx, oob=True)
root_hdr_oob = root_header_sx(ctx, oob=True)
row=SxExpr(page_hdr))
cart_hdr_oob = await _cart_header_sx(ctx, oob=True)
root_hdr_oob = await root_header_sx(ctx, oob=True)
return "(<> " + child_oob + " " + cart_hdr_oob + " " + root_hdr_oob + ")"
@@ -61,9 +65,9 @@ async def _cart_admin_full(ctx: dict, **kw: Any) -> str:
page_post = ctx.get("page_post")
selected = kw.get("selected", "")
root_hdr = root_header_sx(ctx)
root_hdr = await root_header_sx(ctx)
post_hdr = await _post_header_sx(ctx, page_post)
admin_hdr = _cart_page_admin_header_sx(ctx, page_post, selected=selected)
admin_hdr = await _cart_page_admin_header_sx(ctx, page_post, selected=selected)
return "(<> " + root_hdr + " " + post_hdr + " " + admin_hdr + ")"
@@ -72,7 +76,7 @@ async def _cart_admin_oob(ctx: dict, **kw: Any) -> str:
page_post = ctx.get("page_post")
selected = kw.get("selected", "")
return _cart_page_admin_header_sx(ctx, page_post, oob=True, selected=selected)
return await _cart_page_admin_header_sx(ctx, page_post, oob=True, selected=selected)
# ---------------------------------------------------------------------------
@@ -90,20 +94,176 @@ 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 render_to_sx
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 await render_to_sx("cart-overview-content",
page_groups=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 render_to_sx
from shared.sx.parser import SxExpr
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 +277,41 @@ 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 = await render_to_sx("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 await render_to_sx("cart-page-cart-content",
cart_items=[_serialize_cart_item(i) for i in cart],
cal_entries=[_serialize_cal_entry(e) for e in cal_entries],
ticket_groups=[_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 render_to_sx
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 await render_to_sx("cart-payments-content",
page_config=pc_data)

View File

@@ -126,7 +126,7 @@ def register() -> Blueprint:
frag_params["session_id"] = ident["session_id"]
from sx.sx_components import render_ticket_widget
widget_html = render_ticket_widget(entry, qty, "/all-tickets/adjust")
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 ""))

View File

@@ -19,7 +19,7 @@ def register():
@require_admin
async def calendar_description_edit(calendar_slug: str, **kwargs):
from sx.sx_components import render_calendar_description_edit
html = render_calendar_description_edit(g.calendar)
html = await render_calendar_description_edit(g.calendar)
return sx_response(html)
@@ -35,7 +35,7 @@ def register():
await g.s.flush()
from sx.sx_components import render_calendar_description
html = render_calendar_description(g.calendar, oob=True)
html = await 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 sx.sx_components import render_calendar_description
html = render_calendar_description(g.calendar)
html = await render_calendar_description(g.calendar)
return sx_response(html)
return bp

View File

@@ -201,7 +201,7 @@ def register():
from shared.sx.page import get_template_context
from sx.sx_components import _calendar_admin_main_panel_html
ctx = await get_template_context()
html = _calendar_admin_main_panel_html(ctx)
html = await _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 sx.sx_components import render_calendars_list_panel
ctx = await get_template_context()
html = render_calendars_list_panel(ctx)
html = await 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 = render_post_nav_entries_oob(associated_entries, cals, post_data["post"])
nav_oob = await render_post_nav_entries_oob(associated_entries, cals, post_data["post"])
html = html + nav_oob
return sx_response(html)

View File

@@ -259,7 +259,7 @@ def register():
}
from sx.sx_components import render_day_main_panel
html = render_day_main_panel(ctx)
html = await 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 sx.sx_components import render_entry_add_form
return sx_response(render_entry_add_form(g.calendar, day, month, year, day_slots))
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 sx.sx_components import render_entry_add_button
return sx_response(render_entry_add_button(g.calendar, day, month, year))
return sx_response(await render_entry_add_button(g.calendar, day, month, year))

View File

@@ -112,7 +112,7 @@ def register():
# Render OOB nav
from sx.sx_components import render_day_entries_nav_oob
return render_day_entries_nav_oob(visible.confirmed_entries, calendar, day_date)
return await 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 sx.sx_components import render_post_nav_entries_oob
nav_oob = render_post_nav_entries_oob(associated_entries, calendars, post)
nav_oob = await 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 sx.sx_components import render_entry_edit_form
return sx_response(render_entry_edit_form(g.entry, g.calendar, day, month, year, day_slots))
return sx_response(await render_entry_edit_form(g.entry, g.calendar, day, month, year, day_slots))
@bp.put("/")
@require_admin
@@ -423,7 +423,7 @@ def register():
from sx.sx_components import _entry_main_panel_html
tctx = await get_template_context()
html = _entry_main_panel_html(tctx)
html = await _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 sx.sx_components import render_entry_optioned
html = render_entry_optioned(g.entry, g.calendar, day, month, year)
html = await 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 sx.sx_components import render_entry_optioned
html = render_entry_optioned(g.entry, g.calendar, day, month, year)
html = await 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 sx.sx_components import render_entry_optioned
html = render_entry_optioned(g.entry, g.calendar, day, month, year)
html = await 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 sx.sx_components import render_entry_tickets_config
html = render_entry_tickets_config(g.entry, g.calendar, request.view_args.get("day"), request.view_args.get("month"), request.view_args.get("year"))
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)
@bp.get("/posts/search/")
@@ -559,7 +559,7 @@ def register():
va = request.view_args or {}
from sx.sx_components import render_post_search_results
return sx_response(render_post_search_results(
return sx_response(await 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 sx.sx_components import render_entry_posts_panel, render_entry_posts_nav_oob
va = request.view_args or {}
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)
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)
return sx_response(html + nav_oob)
@bp.delete("/posts/<int:post_id>/")
@@ -616,8 +616,8 @@ def register():
# Return updated posts list + OOB nav update
from sx.sx_components import render_entry_posts_panel, render_entry_posts_nav_oob
va = request.view_args or {}
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)
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)
return sx_response(html + nav_oob)
return bp

View File

@@ -69,7 +69,7 @@ def register():
from shared.sx.page import get_template_context
from sx.sx_components import render_calendars_list_panel
ctx = await get_template_context()
html = render_calendars_list_panel(ctx)
html = await 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 = render_post_nav_entries_oob(associated_entries, cals, post_data["post"])
nav_oob = await render_post_nav_entries_oob(associated_entries, cals, post_data["post"])
html = html + nav_oob
return sx_response(html)

View File

@@ -44,7 +44,7 @@ def register():
from shared.sx.page import get_template_context
from sx.sx_components import render_markets_list_panel
ctx = await get_template_context()
return sx_response(render_markets_list_panel(ctx))
return sx_response(await render_markets_list_panel(ctx))
@bp.delete("/<market_slug>/")
@require_admin
@@ -57,6 +57,6 @@ def register():
from shared.sx.page import get_template_context
from sx.sx_components import render_markets_list_panel
ctx = await get_template_context()
return sx_response(render_markets_list_panel(ctx))
return sx_response(await render_markets_list_panel(ctx))
return bp

View File

@@ -107,7 +107,7 @@ def register() -> Blueprint:
frag_params["session_id"] = ident["session_id"]
from sx.sx_components import render_ticket_widget
widget_html = render_ticket_widget(entry, qty, f"/{g.post_slug}/tickets/adjust")
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 ""))

View File

@@ -36,7 +36,7 @@ def register():
if not slot:
return await make_response("Not found", 404)
from sx.sx_components import render_slot_edit_form
return sx_response(render_slot_edit_form(slot, g.calendar))
return sx_response(await 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 sx.sx_components import render_slot_main_panel
return sx_response(render_slot_main_panel(slot, g.calendar))
return sx_response(await 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 sx.sx_components import render_slots_table
return sx_response(render_slots_table(slots, g.calendar))
return sx_response(await render_slots_table(slots, g.calendar))
@bp.put("/")
@require_admin
@@ -136,7 +136,7 @@ def register():
), 422
from sx.sx_components import render_slot_main_panel
return sx_response(render_slot_main_panel(slot, g.calendar, oob=True))
return sx_response(await render_slot_main_panel(slot, g.calendar, oob=True))

View File

@@ -111,19 +111,19 @@ def register():
# Success → re-render the slots table
slots = await svc_list_slots(g.s, g.calendar.id)
from sx.sx_components import render_slots_table
return sx_response(render_slots_table(slots, g.calendar))
return sx_response(await render_slots_table(slots, g.calendar))
@bp.get("/add")
@require_admin
async def add_form(**kwargs):
from sx.sx_components import render_slot_add_form
return sx_response(render_slot_add_form(g.calendar))
return sx_response(await render_slot_add_form(g.calendar))
@bp.get("/add-button")
@require_admin
async def add_button(**kwargs):
from sx.sx_components import render_slot_add_button
return sx_response(render_slot_add_button(g.calendar))
return sx_response(await render_slot_add_button(g.calendar))
return bp

View File

@@ -54,7 +54,7 @@ def register() -> Blueprint:
tickets = await get_tickets_for_entry(g.s, entry_id)
from sx.sx_components import render_entry_tickets_admin
html = render_entry_tickets_admin(entry, tickets)
html = await 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 sx.sx_components import render_lookup_result
if not ticket:
return sx_response(render_lookup_result(None, "Ticket not found"))
return sx_response(await render_lookup_result(None, "Ticket not found"))
return sx_response(render_lookup_result(ticket, None))
return sx_response(await render_lookup_result(ticket, None))
@bp.post("/<code>/checkin/")
@require_admin
@@ -84,9 +84,9 @@ def register() -> Blueprint:
from sx.sx_components import render_checkin_result
if not success:
return sx_response(render_checkin_result(False, error, None))
return sx_response(await render_checkin_result(False, error, None))
ticket = await get_ticket_by_code(g.s, code)
return sx_response(render_checkin_result(True, None, ticket))
return sx_response(await render_checkin_result(True, None, ticket))
return bp

View File

@@ -32,7 +32,7 @@ def register():
from sx.sx_components import render_ticket_type_edit_form
va = request.view_args or {}
return sx_response(render_ticket_type_edit_form(
return sx_response(await 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 sx.sx_components import render_ticket_type_main_panel
va = request.view_args or {}
return sx_response(render_ticket_type_main_panel(
return sx_response(await 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 sx.sx_components import render_ticket_type_main_panel
va = request.view_args or {}
return sx_response(render_ticket_type_main_panel(
return sx_response(await 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 sx.sx_components import render_ticket_types_table
va = request.view_args or {}
return sx_response(render_ticket_types_table(
return sx_response(await render_ticket_types_table(
ticket_types, g.entry, g.calendar,
va.get("day"), va.get("month"), va.get("year"),
))

View File

@@ -95,7 +95,7 @@ def register():
ticket_types = await svc_list_ticket_types(g.s, g.entry.id)
from sx.sx_components import render_ticket_types_table
va = request.view_args or {}
return sx_response(render_ticket_types_table(
return sx_response(await 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 sx.sx_components import render_ticket_type_add_form
va = request.view_args or {}
return sx_response(render_ticket_type_add_form(
return sx_response(await 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 sx.sx_components import render_ticket_type_add_button
va = request.view_args or {}
return sx_response(render_ticket_type_add_button(
return sx_response(await render_ticket_type_add_button(
g.entry, g.calendar,
va.get("day"), va.get("month"), va.get("year"),
))

View File

@@ -127,7 +127,7 @@ def register() -> Blueprint:
cart_count = summary.count + summary.calendar_count + summary.ticket_count
from sx.sx_components import render_buy_result
return sx_response(render_buy_result(entry, created, remaining, cart_count))
return sx_response(await 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 sx.sx_components import render_adjust_response
return sx_response(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,
))

File diff suppressed because it is too large Load Diff

View File

@@ -44,11 +44,11 @@ async def _cal_admin_full(ctx: dict, **kw: Any) -> str:
)
ctx = await _ensure_container_nav(ctx)
slug = (ctx.get("post") or {}).get("slug", "")
root_hdr = root_header_sx(ctx)
post_hdr = _post_header_sx(ctx)
admin_hdr = post_admin_header_sx(ctx, slug, selected="calendars")
child = admin_hdr + _calendar_header_sx(ctx) + _calendar_admin_header_sx(ctx)
return root_hdr + post_hdr + header_child_sx(child)
root_hdr = await root_header_sx(ctx)
post_hdr = await _post_header_sx(ctx)
admin_hdr = await post_admin_header_sx(ctx, slug, selected="calendars")
child = admin_hdr + await _calendar_header_sx(ctx) + await _calendar_admin_header_sx(ctx)
return root_hdr + post_hdr + await header_child_sx(child)
async def _cal_admin_oob(ctx: dict, **kw: Any) -> str:
@@ -59,10 +59,10 @@ async def _cal_admin_oob(ctx: dict, **kw: Any) -> str:
)
ctx = await _ensure_container_nav(ctx)
slug = (ctx.get("post") or {}).get("slug", "")
oobs = (post_admin_header_sx(ctx, slug, oob=True, selected="calendars")
+ _calendar_header_sx(ctx, oob=True))
oobs += oob_header_sx("calendar-header-child", "calendar-admin-header-child",
_calendar_admin_header_sx(ctx))
oobs = (await post_admin_header_sx(ctx, slug, oob=True, selected="calendars")
+ await _calendar_header_sx(ctx, oob=True))
oobs += await oob_header_sx("calendar-header-child", "calendar-admin-header-child",
await _calendar_admin_header_sx(ctx))
oobs += _clear_deeper_oob("post-row", "post-header-child",
"post-admin-row", "post-admin-header-child",
"calendar-row", "calendar-header-child",
@@ -83,8 +83,8 @@ async def _slots_oob(ctx: dict, **kw: Any) -> str:
)
ctx = await _ensure_container_nav({**ctx, "is_admin_section": True})
slug = (ctx.get("post") or {}).get("slug", "")
oobs = (post_admin_header_sx(ctx, slug, oob=True, selected="calendars")
+ _calendar_admin_header_sx(ctx, oob=True))
oobs = (await post_admin_header_sx(ctx, slug, oob=True, selected="calendars")
+ await _calendar_admin_header_sx(ctx, oob=True))
oobs += _clear_deeper_oob("post-row", "post-header-child",
"post-admin-row", "post-admin-header-child",
"calendar-row", "calendar-header-child",
@@ -102,12 +102,12 @@ async def _slot_full(ctx: dict, **kw: Any) -> str:
)
ctx = await _ensure_container_nav({**ctx, "is_admin_section": True})
slug = (ctx.get("post") or {}).get("slug", "")
root_hdr = root_header_sx(ctx)
post_hdr = _post_header_sx(ctx)
admin_hdr = post_admin_header_sx(ctx, slug, selected="calendars")
child = (admin_hdr + _calendar_header_sx(ctx)
+ _calendar_admin_header_sx(ctx) + _slot_header_html(ctx))
return root_hdr + post_hdr + header_child_sx(child)
root_hdr = await root_header_sx(ctx)
post_hdr = await _post_header_sx(ctx)
admin_hdr = await post_admin_header_sx(ctx, slug, selected="calendars")
child = (admin_hdr + await _calendar_header_sx(ctx)
+ await _calendar_admin_header_sx(ctx) + await _slot_header_html(ctx))
return root_hdr + post_hdr + await header_child_sx(child)
async def _slot_oob(ctx: dict, **kw: Any) -> str:
@@ -118,10 +118,10 @@ async def _slot_oob(ctx: dict, **kw: Any) -> str:
)
ctx = await _ensure_container_nav({**ctx, "is_admin_section": True})
slug = (ctx.get("post") or {}).get("slug", "")
oobs = (post_admin_header_sx(ctx, slug, oob=True, selected="calendars")
+ _calendar_admin_header_sx(ctx, oob=True))
oobs += oob_header_sx("calendar-admin-header-child", "slot-header-child",
_slot_header_html(ctx))
oobs = (await post_admin_header_sx(ctx, slug, oob=True, selected="calendars")
+ await _calendar_admin_header_sx(ctx, oob=True))
oobs += await oob_header_sx("calendar-admin-header-child", "slot-header-child",
await _slot_header_html(ctx))
oobs += _clear_deeper_oob("post-row", "post-header-child",
"post-admin-row", "post-admin-header-child",
"calendar-row", "calendar-header-child",
@@ -140,12 +140,12 @@ async def _day_admin_full(ctx: dict, **kw: Any) -> str:
)
ctx = await _ensure_container_nav(ctx)
slug = (ctx.get("post") or {}).get("slug", "")
root_hdr = root_header_sx(ctx)
post_hdr = _post_header_sx(ctx)
admin_hdr = post_admin_header_sx(ctx, slug, selected="calendars")
child = (admin_hdr + _calendar_header_sx(ctx) + _day_header_sx(ctx)
+ _day_admin_header_sx(ctx))
return root_hdr + post_hdr + header_child_sx(child)
root_hdr = await root_header_sx(ctx)
post_hdr = await _post_header_sx(ctx)
admin_hdr = await post_admin_header_sx(ctx, slug, selected="calendars")
child = (admin_hdr + await _calendar_header_sx(ctx) + await _day_header_sx(ctx)
+ await _day_admin_header_sx(ctx))
return root_hdr + post_hdr + await header_child_sx(child)
async def _day_admin_oob(ctx: dict, **kw: Any) -> str:
@@ -156,10 +156,10 @@ async def _day_admin_oob(ctx: dict, **kw: Any) -> str:
)
ctx = await _ensure_container_nav(ctx)
slug = (ctx.get("post") or {}).get("slug", "")
oobs = (post_admin_header_sx(ctx, slug, oob=True, selected="calendars")
+ _calendar_header_sx(ctx, oob=True))
oobs += oob_header_sx("day-header-child", "day-admin-header-child",
_day_admin_header_sx(ctx))
oobs = (await post_admin_header_sx(ctx, slug, oob=True, selected="calendars")
+ await _calendar_header_sx(ctx, oob=True))
oobs += await oob_header_sx("day-header-child", "day-admin-header-child",
await _day_admin_header_sx(ctx))
oobs += _clear_deeper_oob("post-row", "post-header-child",
"post-admin-row", "post-admin-header-child",
"calendar-row", "calendar-header-child",
@@ -170,26 +170,26 @@ async def _day_admin_oob(ctx: dict, **kw: Any) -> str:
# --- Entry layout (root + child(post + cal + day + entry), + menu) ---
def _entry_full(ctx: dict, **kw: Any) -> str:
async def _entry_full(ctx: dict, **kw: Any) -> str:
from shared.sx.helpers import root_header_sx, header_child_sx
from sx.sx_components import (
_post_header_sx, _calendar_header_sx,
_day_header_sx, _entry_header_html,
)
root_hdr = root_header_sx(ctx)
child = (_post_header_sx(ctx) + _calendar_header_sx(ctx)
+ _day_header_sx(ctx) + _entry_header_html(ctx))
return root_hdr + header_child_sx(child)
root_hdr = await root_header_sx(ctx)
child = (await _post_header_sx(ctx) + await _calendar_header_sx(ctx)
+ await _day_header_sx(ctx) + await _entry_header_html(ctx))
return root_hdr + await header_child_sx(child)
def _entry_oob(ctx: dict, **kw: Any) -> str:
async def _entry_oob(ctx: dict, **kw: Any) -> str:
from shared.sx.helpers import oob_header_sx
from sx.sx_components import (
_day_header_sx, _entry_header_html, _clear_deeper_oob,
)
oobs = _day_header_sx(ctx, oob=True)
oobs += oob_header_sx("day-header-child", "entry-header-child",
_entry_header_html(ctx))
oobs = await _day_header_sx(ctx, oob=True)
oobs += await oob_header_sx("day-header-child", "entry-header-child",
await _entry_header_html(ctx))
oobs += _clear_deeper_oob("post-row", "post-header-child",
"calendar-row", "calendar-header-child",
"day-row", "day-header-child",
@@ -208,12 +208,12 @@ async def _entry_admin_full(ctx: dict, **kw: Any) -> str:
)
ctx = await _ensure_container_nav(ctx)
slug = (ctx.get("post") or {}).get("slug", "")
root_hdr = root_header_sx(ctx)
post_hdr = _post_header_sx(ctx)
admin_hdr = post_admin_header_sx(ctx, slug, selected="calendars")
child = (admin_hdr + _calendar_header_sx(ctx) + _day_header_sx(ctx)
+ _entry_header_html(ctx) + _entry_admin_header_html(ctx))
return root_hdr + post_hdr + header_child_sx(child)
root_hdr = await root_header_sx(ctx)
post_hdr = await _post_header_sx(ctx)
admin_hdr = await post_admin_header_sx(ctx, slug, selected="calendars")
child = (admin_hdr + await _calendar_header_sx(ctx) + await _day_header_sx(ctx)
+ await _entry_header_html(ctx) + await _entry_admin_header_html(ctx))
return root_hdr + post_hdr + await header_child_sx(child)
async def _entry_admin_oob(ctx: dict, **kw: Any) -> str:
@@ -224,10 +224,10 @@ async def _entry_admin_oob(ctx: dict, **kw: Any) -> str:
)
ctx = await _ensure_container_nav(ctx)
slug = (ctx.get("post") or {}).get("slug", "")
oobs = (post_admin_header_sx(ctx, slug, oob=True, selected="calendars")
+ _entry_header_html(ctx, oob=True))
oobs += oob_header_sx("entry-header-child", "entry-admin-header-child",
_entry_admin_header_html(ctx))
oobs = (await post_admin_header_sx(ctx, slug, oob=True, selected="calendars")
+ await _entry_header_html(ctx, oob=True))
oobs += await oob_header_sx("entry-header-child", "entry-admin-header-child",
await _entry_admin_header_html(ctx))
oobs += _clear_deeper_oob("post-row", "post-header-child",
"post-admin-row", "post-admin-header-child",
"calendar-row", "calendar-header-child",
@@ -239,75 +239,75 @@ async def _entry_admin_oob(ctx: dict, **kw: Any) -> str:
# --- Ticket types layout (extends entry admin with ticket-types header, + menu) ---
def _ticket_types_full(ctx: dict, **kw: Any) -> str:
async def _ticket_types_full(ctx: dict, **kw: Any) -> str:
from shared.sx.helpers import root_header_sx, header_child_sx
from sx.sx_components import (
_post_header_sx, _calendar_header_sx, _day_header_sx,
_entry_header_html, _entry_admin_header_html,
_ticket_types_header_html,
)
root_hdr = root_header_sx(ctx)
child = (_post_header_sx(ctx) + _calendar_header_sx(ctx)
+ _day_header_sx(ctx) + _entry_header_html(ctx)
+ _entry_admin_header_html(ctx) + _ticket_types_header_html(ctx))
return root_hdr + header_child_sx(child)
root_hdr = await root_header_sx(ctx)
child = (await _post_header_sx(ctx) + await _calendar_header_sx(ctx)
+ await _day_header_sx(ctx) + await _entry_header_html(ctx)
+ await _entry_admin_header_html(ctx) + await _ticket_types_header_html(ctx))
return root_hdr + await header_child_sx(child)
def _ticket_types_oob(ctx: dict, **kw: Any) -> str:
async def _ticket_types_oob(ctx: dict, **kw: Any) -> str:
from shared.sx.helpers import oob_header_sx
from sx.sx_components import (
_entry_admin_header_html, _ticket_types_header_html, _clear_deeper_oob,
)
oobs = _entry_admin_header_html(ctx, oob=True)
oobs += oob_header_sx("entry-admin-header-child", "ticket_types-header-child",
_ticket_types_header_html(ctx))
oobs = await _entry_admin_header_html(ctx, oob=True)
oobs += await oob_header_sx("entry-admin-header-child", "ticket_types-header-child",
await _ticket_types_header_html(ctx))
return oobs
# --- Ticket type detail layout (extends ticket types with ticket-type header, + menu) ---
def _ticket_type_full(ctx: dict, **kw: Any) -> str:
async def _ticket_type_full(ctx: dict, **kw: Any) -> str:
from shared.sx.helpers import root_header_sx, header_child_sx
from sx.sx_components import (
_post_header_sx, _calendar_header_sx, _day_header_sx,
_entry_header_html, _entry_admin_header_html,
_ticket_types_header_html, _ticket_type_header_html,
)
root_hdr = root_header_sx(ctx)
child = (_post_header_sx(ctx) + _calendar_header_sx(ctx)
+ _day_header_sx(ctx) + _entry_header_html(ctx)
+ _entry_admin_header_html(ctx) + _ticket_types_header_html(ctx)
+ _ticket_type_header_html(ctx))
return root_hdr + header_child_sx(child)
root_hdr = await root_header_sx(ctx)
child = (await _post_header_sx(ctx) + await _calendar_header_sx(ctx)
+ await _day_header_sx(ctx) + await _entry_header_html(ctx)
+ await _entry_admin_header_html(ctx) + await _ticket_types_header_html(ctx)
+ await _ticket_type_header_html(ctx))
return root_hdr + await header_child_sx(child)
def _ticket_type_oob(ctx: dict, **kw: Any) -> str:
async def _ticket_type_oob(ctx: dict, **kw: Any) -> str:
from shared.sx.helpers import oob_header_sx
from sx.sx_components import (
_ticket_types_header_html, _ticket_type_header_html,
)
oobs = _ticket_types_header_html(ctx, oob=True)
oobs += oob_header_sx("ticket_types-header-child", "ticket_type-header-child",
_ticket_type_header_html(ctx))
oobs = await _ticket_types_header_html(ctx, oob=True)
oobs += await oob_header_sx("ticket_types-header-child", "ticket_type-header-child",
await _ticket_type_header_html(ctx))
return oobs
# --- Markets layout (root + child(post + markets)) ---
def _markets_full(ctx: dict, **kw: Any) -> str:
async def _markets_full(ctx: dict, **kw: Any) -> str:
from shared.sx.helpers import root_header_sx, header_child_sx
from sx.sx_components import _post_header_sx, _markets_header_sx
root_hdr = root_header_sx(ctx)
child = _post_header_sx(ctx) + _markets_header_sx(ctx)
return root_hdr + header_child_sx(child)
root_hdr = await root_header_sx(ctx)
child = await _post_header_sx(ctx) + await _markets_header_sx(ctx)
return root_hdr + await header_child_sx(child)
def _markets_oob(ctx: dict, **kw: Any) -> str:
async def _markets_oob(ctx: dict, **kw: Any) -> str:
from shared.sx.helpers import oob_header_sx
from sx.sx_components import _post_header_sx, _markets_header_sx
oobs = _post_header_sx(ctx, oob=True)
oobs += oob_header_sx("post-header-child", "markets-header-child",
_markets_header_sx(ctx))
oobs = await _post_header_sx(ctx, oob=True)
oobs += await oob_header_sx("post-header-child", "markets-header-child",
await _markets_header_sx(ctx))
return oobs
@@ -518,7 +518,7 @@ async def _h_calendar_admin_content(calendar_slug=None, **kw):
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)
return await _calendar_admin_main_panel_html(ctx)
async def _h_day_admin_content(calendar_slug=None, year=None, month=None, day=None, **kw):
@@ -526,7 +526,7 @@ async def _h_day_admin_content(calendar_slug=None, year=None, month=None, day=No
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({})
return await _day_admin_main_panel_html({})
async def _h_slots_content(calendar_slug=None, **kw):
@@ -537,7 +537,7 @@ async def _h_slots_content(calendar_slug=None, **kw):
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)
return await render_slots_table(slots, calendar)
async def _h_slot_content(calendar_slug=None, slot_id=None, **kw):
@@ -551,7 +551,7 @@ async def _h_slot_content(calendar_slug=None, slot_id=None, **kw):
_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)
return await render_slot_main_panel(slot, calendar)
async def _h_entry_content(calendar_slug=None, entry_id=None, **kw):
@@ -560,7 +560,7 @@ async def _h_entry_content(calendar_slug=None, entry_id=None, **kw):
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)
return await _entry_main_panel_html(ctx)
async def _h_entry_menu(calendar_slug=None, entry_id=None, **kw):
@@ -569,7 +569,7 @@ async def _h_entry_menu(calendar_slug=None, entry_id=None, **kw):
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)
return await _entry_nav_html(ctx)
async def _h_entry_admin_content(calendar_slug=None, entry_id=None, **kw):
@@ -578,12 +578,12 @@ async def _h_entry_admin_content(calendar_slug=None, entry_id=None, **kw):
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)
return await _entry_admin_main_panel_html(ctx)
def _h_admin_menu():
from shared.sx.helpers import sx_call
return sx_call("events-admin-placeholder-nav")
async def _h_admin_menu():
from shared.sx.helpers import render_to_sx
return await render_to_sx("events-admin-placeholder-nav")
async def _h_ticket_types_content(calendar_slug=None, entry_id=None,
@@ -597,7 +597,7 @@ async def _h_ticket_types_content(calendar_slug=None, entry_id=None,
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)
return await render_ticket_types_table(ticket_types, entry, calendar, day, month, year)
async def _h_ticket_type_content(calendar_slug=None, entry_id=None,
@@ -614,7 +614,7 @@ async def _h_ticket_type_content(calendar_slug=None, entry_id=None,
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)
return await render_ticket_type_main_panel(ticket_type, entry, calendar, day, month, year)
async def _h_tickets_content(**kw):
@@ -630,7 +630,7 @@ async def _h_tickets_content(**kw):
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)
return await _tickets_main_panel_html(ctx, tickets)
async def _h_ticket_detail_content(code=None, **kw):
@@ -653,7 +653,7 @@ async def _h_ticket_detail_content(code=None, **kw):
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)
return await _ticket_detail_panel_html(ctx, ticket)
async def _h_ticket_admin_content(**kw):
@@ -693,11 +693,11 @@ async def _h_ticket_admin_content(**kw):
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)
return await _ticket_admin_main_panel_html(ctx, tickets, stats)
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)
return await _markets_main_panel_html(ctx)

View File

@@ -156,7 +156,7 @@ def register(url_prefix="/social"):
else:
list_type = "following"
from sx.sx_components import render_actor_card
return sx_response(render_actor_card(remote_dto, actor, followed_urls, list_type=list_type))
return sx_response(await render_actor_card(remote_dto, actor, followed_urls, list_type=list_type))
# -- Interactions ----------------------------------------------------------
@@ -243,7 +243,7 @@ def register(url_prefix="/social"):
)).scalar())
from sx.sx_components import render_interaction_buttons
return sx_response(render_interaction_buttons(
return sx_response(await render_interaction_buttons(
object_id=object_id,
author_inbox=author_inbox,
like_count=like_count,

View File

@@ -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)))))

View File

@@ -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")))))))

View File

@@ -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)))))))

View File

@@ -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)))))

View File

@@ -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
@@ -11,9 +13,8 @@ from typing import Any
from markupsafe import escape
from shared.sx.jinja_bridge import load_service_components
from shared.sx.parser import serialize
from shared.sx.helpers import (
sx_call, SxExpr,
render_to_sx,
root_header_sx, full_page_sx, header_child_sx,
)
@@ -23,629 +24,106 @@ 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 _social_page(ctx: dict, actor: Any, *, content: str,
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
# ---------------------------------------------------------------------------
async 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."""
hdr = root_header_sx(ctx)
social_hdr = _social_header_sx(actor)
child = header_child_sx(social_hdr)
from shared.sx.parser import SxExpr
actor_data = _serialize_actor(actor)
nav = await render_to_sx("federation-social-nav", actor=actor_data)
social_hdr = await render_to_sx("federation-social-header", nav=SxExpr(nav))
hdr = await root_header_sx(ctx)
child = await header_child_sx(social_hdr)
header_rows = "(<> " + hdr + " " + child + ")"
return full_page_sx(ctx, header_rows=header_rows, content=content,
return await full_page_sx(ctx, header_rows=header_rows, content=content,
meta_html=meta_html or f'<title>{escape(title)}</title>')
# ---------------------------------------------------------------------------
# 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)
hdr = await root_header_sx(ctx)
return await 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 = await render_to_sx("account-login-content",
error=error or None, email=str(escape(email)))
return await _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,
)
return _social_page(ctx, None, content=content,
content = await render_to_sx("account-check-email-content",
email=str(escape(email)), email_error=email_error)
return await _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 <strong>{escape(query)}</strong>",
)
elif query:
info_sx = sx_call(
"federation-search-info",
cls="text-stone-500 mb-4",
text=f"No results found for <strong>{escape(query)}</strong>",
)
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
from shared.sx.parser import SxExpr
csrf = generate_csrf_token()
error = ctx.get("error", "")
@@ -654,89 +132,162 @@ async def render_choose_username_page(ctx: dict) -> str:
check_url = url_for("identity.check_username")
actor = ctx.get("actor")
error_sx = sx_call("auth-error-banner", error=error) if error else ""
content = sx_call(
error_sx = await render_to_sx("auth-error-banner", error=error) if error else ""
content = await render_to_sx(
"federation-choose-username",
domain=str(escape(ap_domain)),
error=SxExpr(error_sx) if error_sx else None,
csrf=csrf, username=str(escape(username)),
check_url=check_url,
)
return _social_page(ctx, actor, content=content,
return await _social_page(ctx, actor, content=content,
title="Choose Username \u2014 Rose Ash")
# ---------------------------------------------------------------------------
# Public API: Actor profile
# Public API: Pagination fragment renderers
# ---------------------------------------------------------------------------
async def render_profile_page(ctx: dict, actor: Any, activities: list,
total: int) -> str:
"""Full page: actor profile."""
from shared.config import config
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)
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 ""
# 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)
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")
return await render_to_sx("federation-timeline-items",
items=item_dicts,
timeline_type=timeline_type,
actor=actor_data,
next_url=next_url)
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")
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(await render_to_sx("federation-actor-card-from-data",
a=ad,
actor=actor_data,
followed_urls=list(followed_urls),
list_type="search"))
if len(actors) >= 20:
next_url = url_for("social.search_page", q=query, page=page + 1)
parts.append(await render_to_sx("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(await render_to_sx("federation-actor-card-from-data",
a=ad,
actor=actor_data,
followed_urls=[],
list_type="following"))
if len(actors) >= 20:
next_url = url_for("social.following_list_page", page=page + 1)
parts.append(await render_to_sx("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(await render_to_sx("federation-actor-card-from-data",
a=ad,
actor=actor_data,
followed_urls=list(followed_urls),
list_type="followers"))
if len(actors) >= 20:
next_url = url_for("social.followers_list_page", page=page + 1)
parts.append(await render_to_sx("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
# ---------------------------------------------------------------------------
def render_interaction_buttons(object_id: str, author_inbox: str,
async 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
from shared.sx.parser import SxExpr
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 = await render_to_sx("federation-reply-link", url=reply_url) if reply_url else ""
like_form = await render_to_sx("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 = await render_to_sx("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 await render_to_sx("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,
async 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 await render_to_sx("federation-actor-card-from-data",
a=ad,
actor=actor_data,
followed_urls=list(followed_urls),
list_type=list_type)

View File

@@ -26,27 +26,31 @@ def _register_federation_layouts() -> None:
register_custom_layout("social", _social_full, _social_oob)
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
async def _social_full(ctx: dict, **kw: Any) -> str:
from shared.sx.helpers import root_header_sx, header_child_sx, render_to_sx
from shared.sx.parser import SxExpr
actor = ctx.get("actor")
root_hdr = root_header_sx(ctx)
social_hdr = _social_header_sx(actor)
child = header_child_sx(social_hdr)
actor_data = _serialize_actor(actor) if actor else None
nav = await render_to_sx("federation-social-nav", actor=actor_data)
social_hdr = await render_to_sx("federation-social-header", nav=SxExpr(nav))
root_hdr = await root_header_sx(ctx)
child = await 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
async def _social_oob(ctx: dict, **kw: Any) -> str:
from shared.sx.helpers import root_header_sx, render_to_sx
from shared.sx.parser import SxExpr
actor = ctx.get("actor")
social_hdr = _social_header_sx(actor)
child_oob = sx_call("oob-header-sx",
actor_data = _serialize_actor(actor) if actor else None
nav = await render_to_sx("federation-social-nav", actor=actor_data)
social_hdr = await render_to_sx("federation-social-header", nav=SxExpr(nav))
child_oob = await render_to_sx("oob-header-sx",
parent_id="root-header-child",
row=SxExpr(social_hdr))
root_hdr_oob = root_header_sx(ctx, oob=True)
root_hdr_oob = await root_header_sx(ctx, oob=True)
return "(<> " + child_oob + " " + root_hdr_oob + ")"
@@ -69,6 +73,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 +143,40 @@ 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 render_to_sx
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 await render_to_sx("federation-timeline-content",
items=[_serialize_timeline_item(i) for i in items],
timeline_type="home",
actor=_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 render_to_sx
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 await render_to_sx("federation-timeline-content",
items=[_serialize_timeline_item(i) for i in items],
timeline_type="public",
actor=_serialize_actor(actor))
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 render_to_sx
_require_actor()
reply_to = request.args.get("reply_to")
return _compose_content_sx(actor, reply_to)
return await render_to_sx("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 render_to_sx
actor = _get_actor()
query = request.args.get("q", "").strip()
actors_list = []
@@ -125,24 +189,32 @@ 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 await render_to_sx("federation-search-content",
query=query,
actors=[_serialize_remote_actor(a) for a in actors_list],
total=total,
followed_urls=list(followed_urls),
actor=_serialize_actor(actor))
async def _h_following_content(**kw):
from quart import g
from shared.services.registry import services
from shared.sx.helpers import render_to_sx
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 await render_to_sx("federation-following-content",
actors=[_serialize_remote_actor(a) for a in actors_list],
total=total,
actor=_serialize_actor(actor))
async def _h_followers_content(**kw):
from quart import g
from shared.services.registry import services
from shared.sx.helpers import render_to_sx
actor = _require_actor()
actors_list, total = await services.federation.get_followers_paginated(
g.s, actor.preferred_username,
@@ -151,13 +223,17 @@ 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 await render_to_sx("federation-followers-content",
actors=[_serialize_remote_actor(a) for a in actors_list],
total=total,
followed_urls=list(followed_urls),
actor=_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 render_to_sx
actor = _get_actor()
actor_id = id
from shared.models.federation import RemoteActor
@@ -184,15 +260,34 @@ 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 await render_to_sx("federation-actor-timeline-content",
remote_actor=_serialize_remote_actor(remote_dto),
items=[_serialize_timeline_item(i) for i in items],
is_following=is_following,
actor=_serialize_actor(actor))
async def _h_notifications_content(**kw):
from quart import g
from shared.services.registry import services
from shared.sx.helpers import render_to_sx
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 await render_to_sx("federation-notifications-content",
notifications=notif_dicts)

View File

@@ -31,7 +31,7 @@ def register() -> Blueprint:
async def _is_liked():
"""Check if a user has liked a specific target."""
from sqlalchemy import select
from likes.models.like import Like
from models.like import Like
user_id = request.args.get("user_id", type=int)
target_type = request.args.get("target_type", "")
@@ -62,7 +62,7 @@ def register() -> Blueprint:
async def _liked_slugs():
"""Return all liked target_slugs for a user + target_type."""
from sqlalchemy import select
from likes.models.like import Like
from models.like import Like
user_id = request.args.get("user_id", type=int)
target_type = request.args.get("target_type", "")
@@ -86,7 +86,7 @@ def register() -> Blueprint:
async def _liked_ids():
"""Return all liked target_ids for a user + target_type."""
from sqlalchemy import select
from likes.models.like import Like
from models.like import Like
user_id = request.args.get("user_id", type=int)
target_type = request.args.get("target_type", "")

View File

@@ -129,7 +129,7 @@ def register():
from sx.sx_components import render_like_toggle_button
if not g.user:
return sx_response(render_like_toggle_button(product_slug, False), status=403)
return sx_response(await render_like_toggle_button(product_slug, False), status=403)
user_id = g.user.id
@@ -138,7 +138,7 @@ def register():
})
liked = result["liked"]
return sx_response(render_like_toggle_button(product_slug, liked))
return sx_response(await render_like_toggle_button(product_slug, liked))
@@ -257,7 +257,7 @@ def register():
from sx.sx_components import render_cart_added_response
item_data = getattr(g, "item_data", {})
d = item_data.get("d", {})
return sx_response(render_cart_added_response(g.cart, ci_ns, d))
return sx_response(await render_cart_added_response(g.cart, ci_ns, d))
# normal POST: go to cart page
from shared.infrastructure.urls import cart_url

File diff suppressed because it is too large Load Diff

View File

@@ -27,55 +27,55 @@ def _register_market_layouts() -> None:
register_custom_layout("market-admin", _market_admin_full, _market_admin_oob)
def _market_full(ctx: dict, **kw: Any) -> str:
async def _market_full(ctx: dict, **kw: Any) -> str:
from shared.sx.helpers import root_header_sx, header_child_sx
from sx.sx_components import _post_header_sx, _market_header_sx
root_hdr = root_header_sx(ctx)
child = "(<> " + _post_header_sx(ctx) + " " + _market_header_sx(ctx) + ")"
return "(<> " + root_hdr + " " + header_child_sx(child) + ")"
root_hdr = await root_header_sx(ctx)
child = "(<> " + await _post_header_sx(ctx) + " " + await _market_header_sx(ctx) + ")"
return "(<> " + root_hdr + " " + await header_child_sx(child) + ")"
def _market_oob(ctx: dict, **kw: Any) -> str:
async def _market_oob(ctx: dict, **kw: Any) -> str:
from shared.sx.helpers import oob_header_sx
from sx.sx_components import _post_header_sx, _market_header_sx, _clear_deeper_oob
oobs = oob_header_sx("post-header-child", "market-header-child",
_market_header_sx(ctx))
oobs = "(<> " + oobs + " " + _post_header_sx(ctx, oob=True) + " "
oobs = await oob_header_sx("post-header-child", "market-header-child",
await _market_header_sx(ctx))
oobs = "(<> " + oobs + " " + await _post_header_sx(ctx, oob=True) + " "
oobs += _clear_deeper_oob("post-row", "post-header-child",
"market-row", "market-header-child") + ")"
return oobs
def _market_mobile(ctx: dict, **kw: Any) -> str:
async def _market_mobile(ctx: dict, **kw: Any) -> str:
from sx.sx_components import _mobile_nav_panel_sx
return _mobile_nav_panel_sx(ctx)
return await _mobile_nav_panel_sx(ctx)
def _market_admin_full(ctx: dict, **kw: Any) -> str:
async def _market_admin_full(ctx: dict, **kw: Any) -> str:
from shared.sx.helpers import root_header_sx, header_child_sx
from sx.sx_components import (
_post_header_sx, _market_header_sx, _market_admin_header_sx,
)
selected = kw.get("selected", "")
root_hdr = root_header_sx(ctx)
child = "(<> " + _post_header_sx(ctx) + " " + _market_header_sx(ctx) + " "
child += _market_admin_header_sx(ctx, selected=selected) + ")"
return "(<> " + root_hdr + " " + header_child_sx(child) + ")"
root_hdr = await root_header_sx(ctx)
child = "(<> " + await _post_header_sx(ctx) + " " + await _market_header_sx(ctx) + " "
child += await _market_admin_header_sx(ctx, selected=selected) + ")"
return "(<> " + root_hdr + " " + await header_child_sx(child) + ")"
def _market_admin_oob(ctx: dict, **kw: Any) -> str:
async def _market_admin_oob(ctx: dict, **kw: Any) -> str:
from shared.sx.helpers import oob_header_sx
from sx.sx_components import (
_market_header_sx, _market_admin_header_sx, _clear_deeper_oob,
)
selected = kw.get("selected", "")
oobs = "(<> " + _market_header_sx(ctx, oob=True) + " "
oobs += oob_header_sx("market-header-child", "market-admin-header-child",
_market_admin_header_sx(ctx, selected=selected)) + " "
oobs = "(<> " + await _market_header_sx(ctx, oob=True) + " "
oobs += await oob_header_sx("market-header-child", "market-admin-header-child",
await _market_admin_header_sx(ctx, selected=selected)) + " "
oobs += _clear_deeper_oob("post-row", "post-header-child",
"market-row", "market-header-child",
"market-admin-row", "market-admin-header-child") + ")"
@@ -123,14 +123,14 @@ async def _h_all_markets_content(**kw):
if not markets:
from sx.sx_components import _no_markets_sx
return _no_markets_sx()
return await _no_markets_sx()
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
cards = _market_cards_sx(markets, page_info, page, has_more, next_url)
content = _markets_grid(cards)
cards = await _market_cards_sx(markets, page_info, page, has_more, next_url)
content = await _markets_grid(cards)
return "(<> " + content + " " + '(div :class "pb-8")' + ")"
@@ -148,15 +148,15 @@ async def _h_page_markets_content(slug=None, **kw):
if not markets:
from sx.sx_components import _no_markets_sx
return _no_markets_sx("No markets for this page")
return await _no_markets_sx("No markets for this page")
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
cards = _market_cards_sx(markets, {}, page, has_more, next_url,
cards = await _market_cards_sx(markets, {}, page, has_more, next_url,
show_page_badge=False, post_slug=post_slug)
content = _markets_grid(cards)
content = await _markets_grid(cards)
return "(<> " + content + " " + '(div :class "pb-8")' + ")"
@@ -168,12 +168,12 @@ async def _h_page_admin_content(slug=None, **kw):
return '(div :id "main-panel" ' + content + ')'
def _h_market_home_content(page_slug=None, market_slug=None, **kw):
async 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", {})
from sx.sx_components import _market_landing_content_sx
return _market_landing_content_sx(post)
return await _market_landing_content_sx(post)
def _h_market_admin_content(page_slug=None, market_slug=None, **kw):

View File

@@ -73,11 +73,40 @@ 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, render_to_sx
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(await render_to_sx("order-row-pair",
order=od,
detail_url_prefix=detail_prefix))
if page < total_pages:
parts.append(await render_to_sx("infinite-scroll",
url=rows_url + qs_fn(page=page + 1),
page=page, total_pages=total_pages,
id_prefix="orders", colspan=5))
else:
parts.append(await render_to_sx("order-end-row"))
sx_src = "(<> " + " ".join(parts) + ")"
resp = sx_response(sx_src)
resp.headers["Hx-Push-Url"] = _current_url_without_page()
return _vary(resp)

View File

@@ -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, render_to_sx,
root_header_sx, full_page_sx, header_child_sx,
)
from shared.infrastructure.urls import market_product_url, cart_url
@@ -22,328 +22,158 @@ 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 = await render_to_sx("auth-header-row", account_url=account_url)
hdr = await root_header_sx(ctx)
hdr = "(<> " + hdr + " " + await header_child_sx(auth_hdr) + ")"
filt = await render_to_sx("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(
order_sx = await render_to_sx("checkout-error-order-id", oid=f"#{order.id}")
from shared.sx.parser import SxExpr
content = await render_to_sx(
"checkout-error-content",
msg=err_msg,
order=SxExpr(order_sx) if order_sx else None,
back_url=back_url,
back_url=cart_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)
return await full_page_sx(ctx, header_rows=hdr, filter=filt, content=content)
# ---------------------------------------------------------------------------
# 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)."""
filt = sx_call("checkout-return-header", status=status)
from shared.sx.parser import SxExpr
filt = await render_to_sx("checkout-return-header", status=status)
if not order:
content = sx_call("checkout-return-missing")
content = await render_to_sx("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 = await render_to_sx("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 = await render_to_sx("order-item-image",
src=item_d["product_image"],
alt=item_d["product_title"] or "Product image")
else:
img = await render_to_sx("order-item-no-image")
item_parts.append(await render_to_sx("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 = await render_to_sx("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(await render_to_sx("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 = await render_to_sx("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(await render_to_sx("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 = await render_to_sx("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)
status_msg = await render_to_sx("checkout-return-failed", order_id=order.id)
elif order.status == "paid":
status_msg = sx_call("checkout-return-paid")
status_msg = await render_to_sx("checkout-return-paid")
content = sx_call(
"checkout-return-content",
content = await render_to_sx("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,
)
hdr = root_header_sx(ctx)
inner = _auth_header_sx(ctx)
hdr = "(<> " + hdr + " " + header_child_sx(inner) + ")"
account_url = call_url(ctx, "account_url", "")
auth_hdr = await render_to_sx("auth-header-row", account_url=account_url)
hdr = await root_header_sx(ctx)
hdr = "(<> " + hdr + " " + await header_child_sx(auth_hdr) + ")"
return full_page_sx(ctx, header_rows=hdr, filter=filt, content=content)
return await full_page_sx(ctx, header_rows=hdr, filter=filt, content=content)

View File

@@ -28,74 +28,103 @@ def _register_orders_layouts() -> None:
register_custom_layout("order-detail", _order_detail_full, _order_detail_oob, _order_detail_mobile)
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
async def _orders_full(ctx: dict, **kw: Any) -> str:
from shared.sx.helpers import root_header_sx, header_child_sx, call_url, render_to_sx
list_url = kw.get("list_url", "/")
root_hdr = root_header_sx(ctx)
inner = "(<> " + _auth_header_sx(ctx) + " " + _orders_header_sx(ctx, list_url) + ")"
return "(<> " + root_hdr + " " + header_child_sx(inner) + ")"
account_url = call_url(ctx, "account_url", "")
root_hdr = await root_header_sx(ctx)
auth_hdr = await render_to_sx("auth-header-row",
account_url=account_url,
select_colours=ctx.get("select_colours", ""),
account_nav=_as_sx_nav(ctx),
)
orders_hdr = await render_to_sx("orders-header-row", list_url=list_url)
inner = "(<> " + auth_hdr + " " + orders_hdr + ")"
return "(<> " + root_hdr + " " + await 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
async def _orders_oob(ctx: dict, **kw: Any) -> str:
from shared.sx.helpers import root_header_sx, render_to_sx
from shared.sx.helpers import call_url
from shared.sx.parser import SxExpr
list_url = kw.get("list_url", "/")
auth_hdr = _auth_header_sx(ctx, oob=True)
auth_child_oob = sx_call("oob-header-sx",
parent_id="auth-header-child",
row=SxExpr(_orders_header_sx(ctx, list_url)))
root_hdr = root_header_sx(ctx, oob=True)
account_url = call_url(ctx, "account_url", "")
auth_hdr = await render_to_sx("auth-header-row",
account_url=account_url,
select_colours=ctx.get("select_colours", ""),
account_nav=_as_sx_nav(ctx),
oob=True,
)
orders_hdr = await render_to_sx("orders-header-row", list_url=list_url)
auth_child_oob = await render_to_sx("oob-header-sx",
parent_id="auth-header-child",
row=SxExpr(orders_hdr))
root_hdr = await root_header_sx(ctx, oob=True)
return "(<> " + auth_hdr + " " + auth_child_oob + " " + root_hdr + ")"
def _orders_mobile(ctx: dict, **kw: Any) -> str:
async def _orders_mobile(ctx: dict, **kw: Any) -> str:
from shared.sx.helpers import mobile_menu_sx, mobile_root_nav_sx
return mobile_menu_sx(mobile_root_nav_sx(ctx))
return mobile_menu_sx(await mobile_root_nav_sx(ctx))
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
async def _order_detail_full(ctx: dict, **kw: Any) -> str:
from shared.sx.helpers import root_header_sx, render_to_sx
from shared.sx.helpers import call_url
from shared.sx.parser import SxExpr
list_url = kw.get("list_url", "/")
detail_url = kw.get("detail_url", "/")
root_hdr = root_header_sx(ctx)
order_row = sx_call(
account_url = call_url(ctx, "account_url", "")
root_hdr = await root_header_sx(ctx)
order_row = await render_to_sx(
"menu-row-sx",
id="order-row", level=3, colour="sky", link_href=detail_url,
link_label="Order", icon="fa fa-gbp",
)
detail_header = sx_call(
auth_hdr = await render_to_sx("auth-header-row",
account_url=account_url,
select_colours=ctx.get("select_colours", ""),
account_nav=_as_sx_nav(ctx),
)
orders_hdr = await render_to_sx("orders-header-row", list_url=list_url)
detail_header = await render_to_sx(
"order-detail-header-stack",
auth=SxExpr(_auth_header_sx(ctx)),
orders=SxExpr(_orders_header_sx(ctx, list_url)),
auth=SxExpr(auth_hdr),
orders=SxExpr(orders_hdr),
order=SxExpr(order_row),
)
return "(<> " + root_hdr + " " + detail_header + ")"
def _order_detail_oob(ctx: dict, **kw: Any) -> str:
from shared.sx.helpers import root_header_sx, sx_call, SxExpr
async def _order_detail_oob(ctx: dict, **kw: Any) -> str:
from shared.sx.helpers import root_header_sx, render_to_sx
from shared.sx.parser import SxExpr
detail_url = kw.get("detail_url", "/")
order_row_oob = sx_call(
order_row_oob = await render_to_sx(
"menu-row-sx",
id="order-row", level=3, colour="sky", link_href=detail_url,
link_label="Order", icon="fa fa-gbp", oob=True,
)
header_child_oob = sx_call("oob-header-sx",
header_child_oob = await render_to_sx("oob-header-sx",
parent_id="orders-header-child",
row=SxExpr(order_row_oob))
root_hdr = root_header_sx(ctx, oob=True)
root_hdr = await root_header_sx(ctx, oob=True)
return "(<> " + header_child_oob + " " + root_hdr + ")"
def _order_detail_mobile(ctx: dict, **kw: Any) -> str:
async def _order_detail_mobile(ctx: dict, **kw: Any) -> str:
from shared.sx.helpers import mobile_menu_sx, mobile_root_nav_sx
return mobile_menu_sx(mobile_root_nav_sx(ctx))
return mobile_menu_sx(await mobile_root_nav_sx(ctx))
def _as_sx_nav(ctx: dict) -> Any:
"""Convert account_nav fragment to SxExpr for use in component calls."""
from shared.sx.helpers import _as_sx
return _as_sx(ctx.get("account_nav"))
# ---------------------------------------------------------------------------
@@ -257,43 +286,67 @@ 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 render_to_sx
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)
return await render_to_sx("order-empty-state")
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 await render_to_sx("orders-list-content",
orders=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):
await _ensure_orders_list()
from quart import g
from shared.sx.helpers import sx_call, SxExpr
from shared.sx.helpers import render_to_sx
from shared.sx.page import SEARCH_HEADERS_MOBILE
from shared.sx.parser import SxExpr
d = getattr(g, "orders_page_data", None)
search = d.get("search", "") if d else ""
search_count = d.get("search_count", "") if d else ""
search_mobile = sx_call("search-mobile",
search_mobile = await render_to_sx("search-mobile",
current_local_href="/",
search=search or "",
search_count=search_count or "",
hx_select="#main-panel",
search_headers_mobile=SEARCH_HEADERS_MOBILE,
)
return sx_call("order-list-header", search_mobile=SxExpr(search_mobile))
return await render_to_sx("order-list-header", search_mobile=SxExpr(search_mobile))
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.helpers import render_to_sx
from shared.sx.page import SEARCH_HEADERS_DESKTOP
d = getattr(g, "orders_page_data", None)
search = d.get("search", "") if d else ""
search_count = d.get("search_count", "") if d else ""
return sx_call("search-desktop",
return await render_to_sx("search-desktop",
current_local_href="/",
search=search or "",
search_count=search_count or "",
@@ -312,22 +365,74 @@ 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 render_to_sx
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 await render_to_sx("order-detail-content",
order=order_dict,
calendar_entries=cal_dicts)
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 render_to_sx
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 await render_to_sx("order-detail-filter-content",
order=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):

View File

@@ -149,22 +149,22 @@ async def _rich_error_page(errnum: str, message: str, image: str | None = None)
# Root header (site nav bar)
from shared.sx.helpers import (
root_header_sx, post_header_sx,
header_child_sx, full_page_sx, sx_call,
header_child_sx, full_page_sx, render_to_sx,
)
hdr = root_header_sx(ctx)
hdr = await root_header_sx(ctx)
# Post breadcrumb if we resolved a post
post = (post_data or {}).get("post") or ctx.get("post") or {}
if post.get("slug"):
ctx["post"] = post
post_row = post_header_sx(ctx)
post_row = await post_header_sx(ctx)
if post_row:
hdr = "(<> " + hdr + " " + header_child_sx(post_row) + ")"
hdr = "(<> " + hdr + " " + await header_child_sx(post_row) + ")"
# Error content
error_content = sx_call("error-content", errnum=errnum, message=message, image=image)
error_content = await render_to_sx("error-content", errnum=errnum, message=message, image=image)
return full_page_sx(ctx, header_rows=hdr, content=error_content)
return await full_page_sx(ctx, header_rows=hdr, content=error_content)
except Exception:
current_app.logger.debug("Rich error page failed, falling back", exc_info=True)
return None

View File

@@ -114,18 +114,18 @@ async def _render_profile_sx(actor, activities, total):
# Import federation layout for OOB headers
try:
from federation.sxc.pages import _social_oob
oob_headers = _social_oob(tctx)
oob_headers = await _social_oob(tctx)
except ImportError:
oob_headers = ""
return sx_response(oob_page_sx(oobs=oob_headers, content=content))
return sx_response(await oob_page_sx(oobs=oob_headers, content=content))
else:
try:
from federation.sxc.pages import _social_full
header_rows = _social_full(tctx)
header_rows = await _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)
header_rows = await root_header_sx(tctx)
return await full_page_sx(tctx, header_rows=header_rows, content=content)
def create_activitypub_blueprint(app_name: str) -> Blueprint:

View File

@@ -92,14 +92,14 @@ def create_ap_social_blueprint(app_name: str) -> Blueprint:
kw = {"actor": actor}
if is_htmx_request():
oob_headers = _social_oob_headers(tctx, **kw)
return sx_response(oob_page_sx(
oob_headers = await _social_oob_headers(tctx, **kw)
return sx_response(await 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)
header_rows = await _social_full_headers(tctx, **kw)
return await full_page_sx(tctx, header_rows=header_rows, content=content)
# -- Index ----------------------------------------------------------------

View File

@@ -14,10 +14,9 @@ from typing import Any
from markupsafe import escape
from shared.sx.helpers import (
sx_call, root_header_sx, oob_header_sx,
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
# ---------------------------------------------------------------------------
@@ -91,23 +90,23 @@ def _social_header_row(actor: Any) -> str:
)
def _social_full_headers(ctx: dict, **kw: Any) -> str:
root_hdr = root_header_sx(ctx)
async def _social_full_headers(ctx: dict, **kw: Any) -> str:
root_hdr = await 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)
async def _social_oob_headers(ctx: dict, **kw: Any) -> str:
root_hdr = await 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)
return await 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))
async def _social_mobile(ctx: dict, **kw: Any) -> str:
return mobile_menu_sx(await mobile_root_nav_sx(ctx))
# ---------------------------------------------------------------------------

View File

@@ -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, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;"); }
function escapeAttr(s) { return s.replace(/&/g, "&amp;").replace(/"/g, "&quot;").replace(/</g, "&lt;").replace(/>/g, "&gt;"); }
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 '<div style="background:#fef2f2;border:1px solid #fca5a5;color:#991b1b;' +
'padding:4px 8px;margin:2px;border-radius:4px;font-size:12px;font-family:monospace">' +
'Unknown component: ' + escapeText(name) + '</div>';
}
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("") + "</" + tag + ">";
}
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);

View File

@@ -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, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;"); }
function escapeAttr(s) { return s.replace(/&/g, "&amp;").replace(/"/g, "&quot;").replace(/</g, "&lt;").replace(/>/g, "&gt;"); }
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 '<div style="background:#fef2f2;border:1px solid #fca5a5;color:#991b1b;' +
'padding:4px 8px;margin:2px;border-radius:4px;font-size:12px;font-family:monospace">' +
'Unknown component: ' + escapeText(name) + '</div>';
}
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("") + "</" + tag + ">";
}
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>
// =========================================================================
// --- 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 <style data-sx-css>...</style> blocks and inject into <style id="sx-css">
var cssTarget = document.getElementById("sx-css");
if (cssTarget) {
@@ -2619,9 +2248,7 @@
return text;
}
// ---------------------------------------------------------------------------
// sx-comp-hash cookie helpers (component caching)
// ---------------------------------------------------------------------------
// --- sx-comp-hash cookie helpers ---
function _setSxCompCookie(hash) {
document.cookie = "sx-comp-hash=" + hash + ";path=/;max-age=31536000;SameSite=Lax";

View File

@@ -83,12 +83,12 @@ def _as_sx(val: Any) -> SxExpr | None:
return SxExpr(f'(~rich-text :html "{escaped}")')
def root_header_sx(ctx: dict, *, oob: bool = False) -> str:
"""Build the root header row as a sx call string."""
async def root_header_sx(ctx: dict, *, oob: bool = False) -> str:
"""Build the root header row as sx wire format."""
rights = ctx.get("rights") or {}
is_admin = rights.get("admin") if isinstance(rights, dict) else getattr(rights, "admin", False)
settings_url = call_url(ctx, "blog_url", "/settings/") if is_admin else ""
return sx_call("header-row-sx",
return await render_to_sx("header-row-sx",
cart_mini=_as_sx(ctx.get("cart_mini")),
blog_url=call_url(ctx, "blog_url", ""),
site_title=ctx.get("base_title", ""),
@@ -108,13 +108,13 @@ def mobile_menu_sx(*sections: str) -> str:
return "(<> " + " ".join(parts) + ")" if parts else ""
def mobile_root_nav_sx(ctx: dict) -> str:
async def mobile_root_nav_sx(ctx: dict) -> str:
"""Root-level mobile nav via ~mobile-root-nav component."""
nav_tree = ctx.get("nav_tree") or ""
auth_menu = ctx.get("auth_menu") or ""
if not nav_tree and not auth_menu:
return ""
return sx_call("mobile-root-nav",
return await render_to_sx("mobile-root-nav",
nav_tree=_as_sx(nav_tree),
auth_menu=_as_sx(auth_menu),
)
@@ -124,7 +124,7 @@ def mobile_root_nav_sx(ctx: dict) -> str:
# Shared nav-item builders — used by BOTH desktop headers and mobile menus
# ---------------------------------------------------------------------------
def _post_nav_items_sx(ctx: dict) -> str:
async def _post_nav_items_sx(ctx: dict) -> str:
"""Build post-level nav items (container_nav + admin cog). Shared by
``post_header_sx`` (desktop) and ``post_mobile_nav_sx`` (mobile)."""
post = ctx.get("post") or {}
@@ -135,7 +135,7 @@ def _post_nav_items_sx(ctx: dict) -> str:
page_cart_count = ctx.get("page_cart_count", 0)
if page_cart_count and page_cart_count > 0:
cart_href = call_url(ctx, "cart_url", f"/{slug}/")
parts.append(sx_call("page-cart-badge", href=cart_href,
parts.append(await render_to_sx("page-cart-badge", href=cart_href,
count=str(page_cart_count)))
container_nav = str(ctx.get("container_nav") or "").strip()
@@ -171,7 +171,7 @@ def _post_nav_items_sx(ctx: dict) -> str:
return "(<> " + " ".join(parts) + ")" if parts else ""
def _post_admin_nav_items_sx(ctx: dict, slug: str,
async def _post_admin_nav_items_sx(ctx: dict, slug: str,
selected: str = "") -> str:
"""Build post-admin nav items (calendars, markets, etc.). Shared by
``post_admin_header_sx`` (desktop) and mobile menu."""
@@ -193,7 +193,7 @@ def _post_admin_nav_items_sx(ctx: dict, slug: str,
continue
href = url_fn(path)
is_sel = label == selected
parts.append(sx_call("nav-link", href=href, label=label,
parts.append(await render_to_sx("nav-link", href=href, label=label,
select_colours=select_colours,
is_selected=is_sel or None))
return "(<> " + " ".join(parts) + ")" if parts else ""
@@ -203,15 +203,15 @@ def _post_admin_nav_items_sx(ctx: dict, slug: str,
# Mobile menu section builders — wrap shared nav items for hamburger panel
# ---------------------------------------------------------------------------
def post_mobile_nav_sx(ctx: dict) -> str:
async def post_mobile_nav_sx(ctx: dict) -> str:
"""Post-level mobile menu section."""
nav = _post_nav_items_sx(ctx)
nav = await _post_nav_items_sx(ctx)
if not nav:
return ""
post = ctx.get("post") or {}
slug = post.get("slug", "")
title = (post.get("title") or slug)[:40]
return sx_call("mobile-menu-section",
return await render_to_sx("mobile-menu-section",
label=title,
href=call_url(ctx, "blog_url", f"/{slug}/"),
level=1,
@@ -219,22 +219,22 @@ def post_mobile_nav_sx(ctx: dict) -> str:
)
def post_admin_mobile_nav_sx(ctx: dict, slug: str,
async def post_admin_mobile_nav_sx(ctx: dict, slug: str,
selected: str = "") -> str:
"""Post-admin mobile menu section."""
nav = _post_admin_nav_items_sx(ctx, slug, selected)
nav = await _post_admin_nav_items_sx(ctx, slug, selected)
if not nav:
return ""
admin_href = call_url(ctx, "blog_url", f"/{slug}/admin/")
return sx_call("mobile-menu-section",
return await render_to_sx("mobile-menu-section",
label="admin", href=admin_href, level=2,
items=SxExpr(nav),
)
def search_mobile_sx(ctx: dict) -> str:
"""Build mobile search input as sx call string."""
return sx_call("search-mobile",
async def search_mobile_sx(ctx: dict) -> str:
"""Build mobile search input as sx wire format."""
return await render_to_sx("search-mobile",
current_local_href=ctx.get("current_local_href", "/"),
search=ctx.get("search", ""),
search_count=ctx.get("search_count", ""),
@@ -243,9 +243,9 @@ def search_mobile_sx(ctx: dict) -> str:
)
def search_desktop_sx(ctx: dict) -> str:
"""Build desktop search input as sx call string."""
return sx_call("search-desktop",
async def search_desktop_sx(ctx: dict) -> str:
"""Build desktop search input as sx wire format."""
return await render_to_sx("search-desktop",
current_local_href=ctx.get("current_local_href", "/"),
search=ctx.get("search", ""),
search_count=ctx.get("search_count", ""),
@@ -254,8 +254,8 @@ def search_desktop_sx(ctx: dict) -> str:
)
def post_header_sx(ctx: dict, *, oob: bool = False, child: str = "") -> str:
"""Build the post-level header row as sx call string."""
async def post_header_sx(ctx: dict, *, oob: bool = False, child: str = "") -> str:
"""Build the post-level header row as sx wire format."""
post = ctx.get("post") or {}
slug = post.get("slug", "")
if not slug:
@@ -263,11 +263,11 @@ def post_header_sx(ctx: dict, *, oob: bool = False, child: str = "") -> str:
title = (post.get("title") or "")[:160]
feature_image = post.get("feature_image")
label_sx = sx_call("post-label", feature_image=feature_image, title=title)
nav_sx = _post_nav_items_sx(ctx) or None
label_sx = await render_to_sx("post-label", feature_image=feature_image, title=title)
nav_sx = await _post_nav_items_sx(ctx) or None
link_href = call_url(ctx, "blog_url", f"/{slug}/")
return sx_call("menu-row-sx",
return await render_to_sx("menu-row-sx",
id="post-row", level=1,
link_href=link_href,
link_label_content=SxExpr(label_sx),
@@ -278,22 +278,22 @@ def post_header_sx(ctx: dict, *, oob: bool = False, child: str = "") -> str:
)
def post_admin_header_sx(ctx: dict, slug: str, *, oob: bool = False,
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 call string."""
"""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) + ")"
nav_sx = _post_admin_nav_items_sx(ctx, slug, selected) or None
nav_sx = await _post_admin_nav_items_sx(ctx, slug, selected) or None
if not admin_href:
blog_fn = ctx.get("blog_url")
admin_href = blog_fn(f"/{slug}/admin/") if callable(blog_fn) else f"/{slug}/admin/"
return sx_call("menu-row-sx",
return await render_to_sx("menu-row-sx",
id="post-admin-row", level=2,
link_href=admin_href,
link_label_content=SxExpr(label_sx),
@@ -302,29 +302,29 @@ def post_admin_header_sx(ctx: dict, slug: str, *, oob: bool = False,
)
def oob_header_sx(parent_id: str, child_id: str, row_sx: str) -> str:
async def oob_header_sx(parent_id: str, child_id: str, row_sx: str) -> str:
"""Wrap a header row sx in an OOB swap.
child_id is accepted for call-site compatibility but no longer used —
the child placeholder is created by ~menu-row-sx itself.
"""
return sx_call("oob-header-sx",
return await render_to_sx("oob-header-sx",
parent_id=parent_id,
row=SxExpr(row_sx),
)
def header_child_sx(inner_sx: str, *, id: str = "root-header-child") -> str:
async def header_child_sx(inner_sx: str, *, id: str = "root-header-child") -> str:
"""Wrap inner sx in a header-child div."""
return sx_call("header-child-sx",
return await render_to_sx("header-child-sx",
id=id, inner=SxExpr(f"(<> {inner_sx})"),
)
def oob_page_sx(*, oobs: str = "", filter: str = "", aside: str = "",
async def oob_page_sx(*, oobs: str = "", filter: str = "", aside: str = "",
content: str = "", menu: str = "") -> str:
"""Build OOB response as sx call string."""
return sx_call("oob-sx",
"""Build OOB response as sx wire format."""
return await render_to_sx("oob-sx",
oobs=SxExpr(f"(<> {oobs})") if oobs else None,
filter=SxExpr(filter) if filter else None,
aside=SxExpr(aside) if aside else None,
@@ -333,7 +333,7 @@ def oob_page_sx(*, oobs: str = "", filter: str = "", aside: str = "",
)
def full_page_sx(ctx: dict, *, header_rows: str,
async def full_page_sx(ctx: dict, *, header_rows: str,
filter: str = "", aside: str = "",
content: str = "", menu: str = "",
meta_html: str = "", meta: str = "") -> str:
@@ -344,8 +344,8 @@ def full_page_sx(ctx: dict, *, header_rows: str,
"""
# Auto-generate mobile nav from context when no menu provided
if not menu:
menu = mobile_root_nav_sx(ctx)
body_sx = sx_call("app-body",
menu = await mobile_root_nav_sx(ctx)
body_sx = await render_to_sx("app-body",
header_rows=SxExpr(f"(<> {header_rows})") if header_rows else None,
filter=SxExpr(filter) if filter else None,
aside=SxExpr(aside) if aside else None,
@@ -359,6 +359,64 @@ def full_page_sx(ctx: dict, *, header_rows: str,
return sx_page(ctx, body_sx, meta_html=meta_html)
def _build_component_ast(__name: str, **kwargs: Any) -> list:
"""Build an AST list for a component call from Python kwargs.
Returns e.g. [Symbol("~card"), Keyword("title"), "hello", Keyword("count"), 3]
No SX string generation — values stay as native Python objects.
"""
from .types import Symbol, Keyword, NIL
comp_sym = Symbol(__name if __name.startswith("~") else f"~{__name}")
ast: list = [comp_sym]
for key, val in kwargs.items():
kebab = key.replace("_", "-")
ast.append(Keyword(kebab))
if val is None:
ast.append(NIL)
elif isinstance(val, SxExpr):
# SxExpr values need to be parsed into AST
from .parser import parse
ast.append(parse(val.source))
else:
ast.append(val)
return ast
async def render_to_sx(__name: str, **kwargs: Any) -> str:
"""Call a defcomp and get SX wire format back. No SX string literals.
Builds an AST from Python values and evaluates it through the SX
evaluator, which resolves IO primitives and serializes component/tag
calls as SX wire format.
await render_to_sx("card", title="hello", count=3)
# equivalent to old: sx_call("card", title="hello", count=3)
# but values flow as native objects, not serialized strings
"""
from .jinja_bridge import get_component_env, _get_request_context
from .async_eval import async_eval_to_sx
ast = _build_component_ast(__name, **kwargs)
env = dict(get_component_env())
ctx = _get_request_context()
return await async_eval_to_sx(ast, env, ctx)
async def render_to_html(__name: str, **kwargs: Any) -> str:
"""Call a defcomp and get HTML back. No SX string literals.
Same as render_to_sx() but produces HTML output instead of SX wire
format. Used by route renders that need HTML (full pages, fragments).
"""
from .jinja_bridge import get_component_env, _get_request_context
from .async_eval import async_render
ast = _build_component_ast(__name, **kwargs)
env = dict(get_component_env())
ctx = _get_request_context()
return await async_render(ast, env, ctx)
def sx_call(component_name: str, **kwargs: Any) -> str:
"""Build an s-expression component call string from Python kwargs.
@@ -428,27 +486,19 @@ def components_for_request() -> str:
return "\n".join(parts)
def sx_response(source_or_component: str, status: int = 200,
headers: dict | None = None, **kwargs: Any):
def sx_response(source: str, status: int = 200,
headers: dict | None = None):
"""Return an s-expression wire-format response.
Can be called with a raw sx string::
Takes a raw sx string::
return sx_response('(~test-row :nodeid "foo")')
Or with a component name + kwargs (builds the sx call)::
return sx_response("test-row", nodeid="foo", outcome="passed")
For SX requests, missing component definitions are prepended as a
``<script type="text/sx" data-components>`` block so the client
can process them before rendering OOB content.
"""
from quart import request, Response
if kwargs:
source = sx_call(source_or_component, **kwargs)
else:
source = source_or_component
body = source
# Validate the sx source parses as a single expression

View File

@@ -87,61 +87,61 @@ def get_layout(name: str) -> Layout | None:
# Built-in layouts
# ---------------------------------------------------------------------------
def _root_full(ctx: dict, **kw: Any) -> str:
return root_header_sx(ctx)
async def _root_full(ctx: dict, **kw: Any) -> str:
return await root_header_sx(ctx)
def _root_oob(ctx: dict, **kw: Any) -> str:
root_hdr = root_header_sx(ctx)
return oob_header_sx("root-header-child", "root-header-child", root_hdr)
async def _root_oob(ctx: dict, **kw: Any) -> str:
root_hdr = await root_header_sx(ctx)
return await oob_header_sx("root-header-child", "root-header-child", root_hdr)
def _post_full(ctx: dict, **kw: Any) -> str:
root_hdr = root_header_sx(ctx)
post_hdr = post_header_sx(ctx)
async def _post_full(ctx: dict, **kw: Any) -> str:
root_hdr = await root_header_sx(ctx)
post_hdr = await post_header_sx(ctx)
return "(<> " + root_hdr + " " + post_hdr + ")"
def _post_oob(ctx: dict, **kw: Any) -> str:
post_hdr = post_header_sx(ctx, oob=True)
async def _post_oob(ctx: dict, **kw: Any) -> str:
post_hdr = await post_header_sx(ctx, oob=True)
# Also replace #post-header-child (empty — clears any nested admin rows)
child_oob = oob_header_sx("post-header-child", "", "")
child_oob = await oob_header_sx("post-header-child", "", "")
return "(<> " + post_hdr + " " + child_oob + ")"
def _post_admin_full(ctx: dict, **kw: Any) -> str:
async def _post_admin_full(ctx: dict, **kw: Any) -> str:
slug = ctx.get("post", {}).get("slug", "")
selected = kw.get("selected", "")
root_hdr = root_header_sx(ctx)
admin_hdr = post_admin_header_sx(ctx, slug, selected=selected)
post_hdr = post_header_sx(ctx, child=admin_hdr)
root_hdr = await root_header_sx(ctx)
admin_hdr = await post_admin_header_sx(ctx, slug, selected=selected)
post_hdr = await post_header_sx(ctx, child=admin_hdr)
return "(<> " + root_hdr + " " + post_hdr + ")"
def _post_admin_oob(ctx: dict, **kw: Any) -> str:
async def _post_admin_oob(ctx: dict, **kw: Any) -> str:
slug = ctx.get("post", {}).get("slug", "")
selected = kw.get("selected", "")
post_hdr = post_header_sx(ctx, oob=True)
admin_hdr = post_admin_header_sx(ctx, slug, selected=selected)
admin_oob = oob_header_sx("post-header-child", "post-admin-header-child", admin_hdr)
post_hdr = await post_header_sx(ctx, oob=True)
admin_hdr = await post_admin_header_sx(ctx, slug, selected=selected)
admin_oob = await oob_header_sx("post-header-child", "post-admin-header-child", admin_hdr)
return "(<> " + post_hdr + " " + admin_oob + ")"
def _root_mobile(ctx: dict, **kw: Any) -> str:
return mobile_root_nav_sx(ctx)
async def _root_mobile(ctx: dict, **kw: Any) -> str:
return await mobile_root_nav_sx(ctx)
def _post_mobile(ctx: dict, **kw: Any) -> str:
return mobile_menu_sx(post_mobile_nav_sx(ctx), mobile_root_nav_sx(ctx))
async def _post_mobile(ctx: dict, **kw: Any) -> str:
return mobile_menu_sx(await post_mobile_nav_sx(ctx), await mobile_root_nav_sx(ctx))
def _post_admin_mobile(ctx: dict, **kw: Any) -> str:
async def _post_admin_mobile(ctx: dict, **kw: Any) -> str:
slug = ctx.get("post", {}).get("slug", "")
selected = kw.get("selected", "")
return mobile_menu_sx(
post_admin_mobile_nav_sx(ctx, slug, selected),
post_mobile_nav_sx(ctx),
mobile_root_nav_sx(ctx),
await post_admin_mobile_nav_sx(ctx, slug, selected),
await post_mobile_nav_sx(ctx),
await mobile_root_nav_sx(ctx),
)

View File

@@ -268,7 +268,7 @@ async def execute_page(
is_htmx = is_htmx_request()
if is_htmx:
return sx_response(oob_page_sx(
return sx_response(await oob_page_sx(
oobs=oob_headers if oob_headers else "",
filter=filter_sx,
aside=aside_sx,
@@ -276,7 +276,7 @@ async def execute_page(
menu=menu_sx,
))
else:
return full_page_sx(
return await full_page_sx(
tctx,
header_rows=header_rows,
filter=filter_sx,

View File

@@ -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()

View File

@@ -42,6 +42,7 @@ IO_PRIMITIVES: frozenset[str] = frozenset({
"get-children",
"g",
"csrf-token",
"abort",
})
@@ -328,6 +329,22 @@ async def _io_csrf_token(
return ""
async def _io_abort(
args: list[Any], kwargs: dict[str, Any], ctx: RequestContext
) -> Any:
"""``(abort 403 "message")`` — raise HTTP error from SX.
Allows defpages to abort with HTTP error codes for auth/ownership
checks without needing a Python page helper.
"""
if not args:
raise ValueError("abort requires a status code")
from quart import abort
status = int(args[0])
message = str(args[1]) if len(args) > 1 else ""
abort(status, message)
_IO_HANDLERS: dict[str, Any] = {
"frag": _io_frag,
"query": _io_query,
@@ -341,4 +358,5 @@ _IO_HANDLERS: dict[str, Any] = {
"get-children": _io_get_children,
"g": _io_g,
"csrf-token": _io_csrf_token,
"abort": _io_abort,
}

View File

@@ -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

View File

@@ -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")

View File

@@ -15,14 +15,16 @@ from shared.sx.html import render as py_render
from shared.sx.evaluator import evaluate
SX_JS = Path(__file__).resolve().parents[2] / "static" / "scripts" / "sx.js"
SX_TEST_JS = Path(__file__).resolve().parents[2] / "static" / "scripts" / "sx-test.js"
def _js_render(sx_text: str, components_text: str = "") -> str:
"""Run sx.js in Node and return the renderToString result."""
"""Run sx.js + sx-test.js in Node and return the renderToString result."""
# Build a small Node script
script = f"""
global.document = undefined; // no DOM needed for string render
{SX_JS.read_text()}
{SX_TEST_JS.read_text()}
if ({json.dumps(components_text)}) Sx.loadComponents({json.dumps(components_text)});
var result = Sx.renderToString({json.dumps(sx_text)});
process.stdout.write(result);

View File

@@ -167,7 +167,7 @@ JS_API = [
("Sx.parseAll(text)", "Parse multiple s-expressions from text"),
("Sx.eval(expr, env)", "Evaluate an expression in the given environment"),
("Sx.render(expr, env)", "Render an expression to DOM nodes"),
("Sx.renderToString(expr, env)", "Render an expression to an HTML string"),
("Sx.renderToString(expr, env)", "Render an expression to an HTML string (requires sx-test.js)"),
("Sx.renderComponent(name, kwargs, env)", "Render a named component with keyword arguments"),
("Sx.loadComponents(text)", "Parse and register component definitions"),
("Sx.getEnv()", "Get the current component environment"),

View File

@@ -34,30 +34,30 @@ def _register_sx_layouts() -> None:
register_custom_layout("sx-section", _sx_section_full_headers, _sx_section_oob_headers, _sx_section_mobile)
def _sx_full_headers(ctx: dict, **kw: Any) -> str:
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 root_header_sx
from sxc.sx_components import _sx_header_sx, _main_nav_sx
main_nav = _main_nav_sx(kw.get("section"))
root_hdr = root_header_sx(ctx)
sx_row = _sx_header_sx(main_nav)
main_nav = await _main_nav_sx(kw.get("section"))
root_hdr = await root_header_sx(ctx)
sx_row = await _sx_header_sx(main_nav)
return "(<> " + root_hdr + " " + sx_row + ")"
def _sx_oob_headers(ctx: dict, **kw: Any) -> str:
async def _sx_oob_headers(ctx: dict, **kw: Any) -> str:
"""OOB headers for sx home page."""
from shared.sx.helpers import root_header_sx, oob_header_sx
from sxc.sx_components import _sx_header_sx, _main_nav_sx
root_hdr = root_header_sx(ctx)
main_nav = _main_nav_sx(kw.get("section"))
sx_row = _sx_header_sx(main_nav)
root_hdr = await root_header_sx(ctx)
main_nav = await _main_nav_sx(kw.get("section"))
sx_row = await _sx_header_sx(main_nav)
rows = "(<> " + root_hdr + " " + sx_row + ")"
return oob_header_sx("root-header-child", "sx-header-child", rows)
return await oob_header_sx("root-header-child", "sx-header-child", rows)
def _sx_section_full_headers(ctx: dict, **kw: Any) -> str:
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 root_header_sx
from sxc.sx_components import (
@@ -70,14 +70,14 @@ def _sx_section_full_headers(ctx: dict, **kw: Any) -> str:
sub_nav = kw.get("sub_nav", "")
selected = kw.get("selected", "")
root_hdr = root_header_sx(ctx)
main_nav = _main_nav_sx(section)
sub_row = _sub_row_sx(sub_label, sub_href, sub_nav, selected)
sx_row = _sx_header_sx(main_nav, child=sub_row)
root_hdr = await root_header_sx(ctx)
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 "(<> " + root_hdr + " " + sx_row + ")"
def _sx_section_oob_headers(ctx: dict, **kw: Any) -> str:
async def _sx_section_oob_headers(ctx: dict, **kw: Any) -> str:
"""OOB headers for sx section pages."""
from shared.sx.helpers import root_header_sx, oob_header_sx
from sxc.sx_components import (
@@ -90,34 +90,34 @@ def _sx_section_oob_headers(ctx: dict, **kw: Any) -> str:
sub_nav = kw.get("sub_nav", "")
selected = kw.get("selected", "")
root_hdr = root_header_sx(ctx)
main_nav = _main_nav_sx(section)
sub_row = _sub_row_sx(sub_label, sub_href, sub_nav, selected)
sx_row = _sx_header_sx(main_nav, child=sub_row)
root_hdr = await root_header_sx(ctx)
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 = "(<> " + root_hdr + " " + sx_row + ")"
return oob_header_sx("root-header-child", "sx-header-child", rows)
return await oob_header_sx("root-header-child", "sx-header-child", rows)
def _sx_mobile(ctx: dict, **kw: Any) -> str:
async def _sx_mobile(ctx: dict, **kw: Any) -> str:
"""Mobile menu for sx home page: main nav + root."""
from shared.sx.helpers import (
mobile_menu_sx, mobile_root_nav_sx, sx_call, SxExpr,
mobile_menu_sx, mobile_root_nav_sx, render_to_sx, SxExpr,
)
from sxc.sx_components import _main_nav_sx
main_nav = _main_nav_sx(kw.get("section"))
main_nav = await _main_nav_sx(kw.get("section"))
return mobile_menu_sx(
sx_call("mobile-menu-section",
await render_to_sx("mobile-menu-section",
label="sx", href="/", level=1, colour="violet",
items=SxExpr(main_nav)),
mobile_root_nav_sx(ctx),
await mobile_root_nav_sx(ctx),
)
def _sx_section_mobile(ctx: dict, **kw: Any) -> str:
async def _sx_section_mobile(ctx: dict, **kw: Any) -> str:
"""Mobile menu for sx section pages: sub nav + main nav + root."""
from shared.sx.helpers import (
mobile_menu_sx, mobile_root_nav_sx, sx_call, SxExpr,
mobile_menu_sx, mobile_root_nav_sx, render_to_sx, SxExpr,
)
from sxc.sx_components import _main_nav_sx
@@ -125,17 +125,17 @@ def _sx_section_mobile(ctx: dict, **kw: Any) -> str:
sub_label = kw.get("sub_label", section)
sub_href = kw.get("sub_href", "/")
sub_nav = kw.get("sub_nav", "")
main_nav = _main_nav_sx(section)
main_nav = await _main_nav_sx(section)
parts = []
if sub_nav:
parts.append(sx_call("mobile-menu-section",
parts.append(await render_to_sx("mobile-menu-section",
label=sub_label, href=sub_href, level=2, colour="violet",
items=SxExpr(sub_nav)))
parts.append(sx_call("mobile-menu-section",
parts.append(await render_to_sx("mobile-menu-section",
label="sx", href="/", level=1, colour="violet",
items=SxExpr(main_nav)))
parts.append(mobile_root_nav_sx(ctx))
parts.append(await mobile_root_nav_sx(ctx))
return mobile_menu_sx(*parts)

View File

@@ -5,7 +5,7 @@ import os
from shared.sx.jinja_bridge import load_sx_dir, watch_sx_dir
from shared.sx.helpers import (
sx_call, SxExpr, get_asset_url,
render_to_sx, SxExpr, get_asset_url,
)
from content.highlight import highlight
@@ -108,11 +108,11 @@ def _full_wire_text(sx_src: str, *comp_names: str) -> str:
# Navigation helpers
# ---------------------------------------------------------------------------
def _nav_items_sx(items: list[tuple[str, str]], current: str | None = None) -> str:
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(sx_call("nav-link",
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",
@@ -120,9 +120,9 @@ def _nav_items_sx(items: list[tuple[str, str]], current: str | None = None) -> s
return "(<> " + " ".join(parts) + ")"
def _sx_header_sx(nav: str | None = None, *, child: str | None = None) -> str:
async def _sx_header_sx(nav: str | None = None, *, child: str | None = None) -> str:
"""Build the sx docs menu-row."""
return sx_call("menu-row-sx",
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" "(</>) sx")'),
@@ -132,40 +132,40 @@ def _sx_header_sx(nav: str | None = None, *, child: str | None = None) -> str:
)
def _docs_nav_sx(current: str | None = None) -> str:
async def _docs_nav_sx(current: str | None = None) -> str:
from content.pages import DOCS_NAV
return _nav_items_sx(DOCS_NAV, current)
return await _nav_items_sx(DOCS_NAV, current)
def _reference_nav_sx(current: str | None = None) -> str:
async def _reference_nav_sx(current: str | None = None) -> str:
from content.pages import REFERENCE_NAV
return _nav_items_sx(REFERENCE_NAV, current)
return await _nav_items_sx(REFERENCE_NAV, current)
def _protocols_nav_sx(current: str | None = None) -> str:
async def _protocols_nav_sx(current: str | None = None) -> str:
from content.pages import PROTOCOLS_NAV
return _nav_items_sx(PROTOCOLS_NAV, current)
return await _nav_items_sx(PROTOCOLS_NAV, current)
def _examples_nav_sx(current: str | None = None) -> str:
async def _examples_nav_sx(current: str | None = None) -> str:
from content.pages import EXAMPLES_NAV
return _nav_items_sx(EXAMPLES_NAV, current)
return await _nav_items_sx(EXAMPLES_NAV, current)
def _essays_nav_sx(current: str | None = None) -> str:
async def _essays_nav_sx(current: str | None = None) -> str:
from content.pages import ESSAYS_NAV
return _nav_items_sx(ESSAYS_NAV, current)
return await _nav_items_sx(ESSAYS_NAV, current)
def _main_nav_sx(current_section: str | None = None) -> str:
async def _main_nav_sx(current_section: str | None = None) -> str:
from content.pages import MAIN_NAV
return _nav_items_sx(MAIN_NAV, current_section)
return await _nav_items_sx(MAIN_NAV, current_section)
def _sub_row_sx(sub_label: str, sub_href: str, sub_nav: str,
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 sx_call("menu-row-sx",
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,
@@ -178,22 +178,22 @@ def _sub_row_sx(sub_label: str, sub_href: str, sub_nav: str,
# Content builders — return sx source strings
# ---------------------------------------------------------------------------
def _doc_nav_sx(items: list[tuple[str, str]], current: str) -> str:
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 sx_call("doc-nav", items=SxExpr(f"(list {items_sx})"), current=current)
return await render_to_sx("doc-nav", items=SxExpr(f"(list {items_sx})"), current=current)
def _attr_table_sx(title: str, attrs: list[tuple[str, str, bool]]) -> str:
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(sx_call("doc-attr-row", attr=attr, description=desc,
rows.append(await render_to_sx("doc-attr-row", attr=attr, description=desc,
exists="true" if exists else None,
href=href))
return (
@@ -209,13 +209,13 @@ def _attr_table_sx(title: str, attrs: list[tuple[str, str, bool]]) -> str:
)
def _primitives_section_sx() -> str:
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(sx_call("doc-primitives-table",
parts.append(await render_to_sx("doc-primitives-table",
category=category,
primitives=SxExpr(f"(list {prims_sx})")))
return " ".join(parts)
@@ -245,8 +245,9 @@ def _headers_table_sx(title: str, headers: list[tuple[str, str, str]]) -> str:
def _docs_content_sx(slug: str) -> str:
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,
@@ -257,7 +258,8 @@ def _docs_content_sx(slug: str) -> str:
"server-rendering": _docs_server_rendering_sx,
}
builder = builders.get(slug, _docs_introduction_sx)
return builder()
result = builder()
return await result if inspect.isawaitable(result) else result
def _docs_introduction_sx() -> str:
@@ -379,8 +381,8 @@ def _docs_evaluator_sx() -> str:
)
def _docs_primitives_sx() -> str:
prims = _primitives_section_sx()
async def _docs_primitives_sx() -> str:
prims = await _primitives_section_sx()
return (
f'(~doc-page :title "Primitives"'
f' (~doc-section :title "Built-in functions" :id "builtins"'
@@ -471,14 +473,16 @@ def _docs_server_rendering_sx() -> str:
# Reference pages
# ---------------------------------------------------------------------------
def _reference_content_sx(slug: str) -> str:
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,
}
return builders.get(slug or "", _reference_attrs_sx)()
result = builders.get(slug or "", _reference_attrs_sx)()
return await result if inspect.isawaitable(result) else result
def _reference_index_sx() -> str:
@@ -573,18 +577,22 @@ def _reference_attr_detail_sx(slug: str) -> str:
)
def _reference_attrs_sx() -> str:
async def _reference_attrs_sx() -> str:
from content.pages import REQUEST_ATTRS, BEHAVIOR_ATTRS, SX_UNIQUE_ATTRS, HTMX_MISSING_ATTRS
req = await _attr_table_sx("Request Attributes", REQUEST_ATTRS)
beh = await _attr_table_sx("Behavior Attributes", BEHAVIOR_ATTRS)
uniq = await _attr_table_sx("Unique to sx", SX_UNIQUE_ATTRS)
missing = await _attr_table_sx("htmx features not yet in sx", HTMX_MISSING_ATTRS)
return (
f'(~doc-page :title "Attribute Reference"'
f' (p :class "text-stone-600 mb-6"'
f' "sx attributes mirror htmx where possible. This table shows what exists, '
f'what\'s unique to sx, and what\'s not yet implemented.")'
f' (div :class "space-y-8"'
f' {_attr_table_sx("Request Attributes", REQUEST_ATTRS)}'
f' {_attr_table_sx("Behavior Attributes", BEHAVIOR_ATTRS)}'
f' {_attr_table_sx("Unique to sx", SX_UNIQUE_ATTRS)}'
f' {_attr_table_sx("htmx features not yet in sx", HTMX_MISSING_ATTRS)}))'
f' {req}'
f' {beh}'
f' {uniq}'
f' {missing}))'
)
@@ -2066,9 +2074,9 @@ def home_content_sx() -> str:
)
def docs_content_partial_sx(slug: str) -> str:
async def docs_content_partial_sx(slug: str) -> str:
"""Docs content as sx wire format."""
inner = _docs_content_sx(slug)
inner = await _docs_content_sx(slug)
return (
f'(section :id "main-panel"'
f' :class "flex-1 md:h-full md:min-h-0 overflow-y-auto overscroll-contain js-grid-viewport"'
@@ -2076,8 +2084,8 @@ def docs_content_partial_sx(slug: str) -> str:
)
def reference_content_partial_sx(slug: str) -> str:
inner = _reference_content_sx(slug)
async def reference_content_partial_sx(slug: str) -> str:
inner = await _reference_content_sx(slug)
return (
f'(section :id "main-panel"'
f' :class "flex-1 md:h-full md:min-h-0 overflow-y-auto overscroll-contain js-grid-viewport"'
@@ -2085,8 +2093,8 @@ def reference_content_partial_sx(slug: str) -> str:
)
def protocol_content_partial_sx(slug: str) -> str:
inner = _protocol_content_sx(slug)
async def protocol_content_partial_sx(slug: str) -> str:
inner = await _protocol_content_sx(slug)
return (
f'(section :id "main-panel"'
f' :class "flex-1 md:h-full md:min-h-0 overflow-y-auto overscroll-contain js-grid-viewport"'
@@ -2094,8 +2102,8 @@ def protocol_content_partial_sx(slug: str) -> str:
)
def examples_content_partial_sx(slug: str) -> str:
inner = _examples_content_sx(slug)
async def examples_content_partial_sx(slug: str) -> str:
inner = await _examples_content_sx(slug)
return (
f'(section :id "main-panel"'
f' :class "flex-1 md:h-full md:min-h-0 overflow-y-auto overscroll-contain js-grid-viewport"'
@@ -2103,8 +2111,8 @@ def examples_content_partial_sx(slug: str) -> str:
)
def essay_content_partial_sx(slug: str) -> str:
inner = _essay_content_sx(slug)
async def essay_content_partial_sx(slug: str) -> str:
inner = await _essay_content_sx(slug)
return (
f'(section :id "main-panel"'
f' :class "flex-1 md:h-full md:min-h-0 overflow-y-auto overscroll-contain js-grid-viewport"'

View File

@@ -64,7 +64,7 @@ def register(url_prefix: str = "/") -> Blueprint:
# S-expression wire format — sx.js renders client-side
from shared.sx.helpers import sx_response
from sx.sx_components import test_detail_sx
return sx_response(test_detail_sx(test))
return sx_response(await test_detail_sx(test))
# Full page render (direct navigation / refresh)
from shared.sx.page import get_template_context

View File

@@ -6,7 +6,7 @@ from datetime import datetime
from shared.sx.jinja_bridge import load_service_components
from shared.sx.helpers import (
sx_call, SxExpr,
render_to_sx, SxExpr,
root_header_sx, full_page_sx, header_child_sx,
)
@@ -50,9 +50,9 @@ def _filter_tests(tests: list[dict], active_filter: str | None,
# Results partial
# ---------------------------------------------------------------------------
def test_detail_sx(test: dict) -> str:
async def test_detail_sx(test: dict) -> str:
"""Return s-expression wire format for a test detail view."""
inner = sx_call(
inner = await render_to_sx(
"test-detail",
nodeid=test["nodeid"],
outcome=test["outcome"],
@@ -70,10 +70,10 @@ def test_detail_sx(test: dict) -> str:
# Sx-native versions — return sx source (not HTML)
# ---------------------------------------------------------------------------
def _test_header_sx(ctx: dict, active_service: str | None = None) -> str:
async def _test_header_sx(ctx: dict, active_service: str | None = None) -> str:
"""Build the Tests menu-row as sx call."""
nav = _service_nav_sx(ctx, active_service)
return sx_call("menu-row-sx",
nav = await _service_nav_sx(ctx, active_service)
return await render_to_sx("menu-row-sx",
id="test-row", level=1, colour="sky",
link_href="/", link_label="Tests", icon="fa fa-flask",
nav=SxExpr(nav),
@@ -81,17 +81,17 @@ def _test_header_sx(ctx: dict, active_service: str | None = None) -> str:
)
def _service_nav_sx(ctx: dict, active_service: str | None = None) -> str:
async def _service_nav_sx(ctx: dict, active_service: str | None = None) -> str:
"""Service filter nav as sx."""
from runner import _SERVICE_ORDER
parts = []
parts.append(sx_call("nav-link",
parts.append(await render_to_sx("nav-link",
href="/", label="all",
is_selected="true" if not active_service else None,
select_colours="aria-selected:bg-sky-200 aria-selected:text-sky-900",
))
for svc in _SERVICE_ORDER:
parts.append(sx_call("nav-link",
parts.append(await render_to_sx("nav-link",
href=f"/?service={svc}", label=svc,
is_selected="true" if active_service == svc else None,
select_colours="aria-selected:bg-sky-200 aria-selected:text-sky-900",
@@ -99,19 +99,19 @@ def _service_nav_sx(ctx: dict, active_service: str | None = None) -> str:
return "(<> " + " ".join(parts) + ")"
def _header_stack_sx(ctx: dict, active_service: str | None = None) -> str:
async def _header_stack_sx(ctx: dict, active_service: str | None = None) -> str:
"""Full header stack as sx."""
hdr = root_header_sx(ctx)
inner = _test_header_sx(ctx, active_service)
child = header_child_sx(inner)
hdr = await root_header_sx(ctx)
inner = await _test_header_sx(ctx, active_service)
child = await header_child_sx(inner)
return "(<> " + hdr + " " + child + ")"
def _test_rows_sx(tests: list[dict]) -> str:
async def _test_rows_sx(tests: list[dict]) -> str:
"""Render all test result rows as sx."""
parts = []
for t in tests:
parts.append(sx_call("test-row",
parts.append(await render_to_sx("test-row",
nodeid=t["nodeid"],
outcome=t["outcome"],
duration=str(t["duration"]),
@@ -120,46 +120,46 @@ def _test_rows_sx(tests: list[dict]) -> str:
return "(<> " + " ".join(parts) + ")"
def _grouped_rows_sx(tests: list[dict]) -> str:
async def _grouped_rows_sx(tests: list[dict]) -> str:
"""Test rows grouped by service as sx."""
from runner import group_tests_by_service
sections = group_tests_by_service(tests)
parts = []
for sec in sections:
parts.append(sx_call("test-service-header",
parts.append(await render_to_sx("test-service-header",
service=sec["service"],
total=str(sec["total"]),
passed=str(sec["passed"]),
failed=str(sec["failed"]),
))
parts.append(_test_rows_sx(sec["tests"]))
parts.append(await _test_rows_sx(sec["tests"]))
return "(<> " + " ".join(parts) + ")"
def _results_partial_sx(result: dict | None, running: bool, csrf: str,
async def _results_partial_sx(result: dict | None, running: bool, csrf: str,
active_filter: str | None = None,
active_service: str | None = None) -> str:
"""Results section as sx."""
if running and not result:
summary = sx_call("test-summary",
summary = await render_to_sx("test-summary",
status="running", passed="0", failed="0", errors="0",
skipped="0", total="0", duration="...",
last_run="in progress", running=True, csrf=csrf,
active_filter=active_filter,
)
return "(<> " + summary + " " + sx_call("test-running-indicator") + ")"
return "(<> " + summary + " " + await render_to_sx("test-running-indicator") + ")"
if not result:
summary = sx_call("test-summary",
summary = await render_to_sx("test-summary",
status=None, passed="0", failed="0", errors="0",
skipped="0", total="0", duration="0",
last_run="never", running=running, csrf=csrf,
active_filter=active_filter,
)
return "(<> " + summary + " " + sx_call("test-no-results") + ")"
return "(<> " + summary + " " + await render_to_sx("test-no-results") + ")"
status = "running" if running else result["status"]
summary = sx_call("test-summary",
summary = await render_to_sx("test-summary",
status=status,
passed=str(result["passed"]),
failed=str(result["failed"]),
@@ -174,16 +174,16 @@ def _results_partial_sx(result: dict | None, running: bool, csrf: str,
)
if running:
return "(<> " + summary + " " + sx_call("test-running-indicator") + ")"
return "(<> " + summary + " " + await render_to_sx("test-running-indicator") + ")"
tests = result.get("tests", [])
tests = _filter_tests(tests, active_filter, active_service)
if not tests:
return "(<> " + summary + " " + sx_call("test-no-results") + ")"
return "(<> " + summary + " " + await render_to_sx("test-no-results") + ")"
has_failures = result["failed"] > 0 or result["errors"] > 0
rows = _grouped_rows_sx(tests)
table = sx_call("test-results-table",
rows = await _grouped_rows_sx(tests)
table = await render_to_sx("test-results-table",
rows=SxExpr(rows),
has_failures=str(has_failures).lower(),
)
@@ -203,10 +203,10 @@ async def render_dashboard_page_sx(ctx: dict, result: dict | None,
active_filter: str | None = None,
active_service: str | None = None) -> str:
"""Full page: test dashboard (sx wire format)."""
hdr = _header_stack_sx(ctx, active_service)
inner = _results_partial_sx(result, running, csrf, active_filter, active_service)
hdr = await _header_stack_sx(ctx, active_service)
inner = await _results_partial_sx(result, running, csrf, active_filter, active_service)
content = _wrap_results_div_sx(inner, running)
return full_page_sx(ctx, header_rows=hdr, content=content)
return await full_page_sx(ctx, header_rows=hdr, content=content)
async def render_results_partial_sx(result: dict | None, running: bool,
@@ -214,25 +214,27 @@ async def render_results_partial_sx(result: dict | None, running: bool,
active_filter: str | None = None,
active_service: str | None = None) -> str:
"""HTMX partial: results section (sx wire format)."""
inner = _results_partial_sx(result, running, csrf, active_filter, active_service)
inner = await _results_partial_sx(result, running, csrf, active_filter, active_service)
return _wrap_results_div_sx(inner, running)
async def render_test_detail_page_sx(ctx: dict, test: dict) -> str:
"""Full page: test detail (sx wire format)."""
root_hdr = root_header_sx(ctx)
test_row = _test_header_sx(ctx)
detail_row = sx_call("menu-row-sx",
root_hdr = await root_header_sx(ctx)
test_row = await _test_header_sx(ctx)
detail_row = await render_to_sx("menu-row-sx",
id="test-detail-row", level=2, colour="sky",
link_href=f"/test/{test['nodeid']}",
link_label=test["nodeid"].rsplit("::", 1)[-1],
)
inner = "(<> " + test_row + " " + header_child_sx(detail_row, id="test-header-child") + ")"
hdr = "(<> " + root_hdr + " " + header_child_sx(inner) + ")"
content = sx_call("test-detail",
hdr_child_detail = await header_child_sx(detail_row, id="test-header-child")
inner = "(<> " + test_row + " " + hdr_child_detail + ")"
hdr_child_inner = await header_child_sx(inner)
hdr = "(<> " + root_hdr + " " + hdr_child_inner + ")"
content = await render_to_sx("test-detail",
nodeid=test["nodeid"],
outcome=test["outcome"],
duration=str(test["duration"]),
longrepr=test.get("longrepr", ""),
)
return full_page_sx(ctx, header_rows=hdr, content=content)
return await full_page_sx(ctx, header_rows=hdr, content=content)