5 Commits

Author SHA1 Message Date
293f7713d6 Auto-mount defpages: eliminate Python route stubs across all 9 services
Some checks failed
Build and Deploy / build-and-deploy (push) Failing after 16s
Defpages are now declared with absolute paths in .sx files and auto-mounted
directly on the Quart app, removing ~850 lines of blueprint mount_pages calls,
before_request hooks, and g.* wrapper boilerplate. A new page = one defpage
declaration, nothing else.

Infrastructure:
- async_eval awaits coroutine results from callable dispatch
- auto_mount_pages() mounts all registered defpages on the app
- g._defpage_ctx pattern passes helper data to layout context

Migrated: sx, account, orders, federation, cart, market, events, blog

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-03 19:03:15 +00:00
4ba63bda17 Add server-driven architecture principle and React feature analysis
Documents why sx stays server-driven by default, maps React features
to sx equivalents, and defines targeted escape hatches for the few
interactions that genuinely need client-side state.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-03 17:48:35 +00:00
0a81a2af01 Convert social and federation profile from Jinja to SX rendering
All checks were successful
Build and Deploy / build-and-deploy (push) Successful in 14m34s
Add primitives (replace, strip-tags, slice, csrf-token), convert all
social blueprint routes and federation profile to SX content builders,
delete 12 unused Jinja templates and social_lite layout.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-03 17:43:47 +00:00
0c9dbd6657 Add attribute detail pages with live demos for SX reference
All checks were successful
Build and Deploy / build-and-deploy (push) Successful in 4m45s
Per-attribute documentation pages at /reference/attributes/<slug> with:
- Live interactive demos (demo components in reference.sx)
- S-expression source code display
- Server handler code shown as s-expressions (defhandlers in handlers/reference.sx)
- Wire response display via OOB swaps on demo interaction
- Linked attribute names in the reference table

Covers all 20 implemented attributes (sx-get/post/put/delete/patch,
sx-trigger/target/swap/swap-oob/select/confirm/push-url/sync/encoding/
headers/include/vals/media/disable/on:*, sx-retry, data-sx, data-sx-env).

Also adds sx-on:* to BEHAVIOR_ATTRS, updates REFERENCE_NAV to link
/reference/attributes, and makes /reference/ an index page.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-03 17:12:57 +00:00
a4377668be Add isomorphic SX architecture migration plan
Documents the 5-phase plan for making the sx s-expression layer a
universal view language that renders on either client or server, with
pages as cached components and data-only navigation.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-03 16:52:12 +00:00
90 changed files with 3517 additions and 1727 deletions

View File

@@ -81,10 +81,11 @@ def create_app() -> "Quart":
app.register_blueprint(register_auth_bp()) app.register_blueprint(register_auth_bp())
account_bp = register_account_bp() account_bp = register_account_bp()
from shared.sx.pages import mount_pages
mount_pages(account_bp, "account")
app.register_blueprint(account_bp) app.register_blueprint(account_bp)
from shared.sx.pages import auto_mount_pages
auto_mount_pages(app, "account")
app.register_blueprint(register_fragments()) app.register_blueprint(register_fragments())
from bp.actions.routes import register as register_actions from bp.actions.routes import register as register_actions

View File

@@ -8,15 +8,12 @@ from __future__ import annotations
from quart import ( from quart import (
Blueprint, Blueprint,
request, request,
redirect,
g, g,
) )
from sqlalchemy import select from sqlalchemy import select
from shared.models import UserNewsletter from shared.models import UserNewsletter
from shared.models.ghost_membership_entities import GhostNewsletter from shared.infrastructure.fragments import fetch_fragments
from shared.infrastructure.urls import login_url
from shared.infrastructure.fragments import fetch_fragment, fetch_fragments
from shared.sx.helpers import sx_response from shared.sx.helpers import sx_response
@@ -25,8 +22,7 @@ def register(url_prefix="/"):
@account_bp.before_request @account_bp.before_request
async def _prepare_page_data(): async def _prepare_page_data():
"""Fetch account_nav fragments and load data for defpage routes.""" """Fetch account_nav fragments for layout."""
# Fetch account nav items for layout (was in context_processor)
events_nav, cart_nav, artdag_nav = await fetch_fragments([ events_nav, cart_nav, artdag_nav = await fetch_fragments([
("events", "account-nav-item", {}), ("events", "account-nav-item", {}),
("cart", "account-nav-item", {}), ("cart", "account-nav-item", {}),
@@ -34,48 +30,6 @@ def register(url_prefix="/"):
], required=False) ], required=False)
g.account_nav = events_nav + cart_nav + artdag_nav g.account_nav = events_nav + cart_nav + artdag_nav
if request.method != "GET":
return
endpoint = request.endpoint or ""
# Newsletters page — load newsletter data
if endpoint.endswith("defpage_newsletters"):
result = await g.s.execute(
select(GhostNewsletter).order_by(GhostNewsletter.name)
)
all_newsletters = result.scalars().all()
sub_result = await g.s.execute(
select(UserNewsletter).where(
UserNewsletter.user_id == g.user.id,
)
)
user_subs = {un.newsletter_id: un for un in sub_result.scalars().all()}
newsletter_list = []
for nl in all_newsletters:
un = user_subs.get(nl.id)
newsletter_list.append({
"newsletter": nl,
"un": un,
"subscribed": un.subscribed if un else False,
})
g.newsletters_data = newsletter_list
# Fragment page — load fragment from events service
elif endpoint.endswith("defpage_fragment_page"):
slug = request.view_args.get("slug")
if slug and g.get("user"):
fragment_html = await fetch_fragment(
"events", "account-page",
params={"slug": slug, "user_id": str(g.user.id)},
)
if not fragment_html:
from quart import abort
abort(404)
g.fragment_page_data = fragment_html
@account_bp.post("/newsletter/<int:newsletter_id>/toggle/") @account_bp.post("/newsletter/<int:newsletter_id>/toggle/")
async def toggle_newsletter(newsletter_id: int): async def toggle_newsletter(newsletter_id: int):
if not g.get("user"): if not g.get("user"):

View File

@@ -75,31 +75,60 @@ def _register_account_helpers() -> None:
}) })
def _h_account_content(): def _h_account_content(**kw):
from sx.sx_components import _account_main_panel_sx from sx.sx_components import _account_main_panel_sx
return _account_main_panel_sx({}) return _account_main_panel_sx({})
def _h_newsletters_content(): async def _h_newsletters_content(**kw):
from quart import g from quart import g
d = getattr(g, "newsletters_data", None) from sqlalchemy import select
if not d: from shared.models import UserNewsletter
from shared.models.ghost_membership_entities import GhostNewsletter
result = await g.s.execute(
select(GhostNewsletter).order_by(GhostNewsletter.name)
)
all_newsletters = result.scalars().all()
sub_result = await g.s.execute(
select(UserNewsletter).where(
UserNewsletter.user_id == g.user.id,
)
)
user_subs = {un.newsletter_id: un for un in sub_result.scalars().all()}
newsletter_list = []
for nl in all_newsletters:
un = user_subs.get(nl.id)
newsletter_list.append({
"newsletter": nl,
"un": un,
"subscribed": un.subscribed if un else False,
})
if not newsletter_list:
from shared.sx.helpers import sx_call from shared.sx.helpers import sx_call
return sx_call("account-newsletter-empty") return sx_call("account-newsletter-empty")
from shared.sx.page import get_template_context_sync
from sx.sx_components import _newsletters_panel_sx from sx.sx_components import _newsletters_panel_sx
# Build a minimal ctx with account_url
ctx = {"account_url": getattr(g, "_account_url", None)} ctx = {"account_url": getattr(g, "_account_url", None)}
if ctx["account_url"] is None: if ctx["account_url"] is None:
from shared.infrastructure.urls import account_url from shared.infrastructure.urls import account_url
ctx["account_url"] = account_url ctx["account_url"] = account_url
return _newsletters_panel_sx(ctx, d) return _newsletters_panel_sx(ctx, newsletter_list)
def _h_fragment_content(): async def _h_fragment_content(slug=None, **kw):
from quart import g from quart import g, abort
frag = getattr(g, "fragment_page_data", None) from shared.infrastructure.fragments import fetch_fragment
if not frag:
if not slug or not g.get("user"):
return "" return ""
fragment_html = await fetch_fragment(
"events", "account-page",
params={"slug": slug, "user_id": str(g.user.id)},
)
if not fragment_html:
abort(404)
from sx.sx_components import _fragment_content from sx.sx_components import _fragment_content
return _fragment_content(frag) return _fragment_content(fragment_html)

View File

@@ -28,4 +28,4 @@
:path "/<slug>/" :path "/<slug>/"
:auth :login :auth :login
:layout :account :layout :account
:content (fragment-content)) :content (fragment-content slug))

View File

@@ -162,6 +162,23 @@ def create_app() -> "Quart":
) )
return jsonify(resp) return jsonify(resp)
# Auto-mount all defpages with absolute paths
from shared.sx.pages import auto_mount_pages
auto_mount_pages(app, "blog")
# --- Pass defpage helper data to template context for layouts ---
@app.context_processor
async def inject_blog_data():
import os
from shared.config import config as get_config
ctx = {
"blog_title": get_config()["blog_title"],
"base_title": get_config()["title"],
"unsplash_api_key": os.environ.get("UNSPLASH_ACCESS_KEY", ""),
}
ctx.update(getattr(g, '_defpage_ctx', {}))
return ctx
# --- debug: url rules --- # --- debug: url rules ---
@app.get("/__rules") @app.get("/__rules")
async def dump_rules(): async def dump_rules():

View File

@@ -3,13 +3,9 @@ from __future__ import annotations
#from quart import Blueprint, g #from quart import Blueprint, g
from quart import ( from quart import (
render_template,
make_response,
Blueprint, Blueprint,
redirect, redirect,
url_for, url_for,
request,
jsonify
) )
from shared.browser.app.redis_cacher import clear_all_cache from shared.browser.app.redis_cacher import clear_all_cache
from shared.browser.app.authz import require_admin from shared.browser.app.authz import require_admin
@@ -27,23 +23,6 @@ def register(url_prefix):
"base_title": f"{config()['title']} settings", "base_title": f"{config()['title']} settings",
} }
@bp.before_request
async def _prepare_page_data():
ep = request.endpoint or ""
if "defpage_settings_home" in ep:
from shared.sx.page import get_template_context
from sx.sx_components import _settings_main_panel_sx
tctx = await get_template_context()
g.settings_content = _settings_main_panel_sx(tctx)
elif "defpage_cache_page" in ep:
from shared.sx.page import get_template_context
from sx.sx_components import _cache_main_panel_sx
tctx = await get_template_context()
g.cache_content = _cache_main_panel_sx(tctx)
from shared.sx.pages import mount_pages
mount_pages(bp, "blog", names=["settings-home", "cache-page"])
@bp.post("/cache_clear/") @bp.post("/cache_clear/")
@require_admin @require_admin
async def cache_clear(): async def cache_clear():
@@ -54,7 +33,7 @@ def register(url_prefix):
html = render_comp("cache-cleared", time_str=now.strftime("%H:%M:%S")) html = render_comp("cache-cleared", time_str=now.strftime("%H:%M:%S"))
return sx_response(html) return sx_response(html)
return redirect(url_for("settings.defpage_cache_page")) return redirect(url_for("defpage_cache_page"))
return bp return bp

View File

@@ -2,8 +2,6 @@ from __future__ import annotations
import re import re
from quart import ( from quart import (
render_template,
make_response,
Blueprint, Blueprint,
redirect, redirect,
url_for, url_for,
@@ -13,9 +11,7 @@ from quart import (
from sqlalchemy import select, delete from sqlalchemy import select, delete
from shared.browser.app.authz import require_admin from shared.browser.app.authz import require_admin
from shared.browser.app.utils.htmx import is_htmx_request
from shared.browser.app.redis_cacher import invalidate_tag_cache from shared.browser.app.redis_cacher import invalidate_tag_cache
from shared.sx.helpers import sx_response
from models.tag_group import TagGroup, TagGroupTag from models.tag_group import TagGroup, TagGroupTag
from models.ghost_content import Tag from models.ghost_content import Tag
@@ -46,60 +42,13 @@ async def _unassigned_tags(session):
def register(): def register():
bp = Blueprint("tag_groups_admin", __name__, url_prefix="/settings/tag-groups") bp = Blueprint("tag_groups_admin", __name__, url_prefix="/settings/tag-groups")
@bp.before_request
async def _prepare_page_data():
ep = request.endpoint or ""
if "defpage_tag_groups_page" in ep:
groups = list(
(await g.s.execute(
select(TagGroup).order_by(TagGroup.sort_order, TagGroup.name)
)).scalars()
)
unassigned = await _unassigned_tags(g.s)
from shared.sx.page import get_template_context
from sx.sx_components import _tag_groups_main_panel_sx
tctx = await get_template_context()
tctx.update({"groups": groups, "unassigned_tags": unassigned})
g.tag_groups_content = _tag_groups_main_panel_sx(tctx)
elif "defpage_tag_group_edit" in ep:
tag_id = (request.view_args or {}).get("id")
tg = await g.s.get(TagGroup, tag_id)
if not tg:
from quart import abort
abort(404)
assigned_rows = list(
(await g.s.execute(
select(TagGroupTag.tag_id).where(TagGroupTag.tag_group_id == tag_id)
)).scalars()
)
all_tags = list(
(await g.s.execute(
select(Tag).where(
Tag.deleted_at.is_(None),
(Tag.visibility == "public") | (Tag.visibility.is_(None)),
).order_by(Tag.name)
)).scalars()
)
from shared.sx.page import get_template_context
from sx.sx_components import _tag_groups_edit_main_panel_sx
tctx = await get_template_context()
tctx.update({
"group": tg,
"all_tags": all_tags,
"assigned_tag_ids": set(assigned_rows),
})
g.tag_group_edit_content = _tag_groups_edit_main_panel_sx(tctx)
from shared.sx.pages import mount_pages
mount_pages(bp, "blog", names=["tag-groups-page", "tag-group-edit"])
@bp.post("/") @bp.post("/")
@require_admin @require_admin
async def create(): async def create():
form = await request.form form = await request.form
name = (form.get("name") or "").strip() name = (form.get("name") or "").strip()
if not name: if not name:
return redirect(url_for("blog.tag_groups_admin.defpage_tag_groups_page")) return redirect(url_for("defpage_tag_groups_page"))
slug = _slugify(name) slug = _slugify(name)
feature_image = (form.get("feature_image") or "").strip() or None feature_image = (form.get("feature_image") or "").strip() or None
@@ -115,14 +64,14 @@ def register():
await g.s.flush() await g.s.flush()
await invalidate_tag_cache("blog") await invalidate_tag_cache("blog")
return redirect(url_for("blog.tag_groups_admin.defpage_tag_groups_page")) return redirect(url_for("defpage_tag_groups_page"))
@bp.post("/<int:id>/") @bp.post("/<int:id>/")
@require_admin @require_admin
async def save(id: int): async def save(id: int):
tg = await g.s.get(TagGroup, id) tg = await g.s.get(TagGroup, id)
if not tg: if not tg:
return redirect(url_for("blog.tag_groups_admin.defpage_tag_groups_page")) return redirect(url_for("defpage_tag_groups_page"))
form = await request.form form = await request.form
name = (form.get("name") or "").strip() name = (form.get("name") or "").strip()
@@ -153,7 +102,7 @@ def register():
await g.s.flush() await g.s.flush()
await invalidate_tag_cache("blog") await invalidate_tag_cache("blog")
return redirect(url_for("blog.tag_groups_admin.defpage_tag_group_edit", id=id)) return redirect(url_for("defpage_tag_group_edit", id=id))
@bp.post("/<int:id>/delete/") @bp.post("/<int:id>/delete/")
@require_admin @require_admin
@@ -163,6 +112,6 @@ def register():
await g.s.delete(tg) await g.s.delete(tg)
await g.s.flush() await g.s.flush()
await invalidate_tag_cache("blog") await invalidate_tag_cache("blog")
return redirect(url_for("blog.tag_groups_admin.defpage_tag_groups_page")) return redirect(url_for("defpage_tag_groups_page"))
return bp return bp

View File

@@ -53,16 +53,6 @@ def register(url_prefix, title):
@blogs_bp.before_request @blogs_bp.before_request
async def route(): async def route():
g.makeqs_factory = makeqs_factory g.makeqs_factory = makeqs_factory
ep = request.endpoint or ""
if "defpage_new_post" in ep:
from sx.sx_components import render_editor_panel
g.editor_content = render_editor_panel()
elif "defpage_new_page" in ep:
from sx.sx_components import render_editor_panel
g.editor_page_content = render_editor_panel(is_page=True)
from shared.sx.pages import mount_pages
mount_pages(blogs_bp, "blog", names=["new-post", "new-page"])
@blogs_bp.context_processor @blogs_bp.context_processor
async def inject_root(): async def inject_root():
@@ -277,7 +267,7 @@ def register(url_prefix, title):
await invalidate_tag_cache("blog") await invalidate_tag_cache("blog")
# Redirect to the edit page # Redirect to the edit page
return redirect(host_url(url_for("blog.post.admin.defpage_post_edit", slug=post.slug))) return redirect(host_url(url_for("defpage_post_edit", slug=post.slug)))
@blogs_bp.post("/new-page/") @blogs_bp.post("/new-page/")
@@ -335,7 +325,7 @@ def register(url_prefix, title):
await invalidate_tag_cache("blog") await invalidate_tag_cache("blog")
# Redirect to the page admin # Redirect to the page admin
return redirect(host_url(url_for("blog.post.admin.defpage_post_edit", slug=page.slug))) return redirect(host_url(url_for("defpage_post_edit", slug=page.slug)))
@blogs_bp.get("/drafts/") @blogs_bp.get("/drafts/")

View File

@@ -12,7 +12,6 @@ from .services.menu_items import (
search_pages, search_pages,
MenuItemError, MenuItemError,
) )
from shared.browser.app.utils.htmx import is_htmx_request
from shared.sx.helpers import sx_response from shared.sx.helpers import sx_response
def register(): def register():
@@ -23,20 +22,6 @@ def register():
from sx.sx_components import render_menu_items_nav_oob from sx.sx_components import render_menu_items_nav_oob
return render_menu_items_nav_oob(menu_items) return render_menu_items_nav_oob(menu_items)
@bp.before_request
async def _prepare_page_data():
if "defpage_" not in (request.endpoint or ""):
return
menu_items = await get_all_menu_items(g.s)
from shared.sx.page import get_template_context
from sx.sx_components import _menu_items_main_panel_sx
tctx = await get_template_context()
tctx["menu_items"] = menu_items
g.menu_items_content = _menu_items_main_panel_sx(tctx)
from shared.sx.pages import mount_pages
mount_pages(bp, "blog", names=["menu-items-page"])
@bp.get("/new/") @bp.get("/new/")
@require_admin @require_admin
async def new_menu_item(): async def new_menu_item():

View File

@@ -10,7 +10,6 @@ from quart import (
url_for, url_for,
) )
from shared.browser.app.authz import require_admin, require_post_author from shared.browser.app.authz import require_admin, require_post_author
from shared.browser.app.utils.htmx import is_htmx_request
from shared.sx.helpers import sx_response from shared.sx.helpers import sx_response
from shared.utils import host_url from shared.utils import host_url
@@ -55,155 +54,6 @@ def _post_to_edit_dict(post) -> dict:
def register(): def register():
bp = Blueprint("admin", __name__, url_prefix='/admin') bp = Blueprint("admin", __name__, url_prefix='/admin')
@bp.before_request
async def _prepare_page_data():
ep = request.endpoint or ""
if "defpage_post_admin" in ep:
from sqlalchemy import select
from shared.models.page_config import PageConfig
post = (g.post_data or {}).get("post", {})
features = {}
sumup_configured = False
sumup_merchant_code = ""
sumup_checkout_prefix = ""
if post.get("is_page"):
pc = (await g.s.execute(
select(PageConfig).where(
PageConfig.container_type == "page",
PageConfig.container_id == post["id"],
)
)).scalar_one_or_none()
if pc:
features = pc.features or {}
sumup_configured = bool(pc.sumup_api_key)
sumup_merchant_code = pc.sumup_merchant_code or ""
sumup_checkout_prefix = pc.sumup_checkout_prefix or ""
from shared.sx.page import get_template_context
from sx.sx_components import _post_admin_main_panel_sx
tctx = await get_template_context()
tctx.update({
"features": features,
"sumup_configured": sumup_configured,
"sumup_merchant_code": sumup_merchant_code,
"sumup_checkout_prefix": sumup_checkout_prefix,
})
g.post_admin_content = _post_admin_main_panel_sx(tctx)
elif "defpage_post_data" in ep:
from shared.sx.page import get_template_context
from sx.sx_components import _post_data_content_sx
tctx = await get_template_context()
g.post_data_content = _post_data_content_sx(tctx)
elif "defpage_post_preview" in ep:
from models.ghost_content import Post
from sqlalchemy import select as sa_select
post_id = g.post_data["post"]["id"]
post = (await g.s.execute(
sa_select(Post).where(Post.id == post_id)
)).scalar_one_or_none()
preview_ctx = {}
sx_content = getattr(post, "sx_content", None) or ""
if sx_content:
from shared.sx.prettify import sx_to_pretty_sx
preview_ctx["sx_pretty"] = sx_to_pretty_sx(sx_content)
lexical_raw = getattr(post, "lexical", None) or ""
if lexical_raw:
from shared.sx.prettify import json_to_pretty_sx
preview_ctx["json_pretty"] = json_to_pretty_sx(lexical_raw)
if sx_content:
from shared.sx.parser import parse as sx_parse
from shared.sx.html import render as sx_html_render
from shared.sx.jinja_bridge import _COMPONENT_ENV
try:
parsed = sx_parse(sx_content)
preview_ctx["sx_rendered"] = sx_html_render(parsed, dict(_COMPONENT_ENV))
except Exception:
preview_ctx["sx_rendered"] = "<em>Error rendering sx</em>"
if lexical_raw:
from bp.blog.ghost.lexical_renderer import render_lexical
try:
preview_ctx["lex_rendered"] = render_lexical(lexical_raw)
except Exception:
preview_ctx["lex_rendered"] = "<em>Error rendering lexical</em>"
from shared.sx.page import get_template_context
from sx.sx_components import _preview_main_panel_sx
tctx = await get_template_context()
tctx.update(preview_ctx)
g.post_preview_content = _preview_main_panel_sx(tctx)
elif "defpage_post_entries" in ep:
from sqlalchemy import select
from shared.models.calendars import Calendar
from ..services.entry_associations import get_post_entry_ids
post_id = g.post_data["post"]["id"]
associated_entry_ids = await get_post_entry_ids(post_id)
result = await g.s.execute(
select(Calendar)
.where(Calendar.deleted_at.is_(None))
.order_by(Calendar.name.asc())
)
all_calendars = result.scalars().all()
for calendar in all_calendars:
await g.s.refresh(calendar, ["entries", "post"])
from shared.sx.page import get_template_context
from sx.sx_components import _post_entries_content_sx
tctx = await get_template_context()
tctx["all_calendars"] = all_calendars
tctx["associated_entry_ids"] = associated_entry_ids
g.post_entries_content = _post_entries_content_sx(tctx)
elif "defpage_post_settings" in ep:
from models.ghost_content import Post
from sqlalchemy import select as sa_select
from sqlalchemy.orm import selectinload
post_id = g.post_data["post"]["id"]
post = (await g.s.execute(
sa_select(Post)
.where(Post.id == post_id)
.options(selectinload(Post.tags))
)).scalar_one_or_none()
ghost_post = _post_to_edit_dict(post) if post else {}
save_success = request.args.get("saved") == "1"
from shared.sx.page import get_template_context
from sx.sx_components import _post_settings_content_sx
tctx = await get_template_context()
tctx["ghost_post"] = ghost_post
tctx["save_success"] = save_success
g.post_settings_content = _post_settings_content_sx(tctx)
elif "defpage_post_edit" in ep:
from models.ghost_content import Post
from sqlalchemy import select as sa_select
from sqlalchemy.orm import selectinload
from shared.infrastructure.data_client import fetch_data
post_id = g.post_data["post"]["id"]
post = (await g.s.execute(
sa_select(Post)
.where(Post.id == post_id)
.options(selectinload(Post.tags))
)).scalar_one_or_none()
ghost_post = _post_to_edit_dict(post) if post else {}
save_success = request.args.get("saved") == "1"
save_error = request.args.get("error", "")
raw_newsletters = await fetch_data("account", "newsletters", required=False) or []
from types import SimpleNamespace
newsletters = [SimpleNamespace(**nl) for nl in raw_newsletters]
from shared.sx.page import get_template_context
from sx.sx_components import _post_edit_content_sx
tctx = await get_template_context()
tctx["ghost_post"] = ghost_post
tctx["save_success"] = save_success
tctx["save_error"] = save_error
tctx["newsletters"] = newsletters
g.post_edit_content = _post_edit_content_sx(tctx)
from shared.sx.pages import mount_pages
mount_pages(bp, "blog", names=[
"post-admin", "post-data", "post-preview",
"post-entries", "post-settings", "post-edit",
])
@bp.put("/features/") @bp.put("/features/")
@require_admin @require_admin
async def update_features(slug: str): async def update_features(slug: str):
@@ -468,7 +318,7 @@ def register():
except OptimisticLockError: except OptimisticLockError:
from urllib.parse import quote from urllib.parse import quote
return redirect( return redirect(
host_url(url_for("blog.post.admin.defpage_post_settings", slug=slug)) host_url(url_for("defpage_post_settings", slug=slug))
+ "?error=" + quote("Someone else edited this post. Please reload and try again.") + "?error=" + quote("Someone else edited this post. Please reload and try again.")
) )
@@ -479,7 +329,7 @@ def register():
await invalidate_tag_cache("post.post_detail") await invalidate_tag_cache("post.post_detail")
# Redirect using the (possibly new) slug # Redirect using the (possibly new) slug
return redirect(host_url(url_for("blog.post.admin.defpage_post_settings", slug=post.slug)) + "?saved=1") return redirect(host_url(url_for("defpage_post_settings", slug=post.slug)) + "?saved=1")
@bp.post("/edit/") @bp.post("/edit/")
@require_post_author @require_post_author
@@ -504,11 +354,11 @@ def register():
try: try:
lexical_doc = json.loads(lexical_raw) lexical_doc = json.loads(lexical_raw)
except (json.JSONDecodeError, TypeError): except (json.JSONDecodeError, TypeError):
return redirect(host_url(url_for("blog.post.admin.defpage_post_edit", slug=slug)) + "?error=" + quote("Invalid JSON in editor content.")) return redirect(host_url(url_for("defpage_post_edit", slug=slug)) + "?error=" + quote("Invalid JSON in editor content."))
ok, reason = validate_lexical(lexical_doc) ok, reason = validate_lexical(lexical_doc)
if not ok: if not ok:
return redirect(host_url(url_for("blog.post.admin.defpage_post_edit", slug=slug)) + "?error=" + quote(reason)) return redirect(host_url(url_for("defpage_post_edit", slug=slug)) + "?error=" + quote(reason))
# Publish workflow # Publish workflow
is_admin = bool((g.get("rights") or {}).get("admin")) is_admin = bool((g.get("rights") or {}).get("admin"))
@@ -544,7 +394,7 @@ def register():
) )
except OptimisticLockError: except OptimisticLockError:
return redirect( return redirect(
host_url(url_for("blog.post.admin.defpage_post_edit", slug=slug)) host_url(url_for("defpage_post_edit", slug=slug))
+ "?error=" + quote("Someone else edited this post. Please reload and try again.") + "?error=" + quote("Someone else edited this post. Please reload and try again.")
) )
@@ -560,7 +410,7 @@ def register():
await invalidate_tag_cache("post.post_detail") await invalidate_tag_cache("post.post_detail")
# Redirect to GET (PRG pattern) — use post.slug in case it changed # Redirect to GET (PRG pattern) — use post.slug in case it changed
redirect_url = host_url(url_for("blog.post.admin.defpage_post_edit", slug=post.slug)) + "?saved=1" redirect_url = host_url(url_for("defpage_post_edit", slug=post.slug)) + "?saved=1"
if publish_requested_msg: if publish_requested_msg:
redirect_url += "&publish_requested=1" redirect_url += "&publish_requested=1"
return redirect(redirect_url) return redirect(redirect_url)

View File

@@ -1,11 +1,9 @@
from __future__ import annotations from __future__ import annotations
from quart import Blueprint, make_response, request, g, abort from quart import Blueprint, request, g, abort
from sqlalchemy import select, or_ from sqlalchemy import select, or_
from sqlalchemy.orm import selectinload
from shared.browser.app.authz import require_login from shared.browser.app.authz import require_login
from shared.browser.app.utils.htmx import is_htmx_request
from shared.sx.helpers import sx_response from shared.sx.helpers import sx_response
from models import Snippet from models import Snippet
@@ -32,22 +30,6 @@ async def _visible_snippets(session):
def register(): def register():
bp = Blueprint("snippets", __name__, url_prefix="/settings/snippets") bp = Blueprint("snippets", __name__, url_prefix="/settings/snippets")
@bp.before_request
async def _prepare_page_data():
if "defpage_" not in (request.endpoint or ""):
return
snippets = await _visible_snippets(g.s)
is_admin = g.rights.get("admin")
from shared.sx.page import get_template_context
from sx.sx_components import _snippets_main_panel_sx
tctx = await get_template_context()
tctx["snippets"] = snippets
tctx["is_admin"] = is_admin
g.snippets_content = _snippets_main_panel_sx(tctx)
from shared.sx.pages import mount_pages
mount_pages(bp, "blog", names=["snippets-page"])
@bp.delete("/<int:snippet_id>/") @bp.delete("/<int:snippet_id>/")
@require_login @require_login
async def delete_snippet(snippet_id: int): async def delete_snippet(snippet_id: int):

View File

@@ -17,6 +17,96 @@ def _load_blog_page_files() -> None:
load_page_dir(os.path.dirname(__file__), "blog") load_page_dir(os.path.dirname(__file__), "blog")
# ---------------------------------------------------------------------------
# Shared hydration helpers
# ---------------------------------------------------------------------------
def _add_to_defpage_ctx(**kwargs: Any) -> None:
from quart import g
if not hasattr(g, '_defpage_ctx'):
g._defpage_ctx = {}
g._defpage_ctx.update(kwargs)
async def _ensure_post_data(slug: str | None) -> None:
"""Load post data and set g.post_data + defpage context.
Replicates post bp's hydrate_post_data + context_processor.
"""
from quart import g, abort
if hasattr(g, 'post_data') and g.post_data:
await _inject_post_context(g.post_data)
return
if not slug:
abort(404)
from bp.post.services.post_data import post_data
is_admin = bool((g.get("rights") or {}).get("admin"))
p_data = await post_data(slug, g.s, include_drafts=True)
if not p_data:
abort(404)
# Draft access control
if p_data["post"].get("status") != "published":
if is_admin:
pass
elif g.user and p_data["post"].get("user_id") == g.user.id:
pass
else:
abort(404)
g.post_data = p_data
g.post_slug = slug
await _inject_post_context(p_data)
async def _inject_post_context(p_data: dict) -> None:
"""Add post context_processor data to defpage context."""
from shared.config import config
from shared.infrastructure.fragments import fetch_fragment
from shared.infrastructure.data_client import fetch_data
from shared.contracts.dtos import CartSummaryDTO, dto_from_dict
from shared.infrastructure.cart_identity import current_cart_identity
db_post_id = p_data["post"]["id"]
post_slug = p_data["post"]["slug"]
container_nav = await fetch_fragment("relations", "container-nav", params={
"container_type": "page",
"container_id": str(db_post_id),
"post_slug": post_slug,
})
ctx: dict = {
**p_data,
"base_title": config()["title"],
"container_nav": container_nav,
}
if p_data["post"].get("is_page"):
ident = current_cart_identity()
summary_params: dict = {"page_slug": post_slug}
if ident["user_id"] is not None:
summary_params["user_id"] = ident["user_id"]
if ident["session_id"] is not None:
summary_params["session_id"] = ident["session_id"]
raw_summary = await fetch_data(
"cart", "cart-summary", params=summary_params, required=False,
)
page_summary = dto_from_dict(CartSummaryDTO, raw_summary) if raw_summary else CartSummaryDTO()
ctx["page_cart_count"] = (
page_summary.count + page_summary.calendar_count + page_summary.ticket_count
)
ctx["page_cart_total"] = float(
page_summary.total + page_summary.calendar_total + page_summary.ticket_total
)
_add_to_defpage_ctx(**ctx)
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
# Layouts # Layouts
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
@@ -110,48 +200,48 @@ def _sub_settings_oob(ctx: dict, row_id: str, child_id: str,
def _cache_full(ctx: dict, **kw: Any) -> str: def _cache_full(ctx: dict, **kw: Any) -> str:
return _sub_settings_full(ctx, "cache-row", "cache-header-child", return _sub_settings_full(ctx, "cache-row", "cache-header-child",
"settings.defpage_cache_page", "refresh", "Cache") "defpage_cache_page", "refresh", "Cache")
def _cache_oob(ctx: dict, **kw: Any) -> str: def _cache_oob(ctx: dict, **kw: Any) -> str:
return _sub_settings_oob(ctx, "cache-row", "cache-header-child", return _sub_settings_oob(ctx, "cache-row", "cache-header-child",
"settings.defpage_cache_page", "refresh", "Cache") "defpage_cache_page", "refresh", "Cache")
# --- Snippets --- # --- Snippets ---
def _snippets_full(ctx: dict, **kw: Any) -> str: def _snippets_full(ctx: dict, **kw: Any) -> str:
return _sub_settings_full(ctx, "snippets-row", "snippets-header-child", return _sub_settings_full(ctx, "snippets-row", "snippets-header-child",
"snippets.defpage_snippets_page", "puzzle-piece", "Snippets") "defpage_snippets_page", "puzzle-piece", "Snippets")
def _snippets_oob(ctx: dict, **kw: Any) -> str: def _snippets_oob(ctx: dict, **kw: Any) -> str:
return _sub_settings_oob(ctx, "snippets-row", "snippets-header-child", return _sub_settings_oob(ctx, "snippets-row", "snippets-header-child",
"snippets.defpage_snippets_page", "puzzle-piece", "Snippets") "defpage_snippets_page", "puzzle-piece", "Snippets")
# --- Menu Items --- # --- Menu Items ---
def _menu_items_full(ctx: dict, **kw: Any) -> str: def _menu_items_full(ctx: dict, **kw: Any) -> str:
return _sub_settings_full(ctx, "menu_items-row", "menu_items-header-child", return _sub_settings_full(ctx, "menu_items-row", "menu_items-header-child",
"menu_items.defpage_menu_items_page", "bars", "Menu Items") "defpage_menu_items_page", "bars", "Menu Items")
def _menu_items_oob(ctx: dict, **kw: Any) -> str: def _menu_items_oob(ctx: dict, **kw: Any) -> str:
return _sub_settings_oob(ctx, "menu_items-row", "menu_items-header-child", return _sub_settings_oob(ctx, "menu_items-row", "menu_items-header-child",
"menu_items.defpage_menu_items_page", "bars", "Menu Items") "defpage_menu_items_page", "bars", "Menu Items")
# --- Tag Groups --- # --- Tag Groups ---
def _tag_groups_full(ctx: dict, **kw: Any) -> str: def _tag_groups_full(ctx: dict, **kw: Any) -> str:
return _sub_settings_full(ctx, "tag-groups-row", "tag-groups-header-child", return _sub_settings_full(ctx, "tag-groups-row", "tag-groups-header-child",
"blog.tag_groups_admin.defpage_tag_groups_page", "tags", "Tag Groups") "defpage_tag_groups_page", "tags", "Tag Groups")
def _tag_groups_oob(ctx: dict, **kw: Any) -> str: def _tag_groups_oob(ctx: dict, **kw: Any) -> str:
return _sub_settings_oob(ctx, "tag-groups-row", "tag-groups-header-child", return _sub_settings_oob(ctx, "tag-groups-row", "tag-groups-header-child",
"blog.tag_groups_admin.defpage_tag_groups_page", "tags", "Tag Groups") "defpage_tag_groups_page", "tags", "Tag Groups")
# --- Tag Group Edit --- # --- Tag Group Edit ---
@@ -165,7 +255,7 @@ def _tag_group_edit_full(ctx: dict, **kw: Any) -> str:
root_hdr = root_header_sx(ctx) root_hdr = root_header_sx(ctx)
settings_hdr = _settings_header_sx(ctx) settings_hdr = _settings_header_sx(ctx)
sub_hdr = _sub_settings_header_sx("tag-groups-row", "tag-groups-header-child", sub_hdr = _sub_settings_header_sx("tag-groups-row", "tag-groups-header-child",
qurl("blog.tag_groups_admin.defpage_tag_group_edit", id=g_id), qurl("defpage_tag_group_edit", id=g_id),
"tags", "Tag Groups", ctx) "tags", "Tag Groups", ctx)
return "(<> " + root_hdr + " " + settings_hdr + " " + sub_hdr + ")" return "(<> " + root_hdr + " " + settings_hdr + " " + sub_hdr + ")"
@@ -178,14 +268,14 @@ def _tag_group_edit_oob(ctx: dict, **kw: Any) -> str:
from sx.sx_components import _settings_header_sx, _sub_settings_header_sx from sx.sx_components import _settings_header_sx, _sub_settings_header_sx
settings_hdr_oob = _settings_header_sx(ctx, oob=True) settings_hdr_oob = _settings_header_sx(ctx, oob=True)
sub_hdr = _sub_settings_header_sx("tag-groups-row", "tag-groups-header-child", sub_hdr = _sub_settings_header_sx("tag-groups-row", "tag-groups-header-child",
qurl("blog.tag_groups_admin.defpage_tag_group_edit", id=g_id), qurl("defpage_tag_group_edit", id=g_id),
"tags", "Tag Groups", ctx) "tags", "Tag Groups", ctx)
sub_oob = oob_header_sx("root-settings-header-child", "tag-groups-header-child", sub_hdr) sub_oob = oob_header_sx("root-settings-header-child", "tag-groups-header-child", sub_hdr)
return "(<> " + settings_hdr_oob + " " + sub_oob + ")" return "(<> " + settings_hdr_oob + " " + sub_oob + ")"
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
# Page helpers (sync functions available in .sx defpage expressions) # Page helpers (async functions available in .sx defpage expressions)
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
def _register_blog_helpers() -> None: def _register_blog_helpers() -> None:
@@ -208,71 +298,277 @@ def _register_blog_helpers() -> None:
}) })
def _h_editor_content(): # --- Editor helpers ---
async def _h_editor_content(**kw):
from sx.sx_components import render_editor_panel
return render_editor_panel()
async def _h_editor_page_content(**kw):
from sx.sx_components import render_editor_panel
return render_editor_panel(is_page=True)
# --- Post admin helpers ---
async def _h_post_admin_content(slug=None, **kw):
await _ensure_post_data(slug)
from quart import g from quart import g
return getattr(g, "editor_content", "") from sqlalchemy import select
from shared.models.page_config import PageConfig
post = (g.post_data or {}).get("post", {})
features = {}
sumup_configured = False
sumup_merchant_code = ""
sumup_checkout_prefix = ""
if post.get("is_page"):
pc = (await g.s.execute(
select(PageConfig).where(
PageConfig.container_type == "page",
PageConfig.container_id == post["id"],
)
)).scalar_one_or_none()
if pc:
features = pc.features or {}
sumup_configured = bool(pc.sumup_api_key)
sumup_merchant_code = pc.sumup_merchant_code or ""
sumup_checkout_prefix = pc.sumup_checkout_prefix or ""
from shared.sx.page import get_template_context
from sx.sx_components import _post_admin_main_panel_sx
tctx = await get_template_context()
tctx.update({
"features": features,
"sumup_configured": sumup_configured,
"sumup_merchant_code": sumup_merchant_code,
"sumup_checkout_prefix": sumup_checkout_prefix,
})
return _post_admin_main_panel_sx(tctx)
def _h_editor_page_content(): async def _h_post_data_content(slug=None, **kw):
await _ensure_post_data(slug)
from shared.sx.page import get_template_context
from sx.sx_components import _post_data_content_sx
tctx = await get_template_context()
return _post_data_content_sx(tctx)
async def _h_post_preview_content(slug=None, **kw):
await _ensure_post_data(slug)
from quart import g from quart import g
return getattr(g, "editor_page_content", "") from models.ghost_content import Post
from sqlalchemy import select as sa_select
post_id = g.post_data["post"]["id"]
post = (await g.s.execute(
sa_select(Post).where(Post.id == post_id)
)).scalar_one_or_none()
preview_ctx: dict = {}
sx_content = getattr(post, "sx_content", None) or ""
if sx_content:
from shared.sx.prettify import sx_to_pretty_sx
preview_ctx["sx_pretty"] = sx_to_pretty_sx(sx_content)
lexical_raw = getattr(post, "lexical", None) or ""
if lexical_raw:
from shared.sx.prettify import json_to_pretty_sx
preview_ctx["json_pretty"] = json_to_pretty_sx(lexical_raw)
if sx_content:
from shared.sx.parser import parse as sx_parse
from shared.sx.html import render as sx_html_render
from shared.sx.jinja_bridge import _COMPONENT_ENV
try:
parsed = sx_parse(sx_content)
preview_ctx["sx_rendered"] = sx_html_render(parsed, dict(_COMPONENT_ENV))
except Exception:
preview_ctx["sx_rendered"] = "<em>Error rendering sx</em>"
if lexical_raw:
from bp.blog.ghost.lexical_renderer import render_lexical
try:
preview_ctx["lex_rendered"] = render_lexical(lexical_raw)
except Exception:
preview_ctx["lex_rendered"] = "<em>Error rendering lexical</em>"
from shared.sx.page import get_template_context
from sx.sx_components import _preview_main_panel_sx
tctx = await get_template_context()
tctx.update(preview_ctx)
return _preview_main_panel_sx(tctx)
def _h_post_admin_content(): async def _h_post_entries_content(slug=None, **kw):
await _ensure_post_data(slug)
from quart import g from quart import g
return getattr(g, "post_admin_content", "") from sqlalchemy import select
from shared.models.calendars import Calendar
from bp.post.services.entry_associations import get_post_entry_ids
post_id = g.post_data["post"]["id"]
associated_entry_ids = await get_post_entry_ids(post_id)
result = await g.s.execute(
select(Calendar)
.where(Calendar.deleted_at.is_(None))
.order_by(Calendar.name.asc())
)
all_calendars = result.scalars().all()
for calendar in all_calendars:
await g.s.refresh(calendar, ["entries", "post"])
from shared.sx.page import get_template_context
from sx.sx_components import _post_entries_content_sx
tctx = await get_template_context()
tctx["all_calendars"] = all_calendars
tctx["associated_entry_ids"] = associated_entry_ids
return _post_entries_content_sx(tctx)
def _h_post_data_content(): async def _h_post_settings_content(slug=None, **kw):
await _ensure_post_data(slug)
from quart import g, request
from models.ghost_content import Post
from sqlalchemy import select as sa_select
from sqlalchemy.orm import selectinload
from bp.post.admin.routes import _post_to_edit_dict
post_id = g.post_data["post"]["id"]
post = (await g.s.execute(
sa_select(Post)
.where(Post.id == post_id)
.options(selectinload(Post.tags))
)).scalar_one_or_none()
ghost_post = _post_to_edit_dict(post) if post else {}
save_success = request.args.get("saved") == "1"
from shared.sx.page import get_template_context
from sx.sx_components import _post_settings_content_sx
tctx = await get_template_context()
tctx["ghost_post"] = ghost_post
tctx["save_success"] = save_success
return _post_settings_content_sx(tctx)
async def _h_post_edit_content(slug=None, **kw):
await _ensure_post_data(slug)
from quart import g, request
from models.ghost_content import Post
from sqlalchemy import select as sa_select
from sqlalchemy.orm import selectinload
from shared.infrastructure.data_client import fetch_data
from bp.post.admin.routes import _post_to_edit_dict
post_id = g.post_data["post"]["id"]
post = (await g.s.execute(
sa_select(Post)
.where(Post.id == post_id)
.options(selectinload(Post.tags))
)).scalar_one_or_none()
ghost_post = _post_to_edit_dict(post) if post else {}
save_success = request.args.get("saved") == "1"
save_error = request.args.get("error", "")
raw_newsletters = await fetch_data("account", "newsletters", required=False) or []
from types import SimpleNamespace
newsletters = [SimpleNamespace(**nl) for nl in raw_newsletters]
from shared.sx.page import get_template_context
from sx.sx_components import _post_edit_content_sx
tctx = await get_template_context()
tctx["ghost_post"] = ghost_post
tctx["save_success"] = save_success
tctx["save_error"] = save_error
tctx["newsletters"] = newsletters
return _post_edit_content_sx(tctx)
# --- Settings helpers ---
async def _h_settings_content(**kw):
from shared.sx.page import get_template_context
from sx.sx_components import _settings_main_panel_sx
tctx = await get_template_context()
return _settings_main_panel_sx(tctx)
async def _h_cache_content(**kw):
from shared.sx.page import get_template_context
from sx.sx_components import _cache_main_panel_sx
tctx = await get_template_context()
return _cache_main_panel_sx(tctx)
# --- Snippets helper ---
async def _h_snippets_content(**kw):
from quart import g from quart import g
return getattr(g, "post_data_content", "") from sqlalchemy import select, or_
from models import Snippet
uid = g.user.id
is_admin = g.rights.get("admin")
filters = [Snippet.user_id == uid, Snippet.visibility == "shared"]
if is_admin:
filters.append(Snippet.visibility == "admin")
rows = (await g.s.execute(
select(Snippet).where(or_(*filters)).order_by(Snippet.name)
)).scalars().all()
from shared.sx.page import get_template_context
from sx.sx_components import _snippets_main_panel_sx
tctx = await get_template_context()
tctx["snippets"] = rows
tctx["is_admin"] = is_admin
return _snippets_main_panel_sx(tctx)
def _h_post_preview_content(): # --- Menu Items helper ---
async def _h_menu_items_content(**kw):
from quart import g from quart import g
return getattr(g, "post_preview_content", "") from bp.menu_items.services.menu_items import get_all_menu_items
menu_items = await get_all_menu_items(g.s)
from shared.sx.page import get_template_context
from sx.sx_components import _menu_items_main_panel_sx
tctx = await get_template_context()
tctx["menu_items"] = menu_items
return _menu_items_main_panel_sx(tctx)
def _h_post_entries_content(): # --- Tag Groups helpers ---
async def _h_tag_groups_content(**kw):
from quart import g from quart import g
return getattr(g, "post_entries_content", "") from sqlalchemy import select
from models.tag_group import TagGroup
from bp.blog.admin.routes import _unassigned_tags
groups = list(
(await g.s.execute(
select(TagGroup).order_by(TagGroup.sort_order, TagGroup.name)
)).scalars()
)
unassigned = await _unassigned_tags(g.s)
from shared.sx.page import get_template_context
from sx.sx_components import _tag_groups_main_panel_sx
tctx = await get_template_context()
tctx.update({"groups": groups, "unassigned_tags": unassigned})
return _tag_groups_main_panel_sx(tctx)
def _h_post_settings_content(): async def _h_tag_group_edit_content(id=None, **kw):
from quart import g from quart import g, abort
return getattr(g, "post_settings_content", "") from sqlalchemy import select
from models.tag_group import TagGroup, TagGroupTag
from models.ghost_content import Tag
def _h_post_edit_content(): tg = await g.s.get(TagGroup, id)
from quart import g if not tg:
return getattr(g, "post_edit_content", "") abort(404)
assigned_rows = list(
(await g.s.execute(
def _h_settings_content(): select(TagGroupTag.tag_id).where(TagGroupTag.tag_group_id == id)
from quart import g )).scalars()
return getattr(g, "settings_content", "") )
all_tags = list(
(await g.s.execute(
def _h_cache_content(): select(Tag).where(
from quart import g Tag.deleted_at.is_(None),
return getattr(g, "cache_content", "") (Tag.visibility == "public") | (Tag.visibility.is_(None)),
).order_by(Tag.name)
)).scalars()
def _h_snippets_content(): )
from quart import g from shared.sx.page import get_template_context
return getattr(g, "snippets_content", "") from sx.sx_components import _tag_groups_edit_main_panel_sx
tctx = await get_template_context()
tctx.update({
def _h_menu_items_content(): "group": tg,
from quart import g "all_tags": all_tags,
return getattr(g, "menu_items_content", "") "assigned_tag_ids": set(assigned_rows),
})
return _tag_groups_edit_main_panel_sx(tctx)
def _h_tag_groups_content():
from quart import g
return getattr(g, "tag_groups_content", "")
def _h_tag_group_edit_content():
from quart import g
return getattr(g, "tag_group_edit_content", "")

View File

@@ -15,54 +15,54 @@
:layout :blog :layout :blog
:content (editor-page-content)) :content (editor-page-content))
; --- Post admin pages (nested under /<slug>/admin/) --- ; --- Post admin pages (absolute paths under /<slug>/admin/) ---
(defpage post-admin (defpage post-admin
:path "/" :path "/<slug>/admin/"
:auth :admin :auth :admin
:layout (:post-admin :selected "admin") :layout (:post-admin :selected "admin")
:content (post-admin-content)) :content (post-admin-content slug))
(defpage post-data (defpage post-data
:path "/data/" :path "/<slug>/admin/data/"
:auth :admin :auth :admin
:layout (:post-admin :selected "data") :layout (:post-admin :selected "data")
:content (post-data-content)) :content (post-data-content slug))
(defpage post-preview (defpage post-preview
:path "/preview/" :path "/<slug>/admin/preview/"
:auth :admin :auth :admin
:layout (:post-admin :selected "preview") :layout (:post-admin :selected "preview")
:content (post-preview-content)) :content (post-preview-content slug))
(defpage post-entries (defpage post-entries
:path "/entries/" :path "/<slug>/admin/entries/"
:auth :admin :auth :admin
:layout (:post-admin :selected "entries") :layout (:post-admin :selected "entries")
:content (post-entries-content)) :content (post-entries-content slug))
(defpage post-settings (defpage post-settings
:path "/settings/" :path "/<slug>/admin/settings/"
:auth :post_author :auth :post_author
:layout (:post-admin :selected "settings") :layout (:post-admin :selected "settings")
:content (post-settings-content)) :content (post-settings-content slug))
(defpage post-edit (defpage post-edit
:path "/edit/" :path "/<slug>/admin/edit/"
:auth :post_author :auth :post_author
:layout (:post-admin :selected "edit") :layout (:post-admin :selected "edit")
:content (post-edit-content)) :content (post-edit-content slug))
; --- Settings pages --- ; --- Settings pages (absolute paths) ---
(defpage settings-home (defpage settings-home
:path "/" :path "/settings/"
:auth :admin :auth :admin
:layout :blog-settings :layout :blog-settings
:content (settings-content)) :content (settings-content))
(defpage cache-page (defpage cache-page
:path "/cache/" :path "/settings/cache/"
:auth :admin :auth :admin
:layout :blog-cache :layout :blog-cache
:content (cache-content)) :content (cache-content))
@@ -70,7 +70,7 @@
; --- Snippets --- ; --- Snippets ---
(defpage snippets-page (defpage snippets-page
:path "/" :path "/settings/snippets/"
:auth :login :auth :login
:layout :blog-snippets :layout :blog-snippets
:content (snippets-content)) :content (snippets-content))
@@ -78,7 +78,7 @@
; --- Menu Items --- ; --- Menu Items ---
(defpage menu-items-page (defpage menu-items-page
:path "/" :path "/settings/menu_items/"
:auth :admin :auth :admin
:layout :blog-menu-items :layout :blog-menu-items
:content (menu-items-content)) :content (menu-items-content))
@@ -86,13 +86,13 @@
; --- Tag Groups --- ; --- Tag Groups ---
(defpage tag-groups-page (defpage tag-groups-page
:path "/" :path "/settings/tag-groups/"
:auth :admin :auth :admin
:layout :blog-tag-groups :layout :blog-tag-groups
:content (tag-groups-content)) :content (tag-groups-content))
(defpage tag-group-edit (defpage tag-group-edit
:path "/<int:id>/" :path "/settings/tag-groups/<int:id>/"
:auth :admin :auth :admin
:layout :blog-tag-group-edit :layout :blog-tag-group-edit
:content (tag-group-edit-content)) :content (tag-group-edit-content id))

View File

@@ -185,8 +185,6 @@ def create_app() -> "Quart":
from sxc.pages import setup_cart_pages from sxc.pages import setup_cart_pages
setup_cart_pages() setup_cart_pages()
from shared.sx.pages import mount_pages
# --- Blueprint registration --- # --- Blueprint registration ---
# Static prefixes first, dynamic (page_slug) last # Static prefixes first, dynamic (page_slug) last
@@ -196,21 +194,22 @@ def create_app() -> "Quart":
url_prefix="/", url_prefix="/",
) )
# Cart overview at GET / # Cart overview blueprint (no defpage routes, just action endpoints)
overview_bp = register_cart_overview(url_prefix="/") overview_bp = register_cart_overview(url_prefix="/")
mount_pages(overview_bp, "cart", names=["cart-overview"])
app.register_blueprint(overview_bp, url_prefix="/") app.register_blueprint(overview_bp, url_prefix="/")
# Page admin at /<page_slug>/admin/ (before page_cart catch-all) # Page admin (PUT /payments/ etc.)
admin_bp = register_page_admin() admin_bp = register_page_admin()
mount_pages(admin_bp, "cart", names=["cart-admin", "cart-payments"])
app.register_blueprint(admin_bp, url_prefix="/<page_slug>/admin") app.register_blueprint(admin_bp, url_prefix="/<page_slug>/admin")
# Page cart at /<page_slug>/ (dynamic, matched last) # Page cart (POST /checkout/ etc.)
page_cart_bp = register_page_cart(url_prefix="/") page_cart_bp = register_page_cart(url_prefix="/")
mount_pages(page_cart_bp, "cart", names=["page-cart-view"])
app.register_blueprint(page_cart_bp, url_prefix="/<page_slug>") app.register_blueprint(page_cart_bp, url_prefix="/<page_slug>")
# Auto-mount all defpages with absolute paths
from shared.sx.pages import auto_mount_pages
auto_mount_pages(app, "cart")
return app return app

View File

@@ -56,9 +56,9 @@ def register(url_prefix: str) -> Blueprint:
if request.headers.get("SX-Request") == "true" or request.headers.get("HX-Request") == "true": if request.headers.get("SX-Request") == "true" or request.headers.get("HX-Request") == "true":
# Redirect to overview for HTMX # Redirect to overview for HTMX
return redirect(url_for("cart_overview.defpage_cart_overview")) return redirect(url_for("defpage_cart_overview"))
return redirect(url_for("cart_overview.defpage_cart_overview")) return redirect(url_for("defpage_cart_overview"))
@bp.post("/quantity/<int:product_id>/") @bp.post("/quantity/<int:product_id>/")
async def update_quantity(product_id: int): async def update_quantity(product_id: int):
@@ -137,7 +137,7 @@ def register(url_prefix: str) -> Blueprint:
tickets = await get_ticket_cart_entries(g.s) tickets = await get_ticket_cart_entries(g.s)
if not cart and not calendar_entries and not tickets: if not cart and not calendar_entries and not tickets:
return redirect(url_for("cart_overview.defpage_cart_overview")) return redirect(url_for("defpage_cart_overview"))
product_total = total(cart) or 0 product_total = total(cart) or 0
calendar_amount = calendar_total(calendar_entries) or 0 calendar_amount = calendar_total(calendar_entries) or 0
@@ -145,7 +145,7 @@ def register(url_prefix: str) -> Blueprint:
cart_total = product_total + calendar_amount + ticket_amount cart_total = product_total + calendar_amount + ticket_amount
if cart_total <= 0: if cart_total <= 0:
return redirect(url_for("cart_overview.defpage_cart_overview")) return redirect(url_for("defpage_cart_overview"))
try: try:
page_config = await resolve_page_config(g.s, cart, calendar_entries, tickets) page_config = await resolve_page_config(g.s, cart, calendar_entries, tickets)

View File

@@ -3,24 +3,9 @@
from __future__ import annotations from __future__ import annotations
from quart import Blueprint, g, request from quart import Blueprint
from .services import get_cart_grouped_by_page
def register(url_prefix: str) -> Blueprint: def register(url_prefix: str) -> Blueprint:
bp = Blueprint("cart_overview", __name__, url_prefix=url_prefix) bp = Blueprint("cart_overview", __name__, url_prefix=url_prefix)
@bp.before_request
async def _prepare_page_data():
"""Load overview data for defpage route."""
endpoint = request.endpoint or ""
if not endpoint.endswith("defpage_cart_overview"):
return
from shared.sx.page import get_template_context
from sx.sx_components import _overview_main_panel_sx
page_groups = await get_cart_grouped_by_page(g.s)
ctx = await get_template_context()
g.overview_content = _overview_main_panel_sx(page_groups, ctx)
return bp return bp

View File

@@ -19,26 +19,6 @@ from .services import current_cart_identity
def register(url_prefix: str) -> Blueprint: def register(url_prefix: str) -> Blueprint:
bp = Blueprint("page_cart", __name__, url_prefix=url_prefix) bp = Blueprint("page_cart", __name__, url_prefix=url_prefix)
@bp.before_request
async def _prepare_page_data():
"""Load page cart data for defpage route."""
endpoint = request.endpoint or ""
if not endpoint.endswith("defpage_page_cart_view"):
return
post = g.page_post
cart = await get_cart_for_page(g.s, post.id)
cal_entries = await get_calendar_entries_for_page(g.s, post.id)
page_tickets = await get_tickets_for_page(g.s, post.id)
ticket_groups = group_tickets(page_tickets)
from shared.sx.page import get_template_context
from sx.sx_components import _page_cart_main_panel_sx
ctx = await get_template_context()
g.page_cart_content = _page_cart_main_panel_sx(
ctx, cart, cal_entries, page_tickets, ticket_groups,
total, calendar_total, ticket_total,
)
@bp.post("/checkout/") @bp.post("/checkout/")
async def page_checkout(): async def page_checkout():
post = g.page_post post = g.page_post
@@ -48,7 +28,7 @@ def register(url_prefix: str) -> Blueprint:
page_tickets = await get_tickets_for_page(g.s, post.id) page_tickets = await get_tickets_for_page(g.s, post.id)
if not cart and not cal_entries and not page_tickets: if not cart and not cal_entries and not page_tickets:
return redirect(url_for("page_cart.defpage_page_cart_view")) return redirect(url_for("defpage_page_cart_view"))
product_total_val = total(cart) or 0 product_total_val = total(cart) or 0
calendar_amount = calendar_total(cal_entries) or 0 calendar_amount = calendar_total(cal_entries) or 0
@@ -56,7 +36,7 @@ def register(url_prefix: str) -> Blueprint:
cart_total = product_total_val + calendar_amount + ticket_amount cart_total = product_total_val + calendar_amount + ticket_amount
if cart_total <= 0: if cart_total <= 0:
return redirect(url_for("page_cart.defpage_page_cart_view")) return redirect(url_for("defpage_page_cart_view"))
ident = current_cart_identity() ident = current_cart_identity()

View File

@@ -1,6 +1,6 @@
from __future__ import annotations from __future__ import annotations
from quart import Blueprint, g, render_template, redirect, url_for, make_response from quart import Blueprint, g, redirect, url_for, make_response
from sqlalchemy import select, func, or_, cast, String, exists from sqlalchemy import select, func, or_, cast, String, exists
from sqlalchemy.orm import selectinload from sqlalchemy.orm import selectinload

View File

@@ -13,23 +13,6 @@ from shared.sx.helpers import sx_response
def register(): def register():
bp = Blueprint("page_admin", __name__) bp = Blueprint("page_admin", __name__)
@bp.before_request
async def _prepare_page_data():
"""Pre-render admin content for defpage routes."""
endpoint = request.endpoint or ""
if request.method != "GET":
return
if endpoint.endswith("defpage_cart_admin"):
from shared.sx.page import get_template_context
from sx.sx_components import _cart_admin_main_panel_sx
ctx = await get_template_context()
g.cart_admin_content = _cart_admin_main_panel_sx(ctx)
elif endpoint.endswith("defpage_cart_payments"):
from shared.sx.page import get_template_context
from sx.sx_components import _cart_payments_main_panel_sx
ctx = await get_template_context()
g.cart_payments_content = _cart_payments_main_panel_sx(ctx)
@bp.put("/payments/") @bp.put("/payments/")
@require_admin @require_admin
async def update_sumup(**kwargs): async def update_sumup(**kwargs):

View File

@@ -771,7 +771,7 @@ def _cart_page_admin_header_sx(ctx: dict, page_post: Any, *, oob: bool = False,
def _cart_admin_main_panel_sx(ctx: dict) -> str: def _cart_admin_main_panel_sx(ctx: dict) -> str:
"""Admin overview panel -- links to sub-admin pages.""" """Admin overview panel -- links to sub-admin pages."""
from quart import url_for from quart import url_for
payments_href = url_for("page_admin.defpage_cart_payments") payments_href = url_for("defpage_cart_payments")
return ( return (
'(div :id "main-panel"' '(div :id "main-panel"'
' (div :class "flex items-center justify-between p-3 border-b"' ' (div :class "flex items-center justify-between p-3 border-b"'

View File

@@ -90,32 +90,48 @@ def _register_cart_helpers() -> None:
}) })
def _h_overview_content(): async def _h_overview_content(**kw):
from quart import g from quart import g
page_groups = getattr(g, "overview_page_groups", []) from shared.sx.page import get_template_context
from sx.sx_components import _overview_main_panel_sx from sx.sx_components import _overview_main_panel_sx
# _overview_main_panel_sx needs ctx for url helpers — use g-based approach from bp.cart.services import get_cart_grouped_by_page
# The function reads cart_url from ctx, which we can get from template context page_groups = await get_cart_grouped_by_page(g.s)
from shared.sx.page import get_template_context ctx = await get_template_context()
import asyncio return _overview_main_panel_sx(page_groups, ctx)
# Page helpers are sync — we pre-compute in before_request
return getattr(g, "overview_content", "")
def _h_page_cart_content(): async def _h_page_cart_content(page_slug=None, **kw):
from quart import g from quart import g
return getattr(g, "page_cart_content", "") from shared.sx.page import get_template_context
from sx.sx_components import _page_cart_main_panel_sx
from bp.cart.services import total, calendar_total, ticket_total
from bp.cart.services.page_cart import (
get_cart_for_page, get_calendar_entries_for_page, get_tickets_for_page,
)
from bp.cart.services.ticket_groups import group_tickets
post = g.page_post
cart = await get_cart_for_page(g.s, post.id)
cal_entries = await get_calendar_entries_for_page(g.s, post.id)
page_tickets = await get_tickets_for_page(g.s, post.id)
ticket_groups = group_tickets(page_tickets)
ctx = await get_template_context()
return _page_cart_main_panel_sx(
ctx, cart, cal_entries, page_tickets, ticket_groups,
total, calendar_total, ticket_total,
)
def _h_cart_admin_content(): async def _h_cart_admin_content(page_slug=None, **kw):
from shared.sx.page import get_template_context
from sx.sx_components import _cart_admin_main_panel_sx from sx.sx_components import _cart_admin_main_panel_sx
ctx = await get_template_context()
return _cart_admin_main_panel_sx(ctx)
async def _h_cart_payments_content(page_slug=None, **kw):
from shared.sx.page import get_template_context from shared.sx.page import get_template_context
# Sync helper — _cart_admin_main_panel_sx is sync, but needs ctx from sx.sx_components import _cart_payments_main_panel_sx
# We can pre-compute in before_request, or use get_template_context_sync-like pattern ctx = await get_template_context()
from quart import g return _cart_payments_main_panel_sx(ctx)
return getattr(g, "cart_admin_content", "")
def _h_cart_payments_content():
from quart import g
return getattr(g, "cart_payments_content", "")

View File

@@ -7,19 +7,19 @@
:content (overview-content)) :content (overview-content))
(defpage page-cart-view (defpage page-cart-view
:path "/" :path "/<page_slug>/"
:auth :public :auth :public
:layout :cart-page :layout :cart-page
:content (page-cart-content)) :content (page-cart-content))
(defpage cart-admin (defpage cart-admin
:path "/" :path "/<page_slug>/admin/"
:auth :admin :auth :admin
:layout :cart-admin :layout :cart-admin
:content (cart-admin-content)) :content (cart-admin-content))
(defpage cart-payments (defpage cart-payments
:path "/payments/" :path "/<page_slug>/admin/payments/"
:auth :admin :auth :admin
:layout (:cart-admin :selected "payments") :layout (:cart-admin :selected "payments")
:content (cart-payments-content)) :content (cart-payments-content))

View File

@@ -358,3 +358,89 @@ Each service migrates independently, no coordination needed:
3. Enable SSR for bots (Phase 2) — per-page opt-in 3. Enable SSR for bots (Phase 2) — per-page opt-in
4. Client data primitives (Phase 4) — global once sx.js updated 4. Client data primitives (Phase 4) — global once sx.js updated
5. Data-only navigation (Phase 5) — automatic for any `defpage` route 5. Data-only navigation (Phase 5) — automatic for any `defpage` route
---
## Why: Architectural Rationale
The end state is: **sx.js is the only JavaScript in the browser.** All application code — components, pages, routing, event handling, data fetching — is expressed in sx, evaluated by the interpreter, with behavior mediated through bound primitives.
### Benefits
**Single language everywhere.** Components, pages, routing, event handling, data fetching — all sx. No context-switching between JS idioms and template syntax. One language for the entire frontend and the server rendering path.
**Portability.** The same source runs on any VM that implements the ~50-primitive interface. Today: Python + JS. Tomorrow: WASM, edge workers, native mobile, embedded devices. Coupled to a primitive contract, not to a specific runtime.
**Smaller wire transfer.** S-expressions are terser than equivalent JS. Combined with content-addressed caching (hash/localStorage), most navigations transfer zero code — just data.
**Inspectability.** The sx source is the running program — no build step, no source maps, no minification. View source shows exactly what executes. The AST is the structure the evaluator walks. Debugging is tracing a tree.
**Controlled surface area.** The only JS that runs is sx.js. Everything else is mediated through defined primitives. No npm supply chain. No third-party scripts with ambient DOM access. Components can only do what primitives allow — the capability surface is fully controlled.
**Hot-reloadable everything.** Components are data (cached AST). Swapping a definition is replacing a dict entry. No module system, no import graph, no HMR machinery. Already works for .sx file changes in dev mode — extends to behaviors too.
**AI-friendly.** S-expressions are trivially parseable and generatable. An LLM produces correct sx far more reliably than JS/JSX — fewer syntax edge cases, no semicolons/braces/arrow-function ambiguities. The codebase becomes more amenable to automated generation and transformation.
**Security boundary.** No `eval()`, no dynamic `<script>` injection, no prototype pollution. The sx evaluator is a sandbox — it only resolves symbols against the primitive table and component env. Auditing what any sx expression can do means auditing the primitive bindings.
### Performance and WASM
The tradeoff is interpreter overhead — a tree-walking interpreter is slower than native JS execution. For UI rendering (building DOM, handling events, fetching data), this is not the bottleneck — DOM operations dominate, and those are the same speed regardless of initiator.
If performance ever becomes a concern, WASM is the escape hatch at three levels:
1. **Evaluator in WASM.** Rewrite `sxEval` in Rust/Zig → WASM. The tight inner loop (symbol lookup, env traversal, function application) runs ~10-50x faster. DOM rendering stays in JS (it calls browser APIs regardless).
2. **Compile sx to WASM.** Ahead-of-time compiler: `.sx` → WASM modules. Each `defcomp` becomes a WASM function returning DOM instructions. Eliminates the interpreter entirely. The content-addressed cache stores compiled WASM blobs instead of sx source.
3. **Compute-heavy primitives in WASM.** Keep the sx interpreter in JS, bind specific primitives to WASM (image processing, crypto, data transformation). Most pragmatic and least disruptive — additive, no architecture change.
The primitive-binding model means the evaluator doesn't care what's behind a primitive. `(blur-image data radius)` could be a JS Canvas call today and a WASM JAX kernel tomorrow. The sx source doesn't change.
### Server-Driven by Default: The React Question
The sx system is architecturally aligned with HTMX/LiveView — server-driven UI — even though it does far more on the client (full s-expression evaluation, DOM rendering, morph reconciliation, component caching). The server is the single source of truth. Every UI state is a URL. Auth is enforced at render time. There are no state synchronization bugs because there is no client state to synchronize.
React's client-state model (`useState`, `useEffect`, Context, Suspense) exists because React was built for SPAs that need to feel like native apps — optimistic updates, offline capability, instant feedback without network latency. But it created an entire category of problems: state management libraries, hydration mismatches, cache invalidation, stale closures, memory leaks from forgotten cleanup, the `useEffect` footgun.
**The question is not "should sx have useState" — it's which specific interactions actually suffer from the server round-trip.**
For most of our apps, that's a very short list:
- Toggle a mobile nav panel
- Gallery image switching
- Quantity steppers
- Live search-as-you-type
These don't need a general-purpose reactive state system. They need **targeted client-side primitives** that handle those specific cases without abandoning the server-driven model.
**The dangerous path:** Add `useState` → need `useEffect` for cleanup → need Context to avoid prop drilling → need Suspense for async state → rebuild React inside sx → lose the simplicity that makes the server-driven model work.
**The careful path:** Keep server-driven as the default. Add explicit, targeted escape hatches for interactions that genuinely need client-side state. Make those escape hatches obviously different from the normal flow so they don't creep into everything.
#### What sx has vs React
| React feature | SX status | Verdict |
|---|---|---|
| Components + props | `defcomp` + `&key` | Done — cleaner than JSX |
| Fragments, conditionals, lists | `<>`, `if`/`when`/`cond`, `map` | Done — more expressive |
| Macros | `defmacro` | Done — React has nothing like this |
| OOB updates / portals | `sx-swap-oob` | Done — more powerful (server-driven) |
| DOM reconciliation | `_morphDOM` (id-keyed) | Done — works during SxEngine swaps |
| Reactive client state | None | **By design.** Server is source of truth. |
| Component lifecycle | None | Add targeted primitives if body.js behaviors move to sx |
| Context / providers | `_componentEnv` global | Sufficient for auth/theme; revisit if trees get deep |
| Suspense / loading | `sx-request` CSS class | Sufficient for server-driven; revisit for Phase 4 client data |
| Two-way data binding | None | Not needed — HTMX model (form POST → new HTML) works |
| Error boundaries | Global `sx:responseError` | Sufficient; per-component boundaries are a future nice-to-have |
| Keyed list reconciliation | id-based morph | Works; add `:key` prop support if list update bugs arise |
#### Targeted escape hatches (not a general state system)
For the few interactions that need client-side responsiveness, add **specific primitives** rather than a general framework:
- `(toggle! el "class")` — CSS class toggle, no server trip
- `(set-attr! el "attr" value)` — attribute manipulation
- `(on-event el "click" handler)` — declarative event binding within sx
- `(timer interval-ms handler)` — with automatic cleanup on DOM removal
These are imperative DOM operations exposed as primitives — not reactive state. They let components handle simple client-side interactions without importing React's entire mental model. The server-driven flow remains the default for anything involving data.

View File

@@ -171,19 +171,25 @@ def create_app() -> "Quart":
"markets": markets, "markets": markets,
} }
# Auto-mount all defpages with absolute paths
from shared.sx.pages import auto_mount_pages
auto_mount_pages(app, "events")
# Tickets blueprint — user-facing ticket views and QR codes # Tickets blueprint — user-facing ticket views and QR codes
from bp.tickets.routes import register as register_tickets from bp.tickets.routes import register as register_tickets
tickets_bp = register_tickets() tickets_bp = register_tickets()
from shared.sx.pages import mount_pages
mount_pages(tickets_bp, "events", names=["my-tickets", "ticket-detail"])
app.register_blueprint(tickets_bp) app.register_blueprint(tickets_bp)
# Ticket admin — check-in interface (admin only) # Ticket admin — check-in interface (admin only)
from bp.ticket_admin.routes import register as register_ticket_admin from bp.ticket_admin.routes import register as register_ticket_admin
ticket_admin_bp = register_ticket_admin() ticket_admin_bp = register_ticket_admin()
mount_pages(ticket_admin_bp, "events", names=["ticket-admin"])
app.register_blueprint(ticket_admin_bp) app.register_blueprint(ticket_admin_bp)
# --- Pass defpage helper data to template context for layouts ---
@app.context_processor
async def inject_events_data():
return getattr(g, '_defpage_ctx', {})
# --- oEmbed endpoint --- # --- oEmbed endpoint ---
@app.get("/oembed") @app.get("/oembed")
async def oembed(): async def oembed():

View File

@@ -11,7 +11,7 @@ Routes:
""" """
from __future__ import annotations from __future__ import annotations
from quart import Blueprint, g, request, render_template, make_response from quart import Blueprint, g, request, make_response
from shared.browser.app.utils.htmx import is_htmx_request from shared.browser.app.utils.htmx import is_htmx_request
from shared.sx.helpers import sx_response from shared.sx.helpers import sx_response

View File

@@ -1,7 +1,7 @@
from __future__ import annotations from __future__ import annotations
from quart import ( from quart import (
request, Blueprint, g Blueprint, g, request,
) )
@@ -15,18 +15,6 @@ from shared.sx.helpers import sx_response
def register(): def register():
bp = Blueprint("admin", __name__, url_prefix='/admin') bp = Blueprint("admin", __name__, url_prefix='/admin')
@bp.before_request
async def _prepare_page_data():
if "defpage_" not in (request.endpoint or ""):
return
from shared.sx.page import get_template_context
from sx.sx_components import _calendar_admin_main_panel_html
ctx = await get_template_context()
g.calendar_admin_content = _calendar_admin_main_panel_html(ctx)
from shared.sx.pages import mount_pages
mount_pages(bp, "events", names=["calendar-admin"])
@bp.get("/description/") @bp.get("/description/")
@require_admin @require_admin
async def calendar_description_edit(calendar_slug: str, **kwargs): async def calendar_description_edit(calendar_slug: str, **kwargs):

View File

@@ -2,7 +2,7 @@ from __future__ import annotations
from datetime import datetime, timezone from datetime import datetime, timezone
from quart import ( from quart import (
request, render_template, make_response, Blueprint, g, abort, session as qsession request, make_response, Blueprint, g, abort, session as qsession
) )

View File

@@ -3,7 +3,7 @@ from datetime import datetime, timezone
from decimal import Decimal from decimal import Decimal
from quart import ( from quart import (
request, render_template, make_response, request, make_response,
Blueprint, g, redirect, url_for, jsonify, Blueprint, g, redirect, url_for, jsonify,
) )

View File

@@ -1,23 +1,8 @@
from __future__ import annotations from __future__ import annotations
from quart import ( from quart import Blueprint
request, Blueprint, g
)
def register(): def register():
bp = Blueprint("admin", __name__, url_prefix='/admin') bp = Blueprint("admin", __name__, url_prefix='/admin')
@bp.before_request
async def _prepare_page_data():
if "defpage_" not in (request.endpoint or ""):
return
from shared.sx.page import get_template_context
from sx.sx_components import _entry_admin_main_panel_html
ctx = await get_template_context()
g.entry_admin_content = _entry_admin_main_panel_html(ctx)
from shared.sx.pages import mount_pages
mount_pages(bp, "events", names=["entry-admin"])
return bp return bp

View File

@@ -11,7 +11,7 @@ from shared.browser.app.redis_cacher import clear_cache
from sqlalchemy import select from sqlalchemy import select
from quart import ( from quart import (
request, render_template, make_response, Blueprint, g, jsonify request, make_response, Blueprint, g, jsonify
) )
from ..calendar_entries.services.entries import ( from ..calendar_entries.services.entries import (
svc_update_entry, svc_update_entry,
@@ -238,19 +238,6 @@ def register():
"user_ticket_counts_by_type": user_ticket_counts_by_type, "user_ticket_counts_by_type": user_ticket_counts_by_type,
"container_nav": container_nav, "container_nav": container_nav,
} }
@bp.before_request
async def _prepare_page_data():
if "defpage_" not in (request.endpoint or ""):
return
from shared.sx.page import get_template_context
from sx.sx_components import _entry_main_panel_html, _entry_nav_html
ctx = await get_template_context()
g.entry_content = _entry_main_panel_html(ctx)
g.entry_menu = _entry_nav_html(ctx)
from shared.sx.pages import mount_pages
mount_pages(bp, "events", names=["entry-detail"])
@bp.get("/edit/") @bp.get("/edit/")
@require_admin @require_admin
async def get_edit(entry_id: int, **rest): async def get_edit(entry_id: int, **rest):

View File

@@ -1,7 +1,7 @@
from __future__ import annotations from __future__ import annotations
from quart import ( from quart import (
request, render_template, make_response, Blueprint, g request, make_response, Blueprint, g
) )
from sqlalchemy import select from sqlalchemy import select

View File

@@ -1,21 +1,8 @@
from __future__ import annotations from __future__ import annotations
from quart import ( from quart import Blueprint
request, Blueprint, g
)
def register(): def register():
bp = Blueprint("admin", __name__, url_prefix='/admin') bp = Blueprint("admin", __name__, url_prefix='/admin')
@bp.before_request
async def _prepare_page_data():
if "defpage_" not in (request.endpoint or ""):
return
from sx.sx_components import _day_admin_main_panel_html
g.day_admin_content = _day_admin_main_panel_html({})
from shared.sx.pages import mount_pages
mount_pages(bp, "events", names=["day-admin"])
return bp return bp

View File

@@ -2,7 +2,7 @@ from __future__ import annotations
from datetime import datetime, timezone, date, timedelta from datetime import datetime, timezone, date, timedelta
from quart import ( from quart import (
request, render_template, make_response, Blueprint, g, abort, session as qsession request, make_response, Blueprint, g, abort, session as qsession
) )
from bp.calendar.services import get_visible_entries_for_period from bp.calendar.services import get_visible_entries_for_period

View File

@@ -1,7 +1,7 @@
from __future__ import annotations from __future__ import annotations
from quart import ( from quart import (
request, render_template, make_response, Blueprint, g request, make_response, Blueprint, g
) )
from .services.markets import ( from .services.markets import (
@@ -21,18 +21,6 @@ def register():
async def inject_root(): async def inject_root():
return {} return {}
@bp.before_request
async def _prepare_page_data():
if "defpage_" not in (request.endpoint or ""):
return
from shared.sx.page import get_template_context
from sx.sx_components import _markets_main_panel_html
ctx = await get_template_context()
g.markets_content = _markets_main_panel_html(ctx)
from shared.sx.pages import mount_pages
mount_pages(bp, "events", names=["events-markets"])
@bp.post("/new/") @bp.post("/new/")
@require_admin @require_admin
async def create_market(**kwargs): async def create_market(**kwargs):

View File

@@ -8,7 +8,7 @@ Routes:
""" """
from __future__ import annotations from __future__ import annotations
from quart import Blueprint, g, request, render_template, make_response from quart import Blueprint, g, request, make_response
from shared.browser.app.utils.htmx import is_htmx_request from shared.browser.app.utils.htmx import is_htmx_request
from shared.sx.helpers import sx_response from shared.sx.helpers import sx_response

View File

@@ -29,27 +29,6 @@ from shared.sx.helpers import sx_response
def register(): def register():
bp = Blueprint("slot", __name__, url_prefix='/<int:slot_id>') bp = Blueprint("slot", __name__, url_prefix='/<int:slot_id>')
@bp.before_request
async def _prepare_page_data():
if "defpage_" not in (request.endpoint or ""):
return
slot_id = (request.view_args or {}).get("slot_id")
slot = await svc_get_slot(g.s, slot_id) if slot_id else None
if not slot:
from quart import abort
abort(404)
g.slot = slot
calendar = getattr(g, "calendar", None)
from sx.sx_components import render_slot_main_panel
g.slot_content = render_slot_main_panel(slot, calendar)
@bp.context_processor
async def _inject_slot():
return {"slot": getattr(g, "slot", None)}
from shared.sx.pages import mount_pages
mount_pages(bp, "events", names=["slot-detail"])
@bp.get("/edit/") @bp.get("/edit/")
@require_admin @require_admin
async def get_edit(slot_id: int, **kwargs): async def get_edit(slot_id: int, **kwargs):

View File

@@ -38,18 +38,6 @@ def register():
} }
return {"slots": []} return {"slots": []}
@bp.before_request
async def _prepare_page_data():
if "defpage_" not in (request.endpoint or ""):
return
calendar = getattr(g, "calendar", None)
slots = await svc_list_slots(g.s, calendar.id) if calendar else []
from sx.sx_components import render_slots_table
g.slots_content = render_slots_table(slots, calendar)
from shared.sx.pages import mount_pages
mount_pages(bp, "events", names=["slots-listing"])
@bp.post("/") @bp.post("/")
@require_admin @require_admin
@clear_cache(tag="calendars", tag_scope="all") @clear_cache(tag="calendars", tag_scope="all")

View File

@@ -14,10 +14,10 @@ import logging
from quart import ( from quart import (
Blueprint, g, request, make_response, Blueprint, g, request, make_response,
) )
from sqlalchemy import select, func from sqlalchemy import select
from sqlalchemy.orm import selectinload from sqlalchemy.orm import selectinload
from models.calendars import CalendarEntry, Ticket, TicketType from models.calendars import CalendarEntry
from shared.browser.app.authz import require_admin from shared.browser.app.authz import require_admin
from shared.browser.app.redis_cacher import clear_cache from shared.browser.app.redis_cacher import clear_cache
from shared.sx.helpers import sx_response from shared.sx.helpers import sx_response
@@ -34,46 +34,6 @@ logger = logging.getLogger(__name__)
def register() -> Blueprint: def register() -> Blueprint:
bp = Blueprint("ticket_admin", __name__, url_prefix="/admin/tickets") bp = Blueprint("ticket_admin", __name__, url_prefix="/admin/tickets")
@bp.before_request
async def _prepare_page_data():
if "defpage_" not in (request.endpoint or ""):
return
# Get recent tickets
result = await g.s.execute(
select(Ticket)
.options(
selectinload(Ticket.entry).selectinload(CalendarEntry.calendar),
selectinload(Ticket.ticket_type),
)
.order_by(Ticket.created_at.desc())
.limit(50)
)
tickets = result.scalars().all()
# Stats
total = await g.s.scalar(select(func.count(Ticket.id)))
confirmed = await g.s.scalar(
select(func.count(Ticket.id)).where(Ticket.state == "confirmed")
)
checked_in = await g.s.scalar(
select(func.count(Ticket.id)).where(Ticket.state == "checked_in")
)
reserved = await g.s.scalar(
select(func.count(Ticket.id)).where(Ticket.state == "reserved")
)
stats = {
"total": total or 0,
"confirmed": confirmed or 0,
"checked_in": checked_in or 0,
"reserved": reserved or 0,
}
from shared.sx.page import get_template_context
from sx.sx_components import _ticket_admin_main_panel_html
ctx = await get_template_context()
g.ticket_admin_content = _ticket_admin_main_panel_html(ctx, tickets, stats)
@bp.get("/entry/<int:entry_id>/") @bp.get("/entry/<int:entry_id>/")
@require_admin @require_admin
async def entry_tickets(entry_id: int): async def entry_tickets(entry_id: int):

View File

@@ -22,32 +22,6 @@ from shared.sx.helpers import sx_response
def register(): def register():
bp = Blueprint("ticket_type", __name__, url_prefix='/<int:ticket_type_id>') bp = Blueprint("ticket_type", __name__, url_prefix='/<int:ticket_type_id>')
@bp.before_request
async def _prepare_page_data():
if "defpage_" not in (request.endpoint or ""):
return
ticket_type_id = (request.view_args or {}).get("ticket_type_id")
ticket_type = await svc_get_ticket_type(g.s, ticket_type_id) if ticket_type_id else None
if not ticket_type:
from quart import abort
abort(404)
g.ticket_type = ticket_type
entry = getattr(g, "entry", None)
calendar = getattr(g, "calendar", None)
va = request.view_args or {}
from sx.sx_components import render_ticket_type_main_panel
g.ticket_type_content = render_ticket_type_main_panel(
ticket_type, entry, calendar,
va.get("day"), va.get("month"), va.get("year"),
)
@bp.context_processor
async def _inject_ticket_type():
return {"ticket_type": getattr(g, "ticket_type", None)}
from shared.sx.pages import mount_pages
mount_pages(bp, "events", names=["ticket-type-detail"])
@bp.get("/edit/") @bp.get("/edit/")
@require_admin @require_admin
async def get_edit(ticket_type_id: int, **kwargs): async def get_edit(ticket_type_id: int, **kwargs):

View File

@@ -35,23 +35,6 @@ def register():
} }
return {"ticket_types": []} return {"ticket_types": []}
@bp.before_request
async def _prepare_page_data():
if "defpage_" not in (request.endpoint or ""):
return
entry = getattr(g, "entry", None)
calendar = getattr(g, "calendar", None)
ticket_types = await svc_list_ticket_types(g.s, entry.id) if entry else []
va = request.view_args or {}
from sx.sx_components import render_ticket_types_table
g.ticket_types_content = render_ticket_types_table(
ticket_types, entry, calendar,
va.get("day"), va.get("month"), va.get("year"),
)
from shared.sx.pages import mount_pages
mount_pages(bp, "events", names=["ticket-types-listing"])
@bp.post("/") @bp.post("/")
@require_admin @require_admin
@clear_cache(tag="calendars", tag_scope="all") @clear_cache(tag="calendars", tag_scope="all")

View File

@@ -24,8 +24,6 @@ from shared.sx.helpers import sx_response
from .services.tickets import ( from .services.tickets import (
create_ticket, create_ticket,
get_ticket_by_code,
get_user_tickets,
get_available_ticket_count, get_available_ticket_count,
get_tickets_for_entry, get_tickets_for_entry,
get_sold_ticket_count, get_sold_ticket_count,
@@ -39,44 +37,6 @@ logger = logging.getLogger(__name__)
def register() -> Blueprint: def register() -> Blueprint:
bp = Blueprint("tickets", __name__, url_prefix="/tickets") bp = Blueprint("tickets", __name__, url_prefix="/tickets")
@bp.before_request
async def _prepare_page_data():
ep = request.endpoint or ""
if "defpage_my_tickets" in ep:
ident = current_cart_identity()
tickets = await get_user_tickets(
g.s,
user_id=ident["user_id"],
session_id=ident["session_id"],
)
from shared.sx.page import get_template_context
from sx.sx_components import _tickets_main_panel_html
ctx = await get_template_context()
g.tickets_content = _tickets_main_panel_html(ctx, tickets)
elif "defpage_ticket_detail" in ep:
code = (request.view_args or {}).get("code")
ticket = await get_ticket_by_code(g.s, code) if code else None
if not ticket:
from quart import abort
abort(404)
# Verify ownership
ident = current_cart_identity()
if ident["user_id"] is not None:
if ticket.user_id != ident["user_id"]:
from quart import abort
abort(404)
elif ident["session_id"] is not None:
if ticket.session_id != ident["session_id"]:
from quart import abort
abort(404)
else:
from quart import abort
abort(404)
from shared.sx.page import get_template_context
from sx.sx_components import _ticket_detail_panel_html
ctx = await get_template_context()
g.ticket_detail_content = _ticket_detail_panel_html(ctx, ticket)
@bp.post("/buy/") @bp.post("/buy/")
@clear_cache(tag="calendars", tag_scope="all") @clear_cache(tag="calendars", tag_scope="all")
async def buy_tickets(): async def buy_tickets():

View File

@@ -191,11 +191,11 @@ def _calendar_nav_sx(ctx: dict) -> str:
select_colours = ctx.get("select_colours", "") select_colours = ctx.get("select_colours", "")
parts = [] parts = []
slots_href = url_for("calendar.slots.defpage_slots_listing", calendar_slug=cal_slug) slots_href = url_for("defpage_slots_listing", calendar_slug=cal_slug)
parts.append(sx_call("nav-link", href=slots_href, icon="fa fa-clock", parts.append(sx_call("nav-link", href=slots_href, icon="fa fa-clock",
label="Slots", select_colours=select_colours)) label="Slots", select_colours=select_colours))
if is_admin: if is_admin:
admin_href = url_for("calendar.admin.defpage_calendar_admin", calendar_slug=cal_slug) admin_href = url_for("defpage_calendar_admin", calendar_slug=cal_slug)
parts.append(sx_call("nav-link", href=admin_href, icon="fa fa-cog", parts.append(sx_call("nav-link", href=admin_href, icon="fa fa-cog",
select_colours=select_colours)) select_colours=select_colours))
return "".join(parts) return "".join(parts)
@@ -319,7 +319,7 @@ def _calendar_admin_header_sx(ctx: dict, *, oob: bool = False) -> str:
nav_parts = [] nav_parts = []
if cal_slug: if cal_slug:
for endpoint, label in [ for endpoint, label in [
("calendar.slots.defpage_slots_listing", "slots"), ("defpage_slots_listing", "slots"),
("calendar.admin.calendar_description_edit", "description"), ("calendar.admin.calendar_description_edit", "description"),
]: ]:
href = url_for(endpoint, calendar_slug=cal_slug) href = url_for(endpoint, calendar_slug=cal_slug)
@@ -339,7 +339,7 @@ def _calendar_admin_header_sx(ctx: dict, *, oob: bool = False) -> str:
def _markets_header_sx(ctx: dict, *, oob: bool = False) -> str: def _markets_header_sx(ctx: dict, *, oob: bool = False) -> str:
"""Build the markets section header row.""" """Build the markets section header row."""
from quart import url_for from quart import url_for
link_href = url_for("markets.defpage_events_markets") link_href = url_for("defpage_events_markets")
return sx_call("menu-row-sx", id="markets-row", level=3, return sx_call("menu-row-sx", id="markets-row", level=3,
link_href=link_href, link_href=link_href,
link_label_content=SxExpr(sx_call("events-markets-label")), link_label_content=SxExpr(sx_call("events-markets-label")),
@@ -594,7 +594,7 @@ def _day_row_html(ctx: dict, entry) -> str:
# Slot/Time # Slot/Time
slot = getattr(entry, "slot", None) slot = getattr(entry, "slot", None)
if slot: if slot:
slot_href = url_for("calendar.slots.slot.defpage_slot_detail", calendar_slug=cal_slug, slot_id=slot.id) slot_href = url_for("defpage_slot_detail", calendar_slug=cal_slug, slot_id=slot.id)
time_start = slot.time_start.strftime("%H:%M") if slot.time_start else "" time_start = slot.time_start.strftime("%H:%M") if slot.time_start else ""
time_end = f" \u2192 {slot.time_end.strftime('%H:%M')}" if slot.time_end else "" time_end = f" \u2192 {slot.time_end.strftime('%H:%M')}" if slot.time_end else ""
slot_html = sx_call("events-day-row-slot", slot_html = sx_call("events-day-row-slot",
@@ -774,7 +774,7 @@ def _tickets_main_panel_html(ctx: dict, tickets: list) -> str:
ticket_cards = [] ticket_cards = []
if tickets: if tickets:
for ticket in tickets: for ticket in tickets:
href = url_for("tickets.defpage_ticket_detail", code=ticket.code) href = url_for("defpage_ticket_detail", code=ticket.code)
entry = getattr(ticket, "entry", None) entry = getattr(ticket, "entry", None)
entry_name = entry.name if entry else "Unknown event" entry_name = entry.name if entry else "Unknown event"
tt = getattr(ticket, "ticket_type", None) tt = getattr(ticket, "ticket_type", None)
@@ -819,7 +819,7 @@ def _ticket_detail_panel_html(ctx: dict, ticket) -> str:
bg_map = {"confirmed": "bg-emerald-50", "checked_in": "bg-blue-50", "reserved": "bg-amber-50"} bg_map = {"confirmed": "bg-emerald-50", "checked_in": "bg-blue-50", "reserved": "bg-amber-50"}
header_bg = bg_map.get(state, "bg-stone-50") header_bg = bg_map.get(state, "bg-stone-50")
entry_name = entry.name if entry else "Ticket" entry_name = entry.name if entry else "Ticket"
back_href = url_for("tickets.defpage_my_tickets") back_href = url_for("defpage_my_tickets")
# Badge with larger sizing # Badge with larger sizing
badge = _ticket_state_badge_html(state).replace('px-2 py-0.5 text-xs', 'px-3 py-1 text-sm') badge = _ticket_state_badge_html(state).replace('px-2 py-0.5 text-xs', 'px-3 py-1 text-sm')
@@ -2165,7 +2165,7 @@ def render_slots_table(slots, calendar) -> str:
rows_html = "" rows_html = ""
if slots: if slots:
for s in slots: for s in slots:
slot_href = url_for("calendar.slots.slot.defpage_slot_detail", calendar_slug=cal_slug, slot_id=s.id) slot_href = url_for("defpage_slot_detail", calendar_slug=cal_slug, slot_id=s.id)
del_url = url_for("calendar.slots.slot.slot_delete", calendar_slug=cal_slug, slot_id=s.id) del_url = url_for("calendar.slots.slot.slot_delete", calendar_slug=cal_slug, slot_id=s.id)
desc = getattr(s, "description", "") or "" desc = getattr(s, "description", "") or ""
@@ -2309,7 +2309,7 @@ def render_buy_result(entry, created_tickets, remaining, cart_count) -> str:
tickets_html = "" tickets_html = ""
for ticket in created_tickets: for ticket in created_tickets:
href = url_for("tickets.defpage_ticket_detail", code=ticket.code) href = url_for("defpage_ticket_detail", code=ticket.code)
tickets_html += sx_call("events-buy-result-ticket", tickets_html += sx_call("events-buy-result-ticket",
href=href, code_short=ticket.code[:12] + "...") href=href, code_short=ticket.code[:12] + "...")
@@ -2319,7 +2319,7 @@ def render_buy_result(entry, created_tickets, remaining, cart_count) -> str:
remaining_html = sx_call("events-buy-result-remaining", remaining_html = sx_call("events-buy-result-remaining",
text=f"{remaining} ticket{r_suffix} remaining") text=f"{remaining} ticket{r_suffix} remaining")
my_href = url_for("tickets.defpage_my_tickets") my_href = url_for("defpage_my_tickets")
return cart_html + sx_call("events-buy-result", return cart_html + sx_call("events-buy-result",
entry_id=str(entry.id), entry_id=str(entry.id),
@@ -2411,7 +2411,7 @@ def _ticket_adjust_controls(csrf, adjust_url, target, entry_id, count, *, ticket
return _adj_form(1, sx_call("events-adjust-cart-plus"), return _adj_form(1, sx_call("events-adjust-cart-plus"),
extra_cls="flex items-center") extra_cls="flex items-center")
my_tickets_href = url_for("tickets.defpage_my_tickets") my_tickets_href = url_for("defpage_my_tickets")
minus = _adj_form(count - 1, sx_call("events-adjust-minus")) minus = _adj_form(count - 1, sx_call("events-adjust-minus"))
cart_icon = sx_call("events-adjust-cart-icon", cart_icon = sx_call("events-adjust-cart-icon",
href=my_tickets_href, count=str(count)) href=my_tickets_href, count=str(count))

View File

@@ -311,6 +311,183 @@ def _markets_oob(ctx: dict, **kw: Any) -> str:
return oobs return oobs
# ---------------------------------------------------------------------------
# Shared hydration helpers
# ---------------------------------------------------------------------------
def _add_to_defpage_ctx(**kwargs: Any) -> None:
"""Add data to g._defpage_ctx for the app-level context_processor."""
from quart import g
if not hasattr(g, '_defpage_ctx'):
g._defpage_ctx = {}
g._defpage_ctx.update(kwargs)
async def _ensure_calendar(calendar_slug: str | None) -> None:
"""Load calendar into g.calendar if not already present."""
from quart import g, abort
if hasattr(g, 'calendar'):
_add_to_defpage_ctx(calendar=g.calendar)
return
from bp.calendar.services.calendar_view import (
get_calendar_by_post_and_slug, get_calendar_by_slug,
)
post_data = getattr(g, "post_data", None)
if post_data:
post_id = (post_data.get("post") or {}).get("id")
cal = await get_calendar_by_post_and_slug(g.s, post_id, calendar_slug)
else:
cal = await get_calendar_by_slug(g.s, calendar_slug)
if not cal:
abort(404)
g.calendar = cal
g.calendar_slug = calendar_slug
_add_to_defpage_ctx(calendar=cal)
async def _ensure_entry(entry_id: int | None) -> None:
"""Load calendar entry into g.entry if not already present."""
from quart import g, abort
if hasattr(g, 'entry'):
_add_to_defpage_ctx(entry=g.entry)
return
from sqlalchemy import select
from models.calendars import CalendarEntry
result = await g.s.execute(
select(CalendarEntry).where(
CalendarEntry.id == entry_id,
CalendarEntry.deleted_at.is_(None),
)
)
entry = result.scalar_one_or_none()
if entry is None:
abort(404)
g.entry = entry
_add_to_defpage_ctx(entry=entry)
async def _ensure_entry_context(entry_id: int | None) -> None:
"""Load full entry context (ticket data, posts) into g.* and _defpage_ctx."""
from quart import g
from sqlalchemy import select
from sqlalchemy.orm import selectinload
from models.calendars import CalendarEntry
from bp.tickets.services.tickets import (
get_available_ticket_count,
get_sold_ticket_count,
get_user_reserved_count,
)
from shared.infrastructure.cart_identity import current_cart_identity
from bp.calendar_entry.services.post_associations import get_entry_posts
await _ensure_entry(entry_id)
# Reload with ticket_types eagerly loaded
stmt = (
select(CalendarEntry)
.where(CalendarEntry.id == entry_id, CalendarEntry.deleted_at.is_(None))
.options(selectinload(CalendarEntry.ticket_types))
)
result = await g.s.execute(stmt)
calendar_entry = result.scalar_one_or_none()
if calendar_entry and getattr(g, "calendar", None):
if calendar_entry.calendar_id != g.calendar.id:
calendar_entry = None
if calendar_entry:
await g.s.refresh(calendar_entry, ['slot'])
g.entry = calendar_entry
entry_posts = await get_entry_posts(g.s, calendar_entry.id)
ticket_remaining = await get_available_ticket_count(g.s, calendar_entry.id)
ticket_sold_count = await get_sold_ticket_count(g.s, calendar_entry.id)
ident = current_cart_identity()
user_ticket_count = await get_user_reserved_count(
g.s, calendar_entry.id,
user_id=ident["user_id"],
session_id=ident["session_id"],
)
user_ticket_counts_by_type = {}
if calendar_entry.ticket_types:
for tt in calendar_entry.ticket_types:
if tt.deleted_at is None:
user_ticket_counts_by_type[tt.id] = await get_user_reserved_count(
g.s, calendar_entry.id,
user_id=ident["user_id"],
session_id=ident["session_id"],
ticket_type_id=tt.id,
)
_add_to_defpage_ctx(
entry=calendar_entry,
entry_posts=entry_posts,
ticket_remaining=ticket_remaining,
ticket_sold_count=ticket_sold_count,
user_ticket_count=user_ticket_count,
user_ticket_counts_by_type=user_ticket_counts_by_type,
)
async def _ensure_day_data(year: int, month: int, day: int) -> None:
"""Load day-specific data for layout header functions."""
from quart import g, session as qsession
if hasattr(g, 'day_date'):
return
from datetime import date as date_cls, datetime, timezone, timedelta
from sqlalchemy import select
from bp.calendar.services import get_visible_entries_for_period
from models.calendars import CalendarSlot
calendar = getattr(g, "calendar", None)
if not calendar:
return
try:
day_date = date_cls(year, month, day)
except (ValueError, TypeError):
return
period_start = datetime(year, month, day, tzinfo=timezone.utc)
period_end = period_start + timedelta(days=1)
user = getattr(g, "user", None)
session_id = qsession.get("calendar_sid")
visible = await get_visible_entries_for_period(
sess=g.s,
calendar_id=calendar.id,
period_start=period_start,
period_end=period_end,
user=user,
session_id=session_id,
)
weekday_attr = ["mon", "tue", "wed", "thu", "fri", "sat", "sun"][day_date.weekday()]
stmt = (
select(CalendarSlot)
.where(
CalendarSlot.calendar_id == calendar.id,
getattr(CalendarSlot, weekday_attr) == True, # noqa: E712
CalendarSlot.deleted_at.is_(None),
)
.order_by(CalendarSlot.time_start.asc(), CalendarSlot.id.asc())
)
result = await g.s.execute(stmt)
day_slots = list(result.scalars())
g.day_date = day_date
_add_to_defpage_ctx(
qsession=qsession,
day_date=day_date,
day=day,
year=year,
month=month,
day_entries=visible.merged_entries,
user_entries=visible.user_entries,
confirmed_entries=visible.confirmed_entries,
day_slots=day_slots,
)
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
# Page helpers # Page helpers
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
@@ -336,39 +513,72 @@ def _register_events_helpers() -> None:
}) })
def _h_calendar_admin_content(): async def _h_calendar_admin_content(calendar_slug=None, **kw):
await _ensure_calendar(calendar_slug)
from shared.sx.page import get_template_context
from sx.sx_components import _calendar_admin_main_panel_html
ctx = await get_template_context()
return _calendar_admin_main_panel_html(ctx)
async def _h_day_admin_content(calendar_slug=None, year=None, month=None, day=None, **kw):
await _ensure_calendar(calendar_slug)
if year is not None:
await _ensure_day_data(int(year), int(month), int(day))
from sx.sx_components import _day_admin_main_panel_html
return _day_admin_main_panel_html({})
async def _h_slots_content(calendar_slug=None, **kw):
from quart import g from quart import g
return getattr(g, "calendar_admin_content", "") await _ensure_calendar(calendar_slug)
calendar = getattr(g, "calendar", None)
from bp.slots.services.slots import list_slots as svc_list_slots
slots = await svc_list_slots(g.s, calendar.id) if calendar else []
_add_to_defpage_ctx(slots=slots)
from sx.sx_components import render_slots_table
return render_slots_table(slots, calendar)
def _h_day_admin_content(): async def _h_slot_content(calendar_slug=None, slot_id=None, **kw):
from quart import g from quart import g, abort
return getattr(g, "day_admin_content", "") await _ensure_calendar(calendar_slug)
from bp.slot.services.slot import get_slot as svc_get_slot
slot = await svc_get_slot(g.s, slot_id) if slot_id else None
if not slot:
abort(404)
g.slot = slot
_add_to_defpage_ctx(slot=slot)
calendar = getattr(g, "calendar", None)
from sx.sx_components import render_slot_main_panel
return render_slot_main_panel(slot, calendar)
def _h_slots_content(): async def _h_entry_content(calendar_slug=None, entry_id=None, **kw):
from quart import g await _ensure_calendar(calendar_slug)
return getattr(g, "slots_content", "") await _ensure_entry_context(entry_id)
from shared.sx.page import get_template_context
from sx.sx_components import _entry_main_panel_html
ctx = await get_template_context()
return _entry_main_panel_html(ctx)
def _h_slot_content(): async def _h_entry_menu(calendar_slug=None, entry_id=None, **kw):
from quart import g await _ensure_calendar(calendar_slug)
return getattr(g, "slot_content", "") await _ensure_entry_context(entry_id)
from shared.sx.page import get_template_context
from sx.sx_components import _entry_nav_html
ctx = await get_template_context()
return _entry_nav_html(ctx)
def _h_entry_content(): async def _h_entry_admin_content(calendar_slug=None, entry_id=None, **kw):
from quart import g await _ensure_calendar(calendar_slug)
return getattr(g, "entry_content", "") await _ensure_entry_context(entry_id)
from shared.sx.page import get_template_context
from sx.sx_components import _entry_admin_main_panel_html
def _h_entry_menu(): ctx = await get_template_context()
from quart import g return _entry_admin_main_panel_html(ctx)
return getattr(g, "entry_menu", "")
def _h_entry_admin_content():
from quart import g
return getattr(g, "entry_admin_content", "")
def _h_admin_menu(): def _h_admin_menu():
@@ -376,31 +586,118 @@ def _h_admin_menu():
return sx_call("events-admin-placeholder-nav") return sx_call("events-admin-placeholder-nav")
def _h_ticket_types_content(): async def _h_ticket_types_content(calendar_slug=None, entry_id=None,
year=None, month=None, day=None, **kw):
from quart import g from quart import g
return getattr(g, "ticket_types_content", "") await _ensure_calendar(calendar_slug)
await _ensure_entry(entry_id)
entry = getattr(g, "entry", None)
calendar = getattr(g, "calendar", None)
from bp.ticket_types.services.tickets import list_ticket_types as svc_list_ticket_types
ticket_types = await svc_list_ticket_types(g.s, entry.id) if entry else []
_add_to_defpage_ctx(ticket_types=ticket_types)
from sx.sx_components import render_ticket_types_table
return render_ticket_types_table(ticket_types, entry, calendar, day, month, year)
def _h_ticket_type_content(): async def _h_ticket_type_content(calendar_slug=None, entry_id=None,
ticket_type_id=None, year=None, month=None, day=None, **kw):
from quart import g, abort
await _ensure_calendar(calendar_slug)
await _ensure_entry(entry_id)
from bp.ticket_type.services.ticket import get_ticket_type as svc_get_ticket_type
ticket_type = await svc_get_ticket_type(g.s, ticket_type_id) if ticket_type_id else None
if not ticket_type:
abort(404)
g.ticket_type = ticket_type
_add_to_defpage_ctx(ticket_type=ticket_type)
entry = getattr(g, "entry", None)
calendar = getattr(g, "calendar", None)
from sx.sx_components import render_ticket_type_main_panel
return render_ticket_type_main_panel(ticket_type, entry, calendar, day, month, year)
async def _h_tickets_content(**kw):
from quart import g from quart import g
return getattr(g, "ticket_type_content", "") from shared.infrastructure.cart_identity import current_cart_identity
from bp.tickets.services.tickets import get_user_tickets
ident = current_cart_identity()
tickets = await get_user_tickets(
g.s,
user_id=ident["user_id"],
session_id=ident["session_id"],
)
from shared.sx.page import get_template_context
from sx.sx_components import _tickets_main_panel_html
ctx = await get_template_context()
return _tickets_main_panel_html(ctx, tickets)
def _h_tickets_content(): async def _h_ticket_detail_content(code=None, **kw):
from quart import g, abort
from shared.infrastructure.cart_identity import current_cart_identity
from bp.tickets.services.tickets import get_ticket_by_code
ticket = await get_ticket_by_code(g.s, code) if code else None
if not ticket:
abort(404)
# Verify ownership
ident = current_cart_identity()
if ident["user_id"] is not None:
if ticket.user_id != ident["user_id"]:
abort(404)
elif ident["session_id"] is not None:
if ticket.session_id != ident["session_id"]:
abort(404)
else:
abort(404)
from shared.sx.page import get_template_context
from sx.sx_components import _ticket_detail_panel_html
ctx = await get_template_context()
return _ticket_detail_panel_html(ctx, ticket)
async def _h_ticket_admin_content(**kw):
from quart import g from quart import g
return getattr(g, "tickets_content", "") from sqlalchemy import select, func
from sqlalchemy.orm import selectinload
from models.calendars import CalendarEntry, Ticket
result = await g.s.execute(
select(Ticket)
.options(
selectinload(Ticket.entry).selectinload(CalendarEntry.calendar),
selectinload(Ticket.ticket_type),
)
.order_by(Ticket.created_at.desc())
.limit(50)
)
tickets = result.scalars().all()
total = await g.s.scalar(select(func.count(Ticket.id)))
confirmed = await g.s.scalar(
select(func.count(Ticket.id)).where(Ticket.state == "confirmed")
)
checked_in = await g.s.scalar(
select(func.count(Ticket.id)).where(Ticket.state == "checked_in")
)
reserved = await g.s.scalar(
select(func.count(Ticket.id)).where(Ticket.state == "reserved")
)
stats = {
"total": total or 0,
"confirmed": confirmed or 0,
"checked_in": checked_in or 0,
"reserved": reserved or 0,
}
from shared.sx.page import get_template_context
from sx.sx_components import _ticket_admin_main_panel_html
ctx = await get_template_context()
return _ticket_admin_main_panel_html(ctx, tickets, stats)
def _h_ticket_detail_content(): async def _h_markets_content(**kw):
from quart import g from shared.sx.page import get_template_context
return getattr(g, "ticket_detail_content", "") from sx.sx_components import _markets_main_panel_html
ctx = await get_template_context()
return _markets_main_panel_html(ctx)
def _h_ticket_admin_content():
from quart import g
return getattr(g, "ticket_admin_content", "")
def _h_markets_content():
from quart import g
return getattr(g, "markets_content", "")

View File

@@ -1,89 +1,89 @@
;; Events pages — mounted on various nested blueprints ;; Events pages — auto-mounted with absolute paths
;; Calendar admin (mounted on calendar.admin bp) ;; Calendar admin
(defpage calendar-admin (defpage calendar-admin
:path "/" :path "/<slug>/<calendar_slug>/admin/"
:auth :admin :auth :admin
:layout :events-calendar-admin :layout :events-calendar-admin
:content (calendar-admin-content)) :content (calendar-admin-content calendar-slug))
;; Day admin (mounted on day.admin bp) ;; Day admin
(defpage day-admin (defpage day-admin
:path "/" :path "/<slug>/<calendar_slug>/day/<int:year>/<int:month>/<int:day>/admin/"
:auth :admin :auth :admin
:layout :events-day-admin :layout :events-day-admin
:content (day-admin-content)) :content (day-admin-content calendar-slug year month day))
;; Slots listing (mounted on slots bp) ;; Slots listing
(defpage slots-listing (defpage slots-listing
:path "/" :path "/<slug>/<calendar_slug>/slots/"
:auth :public :auth :public
:layout :events-slots :layout :events-slots
:content (slots-content)) :content (slots-content calendar-slug))
;; Slot detail (mounted on slot bp) ;; Slot detail
(defpage slot-detail (defpage slot-detail
:path "/" :path "/<slug>/<calendar_slug>/slots/<int:slot_id>/"
:auth :admin :auth :admin
:layout :events-slot :layout :events-slot
:content (slot-content)) :content (slot-content calendar-slug slot-id))
;; Entry detail (mounted on calendar_entry bp) ;; Entry detail
(defpage entry-detail (defpage entry-detail
:path "/" :path "/<slug>/<calendar_slug>/day/<int:year>/<int:month>/<int:day>/entries/<int:entry_id>/"
:auth :admin :auth :admin
:layout :events-entry :layout :events-entry
:content (entry-content) :content (entry-content calendar-slug entry-id)
:menu (entry-menu)) :menu (entry-menu calendar-slug entry-id))
;; Entry admin (mounted on calendar_entry.admin bp) ;; Entry admin
(defpage entry-admin (defpage entry-admin
:path "/" :path "/<slug>/<calendar_slug>/day/<int:year>/<int:month>/<int:day>/entries/<int:entry_id>/admin/"
:auth :admin :auth :admin
:layout :events-entry-admin :layout :events-entry-admin
:content (entry-admin-content) :content (entry-admin-content calendar-slug entry-id)
:menu (admin-menu)) :menu (admin-menu))
;; Ticket types listing (mounted on ticket_types bp) ;; Ticket types listing
(defpage ticket-types-listing (defpage ticket-types-listing
:path "/" :path "/<slug>/<calendar_slug>/day/<int:year>/<int:month>/<int:day>/entries/<int:entry_id>/ticket-types/"
:auth :public :auth :public
:layout :events-ticket-types :layout :events-ticket-types
:content (ticket-types-content) :content (ticket-types-content calendar-slug entry-id year month day)
:menu (admin-menu)) :menu (admin-menu))
;; Ticket type detail (mounted on ticket_type bp) ;; Ticket type detail
(defpage ticket-type-detail (defpage ticket-type-detail
:path "/" :path "/<slug>/<calendar_slug>/day/<int:year>/<int:month>/<int:day>/entries/<int:entry_id>/ticket-types/<int:ticket_type_id>/"
:auth :admin :auth :admin
:layout :events-ticket-type :layout :events-ticket-type
:content (ticket-type-content) :content (ticket-type-content calendar-slug entry-id ticket-type-id year month day)
:menu (admin-menu)) :menu (admin-menu))
;; My tickets (mounted on tickets bp) ;; My tickets
(defpage my-tickets (defpage my-tickets
:path "/" :path "/tickets/"
:auth :public :auth :public
:layout :root :layout :root
:content (tickets-content)) :content (tickets-content))
;; Ticket detail (mounted on tickets bp) ;; Ticket detail
(defpage ticket-detail (defpage ticket-detail
:path "/<code>/" :path "/tickets/<code>/"
:auth :public :auth :public
:layout :root :layout :root
:content (ticket-detail-content)) :content (ticket-detail-content code))
;; Ticket admin dashboard (mounted on ticket_admin bp) ;; Ticket admin dashboard
(defpage ticket-admin (defpage ticket-admin
:path "/" :path "/admin/tickets/"
:auth :admin :auth :admin
:layout :root :layout :root
:content (ticket-admin-content)) :content (ticket-admin-content))
;; Markets (mounted on markets bp) ;; Markets
(defpage events-markets (defpage events-markets
:path "/" :path "/<slug>/markets/"
:auth :public :auth :public
:layout :events-markets :layout :events-markets
:content (markets-content)) :content (markets-content))

View File

@@ -94,10 +94,11 @@ def create_app() -> "Quart":
app.register_blueprint(register_identity_bp()) app.register_blueprint(register_identity_bp())
social_bp = register_social_bp() social_bp = register_social_bp()
from shared.sx.pages import mount_pages
mount_pages(social_bp, "federation")
app.register_blueprint(social_bp) app.register_blueprint(social_bp)
from shared.sx.pages import auto_mount_pages
auto_mount_pages(app, "federation")
app.register_blueprint(register_fragments()) app.register_blueprint(register_fragments())
# --- home page --- # --- home page ---

View File

@@ -32,102 +32,6 @@ def register(url_prefix="/social"):
actor = await services.federation.get_actor_by_user_id(g.s, g.user.id) actor = await services.federation.get_actor_by_user_id(g.s, g.user.id)
g._social_actor = actor g._social_actor = actor
@bp.before_request
async def _prepare_page_data():
"""Pre-render content for defpage routes."""
endpoint = request.endpoint or ""
if endpoint.endswith("defpage_home_timeline"):
actor = _require_actor()
items = await services.federation.get_home_timeline(g.s, actor.id)
from sx.sx_components import _timeline_content_sx
g.home_timeline_content = _timeline_content_sx(items, "home", actor)
elif endpoint.endswith("defpage_public_timeline"):
actor = getattr(g, "_social_actor", None)
items = await services.federation.get_public_timeline(g.s)
from sx.sx_components import _timeline_content_sx
g.public_timeline_content = _timeline_content_sx(items, "public", actor)
elif endpoint.endswith("defpage_compose_form"):
actor = _require_actor()
from sx.sx_components import _compose_content_sx
reply_to = request.args.get("reply_to")
g.compose_content = _compose_content_sx(actor, reply_to)
elif endpoint.endswith("defpage_search"):
actor = getattr(g, "_social_actor", None)
query = request.args.get("q", "").strip()
actors_list = []
total = 0
followed_urls: set[str] = set()
if query:
actors_list, total = await services.federation.search_actors(g.s, query)
if actor:
following, _ = await services.federation.get_following(
g.s, actor.preferred_username, page=1, per_page=1000,
)
followed_urls = {a.actor_url for a in following}
from sx.sx_components import _search_content_sx
g.search_content = _search_content_sx(query, actors_list, total, 1, followed_urls, actor)
elif endpoint.endswith("defpage_following_list"):
actor = _require_actor()
actors_list, total = await services.federation.get_following(
g.s, actor.preferred_username,
)
from sx.sx_components import _following_content_sx
g.following_content = _following_content_sx(actors_list, total, actor)
elif endpoint.endswith("defpage_followers_list"):
actor = _require_actor()
actors_list, total = await services.federation.get_followers_paginated(
g.s, actor.preferred_username,
)
following, _ = await services.federation.get_following(
g.s, actor.preferred_username, page=1, per_page=1000,
)
followed_urls = {a.actor_url for a in following}
from sx.sx_components import _followers_content_sx
g.followers_content = _followers_content_sx(actors_list, total, followed_urls, actor)
elif endpoint.endswith("defpage_actor_timeline"):
actor = getattr(g, "_social_actor", None)
actor_id = request.view_args.get("id")
from shared.models.federation import RemoteActor
from sqlalchemy import select as sa_select
remote = (
await g.s.execute(
sa_select(RemoteActor).where(RemoteActor.id == actor_id)
)
).scalar_one_or_none()
if not remote:
abort(404)
from shared.services.federation_impl import _remote_actor_to_dto
remote_dto = _remote_actor_to_dto(remote)
items = await services.federation.get_actor_timeline(g.s, actor_id)
is_following = False
if actor:
from shared.models.federation import APFollowing
existing = (
await g.s.execute(
sa_select(APFollowing).where(
APFollowing.actor_profile_id == actor.id,
APFollowing.remote_actor_id == actor_id,
)
)
).scalar_one_or_none()
is_following = existing is not None
from sx.sx_components import _actor_timeline_content_sx
g.actor_timeline_content = _actor_timeline_content_sx(remote_dto, items, is_following, actor)
elif endpoint.endswith("defpage_notifications"):
actor = _require_actor()
items = await services.federation.get_notifications(g.s, actor.id)
await services.federation.mark_notifications_read(g.s, actor.id)
from sx.sx_components import _notifications_content_sx
g.notifications_content = _notifications_content_sx(items)
# -- Timeline pagination --------------------------------------------------- # -- Timeline pagination ---------------------------------------------------
@bp.get("/timeline") @bp.get("/timeline")
@@ -170,7 +74,7 @@ def register(url_prefix="/social"):
form = await request.form form = await request.form
content = form.get("content", "").strip() content = form.get("content", "").strip()
if not content: if not content:
return redirect(url_for("social.defpage_compose_form")) return redirect(url_for("defpage_compose_form"))
visibility = form.get("visibility", "public") visibility = form.get("visibility", "public")
in_reply_to = form.get("in_reply_to") or None in_reply_to = form.get("in_reply_to") or None
@@ -181,13 +85,13 @@ def register(url_prefix="/social"):
visibility=visibility, visibility=visibility,
in_reply_to=in_reply_to, in_reply_to=in_reply_to,
) )
return redirect(url_for("social.defpage_home_timeline")) return redirect(url_for("defpage_home_timeline"))
@bp.post("/delete/<int:post_id>") @bp.post("/delete/<int:post_id>")
async def delete_post(post_id: int): async def delete_post(post_id: int):
actor = _require_actor() actor = _require_actor()
await services.federation.delete_local_post(g.s, actor.id, post_id) await services.federation.delete_local_post(g.s, actor.id, post_id)
return redirect(url_for("social.defpage_home_timeline")) return redirect(url_for("defpage_home_timeline"))
# -- Search + Follow ------------------------------------------------------- # -- Search + Follow -------------------------------------------------------
@@ -223,7 +127,7 @@ def register(url_prefix="/social"):
) )
if request.headers.get("SX-Request") or request.headers.get("HX-Request"): if request.headers.get("SX-Request") or request.headers.get("HX-Request"):
return await _actor_card_response(actor, remote_actor_url, is_followed=True) return await _actor_card_response(actor, remote_actor_url, is_followed=True)
return redirect(request.referrer or url_for("social.defpage_search")) return redirect(request.referrer or url_for("defpage_search"))
@bp.post("/unfollow") @bp.post("/unfollow")
async def unfollow(): async def unfollow():
@@ -236,7 +140,7 @@ def register(url_prefix="/social"):
) )
if request.headers.get("SX-Request") or request.headers.get("HX-Request"): if request.headers.get("SX-Request") or request.headers.get("HX-Request"):
return await _actor_card_response(actor, remote_actor_url, is_followed=False) return await _actor_card_response(actor, remote_actor_url, is_followed=False)
return redirect(request.referrer or url_for("social.defpage_search")) return redirect(request.referrer or url_for("defpage_search"))
async def _actor_card_response(actor, remote_actor_url, is_followed): async def _actor_card_response(actor, remote_actor_url, is_followed):
"""Re-render a single actor card after follow/unfollow via HTMX.""" """Re-render a single actor card after follow/unfollow via HTMX."""
@@ -414,6 +318,6 @@ def register(url_prefix="/social"):
async def mark_read(): async def mark_read():
actor = _require_actor() actor = _require_actor()
await services.federation.mark_notifications_read(g.s, actor.id) await services.federation.mark_notifications_read(g.s, actor.id)
return redirect(url_for("social.defpage_notifications")) return redirect(url_for("defpage_notifications"))
return bp return bp

View File

@@ -69,41 +69,130 @@ def _register_federation_helpers() -> None:
}) })
def _h_home_timeline_content(): def _get_actor():
"""Return current user's actor or None."""
from quart import g from quart import g
return getattr(g, "home_timeline_content", "") return getattr(g, "_social_actor", None)
def _h_public_timeline_content(): def _require_actor():
"""Return current user's actor or abort 403."""
from quart import abort
actor = _get_actor()
if not actor:
abort(403, "You need to choose a federation username first")
return actor
async def _h_home_timeline_content(**kw):
from quart import g from quart import g
return getattr(g, "public_timeline_content", "") from shared.services.registry import services
actor = _require_actor()
items = await services.federation.get_home_timeline(g.s, actor.id)
from sx.sx_components import _timeline_content_sx
return _timeline_content_sx(items, "home", actor)
def _h_compose_content(): async def _h_public_timeline_content(**kw):
from quart import g from quart import g
return getattr(g, "compose_content", "") from shared.services.registry import services
actor = _get_actor()
items = await services.federation.get_public_timeline(g.s)
from sx.sx_components import _timeline_content_sx
return _timeline_content_sx(items, "public", actor)
def _h_search_content(): async def _h_compose_content(**kw):
from quart import request
actor = _require_actor()
from sx.sx_components import _compose_content_sx
reply_to = request.args.get("reply_to")
return _compose_content_sx(actor, reply_to)
async def _h_search_content(**kw):
from quart import g, request
from shared.services.registry import services
actor = _get_actor()
query = request.args.get("q", "").strip()
actors_list = []
total = 0
followed_urls: set[str] = set()
if query:
actors_list, total = await services.federation.search_actors(g.s, query)
if actor:
following, _ = await services.federation.get_following(
g.s, actor.preferred_username, page=1, per_page=1000,
)
followed_urls = {a.actor_url for a in following}
from sx.sx_components import _search_content_sx
return _search_content_sx(query, actors_list, total, 1, followed_urls, actor)
async def _h_following_content(**kw):
from quart import g from quart import g
return getattr(g, "search_content", "") from shared.services.registry import services
actor = _require_actor()
actors_list, total = await services.federation.get_following(
g.s, actor.preferred_username,
)
from sx.sx_components import _following_content_sx
return _following_content_sx(actors_list, total, actor)
def _h_following_content(): async def _h_followers_content(**kw):
from quart import g from quart import g
return getattr(g, "following_content", "") from shared.services.registry import services
actor = _require_actor()
actors_list, total = await services.federation.get_followers_paginated(
g.s, actor.preferred_username,
)
following, _ = await services.federation.get_following(
g.s, actor.preferred_username, page=1, per_page=1000,
)
followed_urls = {a.actor_url for a in following}
from sx.sx_components import _followers_content_sx
return _followers_content_sx(actors_list, total, followed_urls, actor)
def _h_followers_content(): async def _h_actor_timeline_content(id=None, **kw):
from quart import g, abort
from shared.services.registry import services
actor = _get_actor()
actor_id = id
from shared.models.federation import RemoteActor
from sqlalchemy import select as sa_select
remote = (
await g.s.execute(
sa_select(RemoteActor).where(RemoteActor.id == actor_id)
)
).scalar_one_or_none()
if not remote:
abort(404)
from shared.services.federation_impl import _remote_actor_to_dto
remote_dto = _remote_actor_to_dto(remote)
items = await services.federation.get_actor_timeline(g.s, actor_id)
is_following = False
if actor:
from shared.models.federation import APFollowing
existing = (
await g.s.execute(
sa_select(APFollowing).where(
APFollowing.actor_profile_id == actor.id,
APFollowing.remote_actor_id == actor_id,
)
)
).scalar_one_or_none()
is_following = existing is not None
from sx.sx_components import _actor_timeline_content_sx
return _actor_timeline_content_sx(remote_dto, items, is_following, actor)
async def _h_notifications_content(**kw):
from quart import g from quart import g
return getattr(g, "followers_content", "") from shared.services.registry import services
actor = _require_actor()
items = await services.federation.get_notifications(g.s, actor.id)
def _h_actor_timeline_content(): await services.federation.mark_notifications_read(g.s, actor.id)
from quart import g from sx.sx_components import _notifications_content_sx
return getattr(g, "actor_timeline_content", "") return _notifications_content_sx(items)
def _h_notifications_content():
from quart import g
return getattr(g, "notifications_content", "")

View File

@@ -1,49 +1,49 @@
;; Federation social pages ;; Federation social pages
(defpage home-timeline (defpage home-timeline
:path "/" :path "/social/"
:auth :login :auth :login
:layout :social :layout :social
:content (home-timeline-content)) :content (home-timeline-content))
(defpage public-timeline (defpage public-timeline
:path "/public" :path "/social/public"
:auth :public :auth :public
:layout :social :layout :social
:content (public-timeline-content)) :content (public-timeline-content))
(defpage compose-form (defpage compose-form
:path "/compose" :path "/social/compose"
:auth :login :auth :login
:layout :social :layout :social
:content (compose-content)) :content (compose-content))
(defpage search (defpage search
:path "/search" :path "/social/search"
:auth :public :auth :public
:layout :social :layout :social
:content (search-content)) :content (search-content))
(defpage following-list (defpage following-list
:path "/following" :path "/social/following"
:auth :login :auth :login
:layout :social :layout :social
:content (following-content)) :content (following-content))
(defpage followers-list (defpage followers-list
:path "/followers" :path "/social/followers"
:auth :login :auth :login
:layout :social :layout :social
:content (followers-content)) :content (followers-content))
(defpage actor-timeline (defpage actor-timeline
:path "/actor/<int:id>" :path "/social/actor/<int:id>"
:auth :public :auth :public
:layout :social :layout :social
:content (actor-timeline-content)) :content (actor-timeline-content id))
(defpage notifications (defpage notifications
:path "/notifications" :path "/social/notifications"
:auth :login :auth :login
:layout :social :layout :social
:content (notifications-content)) :content (notifications-content))

View File

@@ -1,32 +0,0 @@
{% extends "_types/social/index.html" %}
{% block title %}@{{ actor.preferred_username }} — Rose Ash{% endblock %}
{% block social_content %}
<div class="py-8">
<div class="bg-white rounded-lg shadow p-6 mb-6">
<h1 class="text-2xl font-bold">{{ actor.display_name or actor.preferred_username }}</h1>
<p class="text-stone-500">@{{ actor.preferred_username }}@{{ config.get('ap_domain', 'rose-ash.com') }}</p>
{% if actor.summary %}
<p class="mt-2">{{ actor.summary }}</p>
{% endif %}
</div>
<h2 class="text-xl font-bold mb-4">Activities ({{ total }})</h2>
{% if activities %}
<div class="space-y-4">
{% for a in activities %}
<div class="bg-white rounded-lg shadow p-4">
<div class="flex justify-between items-start">
<span class="font-medium">{{ a.activity_type }}</span>
<span class="text-sm text-stone-400">{{ a.published.strftime('%Y-%m-%d %H:%M') if a.published }}</span>
</div>
{% if a.object_type %}
<span class="text-sm text-stone-500">{{ a.object_type }}</span>
{% endif %}
</div>
{% endfor %}
</div>
{% else %}
<p class="text-stone-500">No activities yet.</p>
{% endif %}
</div>
{% endblock %}

View File

@@ -103,21 +103,16 @@ def create_app() -> "Quart":
from sxc.pages import setup_market_pages from sxc.pages import setup_market_pages
setup_market_pages() setup_market_pages()
from shared.sx.pages import mount_pages
# All markets: / — global view across all pages # All markets: / — global view across all pages
all_markets_bp = register_all_markets() all_markets_bp = register_all_markets()
mount_pages(all_markets_bp, "market", names=["all-markets-index"])
app.register_blueprint(all_markets_bp, url_prefix="/") app.register_blueprint(all_markets_bp, url_prefix="/")
# Page markets: /<slug>/ — markets for a single page # Page markets: /<slug>/ — markets for a single page
page_markets_bp = register_page_markets() page_markets_bp = register_page_markets()
mount_pages(page_markets_bp, "market", names=["page-markets-index"])
app.register_blueprint(page_markets_bp, url_prefix="/<slug>") app.register_blueprint(page_markets_bp, url_prefix="/<slug>")
# Page admin: /<slug>/admin/ — post-level admin for markets # Page admin: /<slug>/admin/ — post-level admin for markets
page_admin_bp = register_page_admin() page_admin_bp = register_page_admin()
mount_pages(page_admin_bp, "market", names=["page-admin"])
app.register_blueprint(page_admin_bp, url_prefix="/<slug>/admin") app.register_blueprint(page_admin_bp, url_prefix="/<slug>/admin")
# Market blueprint nested under post slug: /<page_slug>/<market_slug>/ # Market blueprint nested under post slug: /<page_slug>/<market_slug>/
@@ -135,6 +130,10 @@ def create_app() -> "Quart":
app.register_blueprint(register_actions()) app.register_blueprint(register_actions())
app.register_blueprint(register_data()) app.register_blueprint(register_data())
# Auto-mount all defpages with absolute paths
from shared.sx.pages import auto_mount_pages
auto_mount_pages(app, "market")
# --- Auto-inject slugs into url_for() calls --- # --- Auto-inject slugs into url_for() calls ---
@app.url_value_preprocessor @app.url_value_preprocessor
def pull_slugs(endpoint, values): def pull_slugs(endpoint, values):

View File

@@ -41,19 +41,6 @@ async def _load_markets(page, per_page=20):
def register() -> Blueprint: def register() -> Blueprint:
bp = Blueprint("all_markets", __name__) bp = Blueprint("all_markets", __name__)
@bp.before_request
async def _prepare_page_data():
"""Load all-markets data for defpage routes."""
endpoint = request.endpoint or ""
if not endpoint.endswith("defpage_all_markets_index"):
return
page = int(request.args.get("page", 1))
markets, has_more, page_info = await _load_markets(page)
g.all_markets_data = {
"markets": markets, "has_more": has_more,
"page_info": page_info, "page": page,
}
@bp.get("/all-markets") @bp.get("/all-markets")
async def markets_fragment(): async def markets_fragment():
page = int(request.args.get("page", 1)) page = int(request.args.get("page", 1))

View File

@@ -29,10 +29,6 @@ def register():
register_product(), register_product(),
) )
# Mount defpage for market home (GET /)
from shared.sx.pages import mount_pages
mount_pages(browse_bp, "market", names=["market-home"])
@browse_bp.get("/all/") @browse_bp.get("/all/")
@cache_page(tag="browse") @cache_page(tag="browse")
async def browse_all(): async def browse_all():

View File

@@ -5,9 +5,4 @@ from quart import Blueprint
def register(): def register():
bp = Blueprint("admin", __name__, url_prefix='/admin') bp = Blueprint("admin", __name__, url_prefix='/admin')
# Mount defpage for market admin (GET /)
from shared.sx.pages import mount_pages
mount_pages(bp, "market", names=["market-admin"])
return bp return bp

View File

@@ -1,6 +1,6 @@
from __future__ import annotations from __future__ import annotations
from quart import Blueprint, g, render_template, make_response, url_for from quart import Blueprint, g, make_response, url_for
from ..browse.routes import register as register_browse_bp from ..browse.routes import register as register_browse_bp

View File

@@ -26,17 +26,6 @@ def _slugify(value: str, max_len: int = 255) -> str:
def register(): def register():
bp = Blueprint("page_admin", __name__) bp = Blueprint("page_admin", __name__)
@bp.before_request
async def _prepare_page_data():
"""Pre-render page admin content for defpage (async helper)."""
endpoint = request.endpoint or ""
if request.method != "GET" or not endpoint.endswith("defpage_page_admin"):
return
from shared.sx.page import get_template_context
from sx.sx_components import _markets_admin_panel_sx
ctx = await get_template_context()
g.page_admin_content = await _markets_admin_panel_sx(ctx)
@bp.post("/new/") @bp.post("/new/")
@require_admin @require_admin
async def create_market(**kwargs): async def create_market(**kwargs):

View File

@@ -23,20 +23,6 @@ async def _load_markets(post_id, page, per_page=20):
def register() -> Blueprint: def register() -> Blueprint:
bp = Blueprint("page_markets", __name__) bp = Blueprint("page_markets", __name__)
@bp.before_request
async def _prepare_page_data():
"""Load page-markets data for defpage routes."""
endpoint = request.endpoint or ""
if not endpoint.endswith("defpage_page_markets_index"):
return
post = g.post_data["post"]
page = int(request.args.get("page", 1))
markets, has_more = await _load_markets(post["id"], page)
g.page_markets_data = {
"markets": markets, "has_more": has_more,
"page": page, "post_slug": post.get("slug", ""),
}
@bp.get("/page-markets") @bp.get("/page-markets")
async def markets_fragment(): async def markets_fragment():
post = g.post_data["post"] post = g.post_data["post"]

View File

@@ -98,67 +98,77 @@ def _register_market_helpers() -> None:
}) })
def _h_all_markets_content(): async def _h_all_markets_content(**kw):
from quart import g, url_for, request from quart import g, url_for, request
from shared.utils import route_prefix from shared.utils import route_prefix
from shared.services.registry import services
from shared.infrastructure.data_client import fetch_data
from shared.contracts.dtos import PostDTO, dto_from_dict
data = getattr(g, "all_markets_data", None) page = int(request.args.get("page", 1))
if not data: markets, has_more = await services.market.list_marketplaces(
g.s, page=page, per_page=20,
)
page_info = {}
if markets:
post_ids = list({m.container_id for m in markets if m.container_type == "page"})
if post_ids:
raw_posts = await fetch_data("blog", "posts-by-ids",
params={"ids": ",".join(str(i) for i in post_ids)},
required=False) or []
for raw_p in raw_posts:
p = dto_from_dict(PostDTO, raw_p)
page_info[p.id] = {"title": p.title, "slug": p.slug}
if not markets:
from sx.sx_components import _no_markets_sx from sx.sx_components import _no_markets_sx
return _no_markets_sx() return _no_markets_sx()
markets = data["markets"]
has_more = data["has_more"]
page_info = data["page_info"]
page = data["page"]
prefix = route_prefix() prefix = route_prefix()
next_url = prefix + url_for("all_markets.markets_fragment", page=page + 1) next_url = prefix + url_for("all_markets.markets_fragment", page=page + 1)
from sx.sx_components import _market_cards_sx, _markets_grid, _no_markets_sx from sx.sx_components import _market_cards_sx, _markets_grid
if markets: cards = _market_cards_sx(markets, page_info, page, has_more, next_url)
cards = _market_cards_sx(markets, page_info, page, has_more, next_url) content = _markets_grid(cards)
content = _markets_grid(cards)
else:
content = _no_markets_sx()
return "(<> " + content + " " + '(div :class "pb-8")' + ")" return "(<> " + content + " " + '(div :class "pb-8")' + ")"
def _h_page_markets_content(): async def _h_page_markets_content(slug=None, **kw):
from quart import g, url_for from quart import g, url_for, request
from shared.utils import route_prefix from shared.utils import route_prefix
from shared.services.registry import services
data = getattr(g, "page_markets_data", None) post = g.post_data["post"]
if not data: page = int(request.args.get("page", 1))
markets, has_more = await services.market.list_marketplaces(
g.s, "page", post["id"], page=page, per_page=20,
)
post_slug = post.get("slug", "")
if not markets:
from sx.sx_components import _no_markets_sx from sx.sx_components import _no_markets_sx
return _no_markets_sx("No markets for this page") return _no_markets_sx("No markets for this page")
markets = data["markets"]
has_more = data["has_more"]
page = data["page"]
post_slug = data.get("post_slug", "")
prefix = route_prefix() prefix = route_prefix()
next_url = prefix + url_for("page_markets.markets_fragment", page=page + 1) next_url = prefix + url_for("page_markets.markets_fragment", page=page + 1)
from sx.sx_components import _market_cards_sx, _markets_grid, _no_markets_sx from sx.sx_components import _market_cards_sx, _markets_grid
if markets: cards = _market_cards_sx(markets, {}, page, has_more, next_url,
cards = _market_cards_sx(markets, {}, page, has_more, next_url, show_page_badge=False, post_slug=post_slug)
show_page_badge=False, post_slug=post_slug) content = _markets_grid(cards)
content = _markets_grid(cards)
else:
content = _no_markets_sx("No markets for this page")
return "(<> " + content + " " + '(div :class "pb-8")' + ")" return "(<> " + content + " " + '(div :class "pb-8")' + ")"
def _h_page_admin_content(): async def _h_page_admin_content(slug=None, **kw):
# Content pre-rendered by before_request (async _markets_admin_panel_sx) from shared.sx.page import get_template_context
from quart import g from sx.sx_components import _markets_admin_panel_sx
content = getattr(g, "page_admin_content", "") ctx = await get_template_context()
content = await _markets_admin_panel_sx(ctx)
return '(div :id "main-panel" ' + content + ')' return '(div :id "main-panel" ' + content + ')'
def _h_market_home_content(): def _h_market_home_content(page_slug=None, market_slug=None, **kw):
from quart import g from quart import g
post_data = getattr(g, "post_data", {}) post_data = getattr(g, "post_data", {})
post = post_data.get("post", {}) post = post_data.get("post", {})
@@ -166,5 +176,5 @@ def _h_market_home_content():
return _market_landing_content_sx(post) return _market_landing_content_sx(post)
def _h_market_admin_content(): def _h_market_admin_content(page_slug=None, market_slug=None, **kw):
return '"market admin"' return '"market admin"'

View File

@@ -1,10 +1,10 @@
;; Market app defpage declarations. ;; Market app defpage declarations.
;; ;;
;; all-markets-index: / — global view across all pages ;; all-markets-index: / — global view across all pages
;; page-markets-index: / (on page_markets bp, mounted at /<slug>) ;; page-markets-index: /<slug>/ — markets for a single page
;; page-admin: / (on page_admin bp, mounted at /<slug>/admin) ;; page-admin: /<slug>/admin/ — post-level admin for markets
;; market-home: / (on browse bp, mounted at /<page_slug>/<market_slug>) ;; market-home: /<page_slug>/<market_slug>/ — market landing page
;; market-admin: / (on admin bp, mounted at /<page_slug>/<market_slug>/admin) ;; market-admin: /<page_slug>/<market_slug>/admin/ — market admin
(defpage all-markets-index (defpage all-markets-index
:path "/" :path "/"
@@ -13,25 +13,25 @@
:content (all-markets-content)) :content (all-markets-content))
(defpage page-markets-index (defpage page-markets-index
:path "/" :path "/<slug>/"
:auth :public :auth :public
:layout :post :layout :post
:content (page-markets-content)) :content (page-markets-content))
(defpage page-admin (defpage page-admin
:path "/" :path "/<slug>/admin/"
:auth :admin :auth :admin
:layout (:post-admin :selected "markets") :layout (:post-admin :selected "markets")
:content (page-admin-content)) :content (page-admin-content))
(defpage market-home (defpage market-home
:path "/" :path "/<page_slug>/<market_slug>/"
:auth :public :auth :public
:layout :market :layout :market
:content (market-home-content)) :content (market-home-content))
(defpage market-admin (defpage market-admin
:path "/" :path "/<page_slug>/<market_slug>/admin/"
:auth :admin :auth :admin
:layout (:market-admin :selected "markets") :layout (:market-admin :selected "markets")
:content (market-admin-content)) :content (market-admin-content))

View File

@@ -81,12 +81,13 @@ def create_app() -> "Quart":
app.register_blueprint(register_actions()) app.register_blueprint(register_actions())
app.register_blueprint(register_data()) app.register_blueprint(register_data())
# Orders list at / (defpage routes mounted below) # Orders list at /
bp = register_orders(url_prefix="/") bp = register_orders(url_prefix="/")
from shared.sx.pages import mount_pages
mount_pages(bp, "orders")
app.register_blueprint(bp) app.register_blueprint(bp)
from shared.sx.pages import auto_mount_pages
auto_mount_pages(app, "orders")
# Checkout webhook + return # Checkout webhook + return
app.register_blueprint(register_checkout()) app.register_blueprint(register_checkout())

View File

@@ -1,6 +1,6 @@
from __future__ import annotations from __future__ import annotations
from quart import Blueprint, g, redirect, url_for, make_response, request from quart import Blueprint, g, redirect, url_for, request
from sqlalchemy import select, func, or_, cast, String, exists from sqlalchemy import select, func, or_, cast, String, exists
from sqlalchemy.orm import selectinload from sqlalchemy.orm import selectinload
@@ -8,7 +8,6 @@ from shared.models.order import Order, OrderItem
from shared.infrastructure.http_utils import vary as _vary, current_url_without_page as _current_url_without_page from shared.infrastructure.http_utils import vary as _vary, current_url_without_page as _current_url_without_page
from shared.infrastructure.cart_identity import current_cart_identity from shared.infrastructure.cart_identity import current_cart_identity
from shared.browser.app.utils.htmx import is_htmx_request
from bp.order.routes import register as register_order from bp.order.routes import register as register_order
from .filters.qs import makeqs_factory, decode from .filters.qs import makeqs_factory, decode
@@ -31,112 +30,6 @@ def register(url_prefix: str) -> Blueprint:
if not ident["user_id"] and not ident["session_id"]: if not ident["user_id"] and not ident["session_id"]:
return redirect(url_for("auth.login_form")) return redirect(url_for("auth.login_form"))
@bp.before_request
async def _prepare_page_data():
"""Load data for defpage routes into g.*."""
if request.method != "GET":
return
endpoint = request.endpoint or ""
# Orders list page
if endpoint.endswith("defpage_orders_list"):
ident = current_cart_identity()
if ident["user_id"]:
owner_clause = Order.user_id == ident["user_id"]
elif ident["session_id"]:
owner_clause = Order.session_id == ident["session_id"]
else:
return
q = decode()
page, search = q.page, q.search
if page < 1:
page = 1
where_clause = _search_clause(search) if search else None
count_stmt = select(func.count()).select_from(Order).where(owner_clause)
if where_clause is not None:
count_stmt = count_stmt.where(where_clause)
total_count_result = await g.s.execute(count_stmt)
total_count = total_count_result.scalar_one() or 0
total_pages = max(1, (total_count + ORDERS_PER_PAGE - 1) // ORDERS_PER_PAGE)
if page > total_pages:
page = total_pages
offset = (page - 1) * ORDERS_PER_PAGE
stmt = (
select(Order)
.where(owner_clause)
.order_by(Order.created_at.desc())
.offset(offset)
.limit(ORDERS_PER_PAGE)
)
if where_clause is not None:
stmt = stmt.where(where_clause)
result = await g.s.execute(stmt)
orders = result.scalars().all()
from shared.utils import route_prefix
pfx = route_prefix()
qs_fn = makeqs_factory()
g.orders_page_data = {
"orders": orders,
"page": page,
"total_pages": total_pages,
"search": search,
"search_count": total_count,
"url_for_fn": url_for,
"qs_fn": qs_fn,
"list_url": pfx + url_for("orders.defpage_orders_list"),
}
# Order detail page
elif endpoint.endswith("defpage_order_detail"):
order_id = request.view_args.get("order_id")
if order_id is None:
return
ident = current_cart_identity()
if ident["user_id"]:
owner = Order.user_id == ident["user_id"]
elif ident["session_id"]:
owner = Order.session_id == ident["session_id"]
else:
from quart import abort
abort(404)
return
result = await g.s.execute(
select(Order)
.options(selectinload(Order.items))
.where(Order.id == order_id, owner)
)
order = result.scalar_one_or_none()
if not order:
from quart import abort
abort(404)
return
from shared.utils import route_prefix
from shared.browser.app.csrf import generate_csrf_token
pfx = route_prefix()
g.order_detail_data = {
"order": order,
"calendar_entries": None,
"detail_url": pfx + url_for("orders.defpage_order_detail", order_id=order.id),
"list_url": pfx + url_for("orders.defpage_orders_list"),
"recheck_url": pfx + url_for("orders.order.order_recheck", order_id=order.id),
"pay_url": pfx + url_for("orders.order.order_pay", order_id=order.id),
"csrf_token": generate_csrf_token(),
}
@bp.get("/rows") @bp.get("/rows")
async def orders_rows(): async def orders_rows():
"""Pagination endpoint — returns order rows for page > 1.""" """Pagination endpoint — returns order rows for page > 1."""

View File

@@ -119,7 +119,143 @@ def _register_orders_helpers() -> None:
}) })
def _h_orders_list_content(): async def _ensure_orders_list():
"""Fetch orders list data and store in g.orders_page_data."""
from quart import g, url_for
if hasattr(g, "orders_page_data"):
return
from sqlalchemy import select, func, or_, cast, String, exists
from shared.models.order import Order, OrderItem
from shared.infrastructure.cart_identity import current_cart_identity
from shared.utils import route_prefix
ORDERS_PER_PAGE = 10
ident = current_cart_identity()
if ident["user_id"]:
owner_clause = Order.user_id == ident["user_id"]
elif ident["session_id"]:
owner_clause = Order.session_id == ident["session_id"]
else:
g.orders_page_data = None
return
from bp.orders.filters.qs import makeqs_factory, decode
q = decode()
page, search = q.page, q.search
if page < 1:
page = 1
where_clause = None
if search:
term = f"%{search.strip()}%"
conditions = [
Order.status.ilike(term),
Order.currency.ilike(term),
Order.sumup_checkout_id.ilike(term),
Order.sumup_status.ilike(term),
Order.description.ilike(term),
]
conditions.append(
exists(
select(1).select_from(OrderItem)
.where(OrderItem.order_id == Order.id,
or_(OrderItem.product_title.ilike(term),
OrderItem.product_slug.ilike(term)))
)
)
try:
search_id = int(search)
except (TypeError, ValueError):
search_id = None
if search_id is not None:
conditions.append(Order.id == search_id)
else:
conditions.append(cast(Order.id, String).ilike(term))
where_clause = or_(*conditions)
count_stmt = select(func.count()).select_from(Order).where(owner_clause)
if where_clause is not None:
count_stmt = count_stmt.where(where_clause)
total_count_result = await g.s.execute(count_stmt)
total_count = total_count_result.scalar_one() or 0
total_pages = max(1, (total_count + ORDERS_PER_PAGE - 1) // ORDERS_PER_PAGE)
if page > total_pages:
page = total_pages
offset = (page - 1) * ORDERS_PER_PAGE
stmt = (
select(Order).where(owner_clause)
.order_by(Order.created_at.desc())
.offset(offset).limit(ORDERS_PER_PAGE)
)
if where_clause is not None:
stmt = stmt.where(where_clause)
result = await g.s.execute(stmt)
orders = result.scalars().all()
pfx = route_prefix()
qs_fn = makeqs_factory()
g.orders_page_data = {
"orders": orders,
"page": page,
"total_pages": total_pages,
"search": search,
"search_count": total_count,
"url_for_fn": url_for,
"qs_fn": qs_fn,
"list_url": pfx + url_for("defpage_orders_list"),
}
async def _ensure_order_detail(order_id):
"""Fetch order detail data and store in g.order_detail_data."""
from quart import g, url_for, abort
if hasattr(g, "order_detail_data"):
return
from sqlalchemy import select
from sqlalchemy.orm import selectinload
from shared.models.order import Order
from shared.infrastructure.cart_identity import current_cart_identity
from shared.utils import route_prefix
from shared.browser.app.csrf import generate_csrf_token
if order_id is None:
abort(404)
ident = current_cart_identity()
if ident["user_id"]:
owner = Order.user_id == ident["user_id"]
elif ident["session_id"]:
owner = Order.session_id == ident["session_id"]
else:
abort(404)
return
result = await g.s.execute(
select(Order).options(selectinload(Order.items))
.where(Order.id == order_id, owner)
)
order = result.scalar_one_or_none()
if not order:
abort(404)
return
pfx = route_prefix()
g.order_detail_data = {
"order": order,
"calendar_entries": None,
"detail_url": pfx + url_for("defpage_order_detail", order_id=order.id),
"list_url": pfx + url_for("defpage_orders_list"),
"recheck_url": pfx + url_for("orders.order.order_recheck", order_id=order.id),
"pay_url": pfx + url_for("orders.order.order_pay", order_id=order.id),
"csrf_token": generate_csrf_token(),
}
async def _h_orders_list_content(**kw):
await _ensure_orders_list()
from quart import g from quart import g
d = getattr(g, "orders_page_data", None) d = getattr(g, "orders_page_data", None)
if not d: if not d:
@@ -131,7 +267,8 @@ def _h_orders_list_content():
return _orders_main_panel_sx(d["orders"], rows) return _orders_main_panel_sx(d["orders"], rows)
def _h_orders_list_filter(): async def _h_orders_list_filter(**kw):
await _ensure_orders_list()
from quart import g from quart import g
from shared.sx.helpers import sx_call, SxExpr from shared.sx.helpers import sx_call, SxExpr
from shared.sx.page import SEARCH_HEADERS_MOBILE from shared.sx.page import SEARCH_HEADERS_MOBILE
@@ -148,7 +285,8 @@ def _h_orders_list_filter():
return sx_call("order-list-header", search_mobile=SxExpr(search_mobile)) return sx_call("order-list-header", search_mobile=SxExpr(search_mobile))
def _h_orders_list_aside(): async def _h_orders_list_aside(**kw):
await _ensure_orders_list()
from quart import g from quart import g
from shared.sx.helpers import sx_call from shared.sx.helpers import sx_call
from shared.sx.page import SEARCH_HEADERS_DESKTOP from shared.sx.page import SEARCH_HEADERS_DESKTOP
@@ -164,13 +302,15 @@ def _h_orders_list_aside():
) )
def _h_orders_list_url(): async def _h_orders_list_url(**kw):
await _ensure_orders_list()
from quart import g from quart import g
d = getattr(g, "orders_page_data", None) d = getattr(g, "orders_page_data", None)
return d["list_url"] if d else "/" return d["list_url"] if d else "/"
def _h_order_detail_content(): async def _h_order_detail_content(order_id=None, **kw):
await _ensure_order_detail(order_id)
from quart import g from quart import g
d = getattr(g, "order_detail_data", None) d = getattr(g, "order_detail_data", None)
if not d: if not d:
@@ -179,7 +319,8 @@ def _h_order_detail_content():
return _order_main_sx(d["order"], d["calendar_entries"]) return _order_main_sx(d["order"], d["calendar_entries"])
def _h_order_detail_filter(): async def _h_order_detail_filter(order_id=None, **kw):
await _ensure_order_detail(order_id)
from quart import g from quart import g
d = getattr(g, "order_detail_data", None) d = getattr(g, "order_detail_data", None)
if not d: if not d:
@@ -189,13 +330,15 @@ def _h_order_detail_filter():
d["pay_url"], d["csrf_token"]) d["pay_url"], d["csrf_token"])
def _h_order_detail_url(): async def _h_order_detail_url(order_id=None, **kw):
await _ensure_order_detail(order_id)
from quart import g from quart import g
d = getattr(g, "order_detail_data", None) d = getattr(g, "order_detail_data", None)
return d["detail_url"] if d else "/" return d["detail_url"] if d else "/"
def _h_order_list_url_from_detail(): async def _h_order_list_url_from_detail(order_id=None, **kw):
await _ensure_order_detail(order_id)
from quart import g from quart import g
d = getattr(g, "order_detail_data", None) d = getattr(g, "order_detail_data", None)
return d["list_url"] if d else "/" return d["list_url"] if d else "/"

View File

@@ -21,7 +21,7 @@
:path "/<int:order_id>/" :path "/<int:order_id>/"
:auth :public :auth :public
:layout (:order-detail :layout (:order-detail
:list-url (order-list-url-from-detail) :list-url (order-list-url-from-detail order-id)
:detail-url (order-detail-url)) :detail-url (order-detail-url order-id))
:filter (order-detail-filter) :filter (order-detail-filter order-id)
:content (order-detail-content)) :content (order-detail-content order-id))

View File

@@ -1,42 +0,0 @@
{% import 'macros/links.html' as links %}
{% macro header_row(oob=False) %}
{% call links.menu_row(id='social-lite-row', oob=oob) %}
<div class="w-full flex flex-row items-center gap-2 flex-wrap">
{% if actor %}
<nav class="flex gap-3 text-sm items-center flex-wrap">
<a href="{{ url_for('ap_social.search') }}"
class="px-2 py-1 rounded hover:bg-stone-200 {% if request.path == url_for('ap_social.search') %}font-bold{% endif %}">
Search
</a>
<a href="{{ url_for('ap_social.following_list') }}"
class="px-2 py-1 rounded hover:bg-stone-200 {% if request.path == url_for('ap_social.following_list') %}font-bold{% endif %}">
Following
</a>
<a href="{{ url_for('ap_social.followers_list') }}"
class="px-2 py-1 rounded hover:bg-stone-200 {% if request.path == url_for('ap_social.followers_list') %}font-bold{% endif %}">
Followers
</a>
<a href="{{ url_for('activitypub.actor_profile', username=actor.preferred_username) }}"
class="px-2 py-1 rounded hover:bg-stone-200">
@{{ actor.preferred_username }}
</a>
<a href="{{ federation_url('/social/') }}"
class="px-2 py-1 rounded hover:bg-stone-200 text-stone-500">
Hub
</a>
</nav>
{% else %}
<nav class="flex gap-3 text-sm items-center">
<a href="{{ url_for('ap_social.search') }}"
class="px-2 py-1 rounded hover:bg-stone-200 {% if request.path == url_for('ap_social.search') %}font-bold{% endif %}">
Search
</a>
<a href="{{ federation_url('/social/') }}"
class="px-2 py-1 rounded hover:bg-stone-200 text-stone-500">
Hub
</a>
</nav>
{% endif %}
</div>
{% endcall %}
{% endmacro %}

View File

@@ -1,10 +0,0 @@
{% extends '_types/root/_index.html' %}
{% block meta %}{% endblock %}
{% block root_header_child %}
{% from '_types/root/_n/macros.html' import index_row with context %}
{% call index_row('social-lite-header-child', '_types/social_lite/header/_header.html') %}
{% endcall %}
{% endblock %}
{% block content %}
{% block social_content %}{% endblock %}
{% endblock %}

View File

@@ -1,63 +0,0 @@
{% for a in actors %}
<article class="bg-white rounded-lg shadow-sm border border-stone-200 p-4 mb-3 flex items-center gap-4"
id="actor-{{ a.actor_url | replace('/', '_') | replace(':', '_') }}">
{% if a.icon_url %}
<img src="{{ a.icon_url }}" alt="" class="w-12 h-12 rounded-full">
{% else %}
<div class="w-12 h-12 rounded-full bg-stone-300 flex items-center justify-center text-stone-600 font-bold">
{{ (a.display_name or a.preferred_username)[0] | upper }}
</div>
{% endif %}
<div class="flex-1 min-w-0">
{% if list_type == "following" and a.id %}
<a href="{{ url_for('ap_social.actor_timeline', id=a.id) }}" class="font-semibold text-stone-900 hover:underline">
{{ a.display_name or a.preferred_username }}
</a>
{% else %}
<a href="https://{{ a.domain }}/@{{ a.preferred_username }}" target="_blank" rel="noopener" class="font-semibold text-stone-900 hover:underline">
{{ a.display_name or a.preferred_username }}
</a>
{% endif %}
<div class="text-sm text-stone-500">@{{ a.preferred_username }}@{{ a.domain }}</div>
{% if a.summary %}
<div class="text-sm text-stone-600 mt-1 truncate">{{ a.summary | striptags }}</div>
{% endif %}
</div>
{% if actor %}
<div class="flex-shrink-0">
{% if list_type == "following" or a.actor_url in (followed_urls or []) %}
<form method="post" action="{{ url_for('ap_social.unfollow') }}"
sx-post="{{ url_for('ap_social.unfollow') }}"
sx-target="closest article"
sx-swap="outerHTML">
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
<input type="hidden" name="actor_url" value="{{ a.actor_url }}">
<button type="submit" class="text-sm border border-stone-300 rounded px-3 py-1 hover:bg-stone-100">
Unfollow
</button>
</form>
{% else %}
<form method="post" action="{{ url_for('ap_social.follow') }}"
sx-post="{{ url_for('ap_social.follow') }}"
sx-target="closest article"
sx-swap="outerHTML">
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
<input type="hidden" name="actor_url" value="{{ a.actor_url }}">
<button type="submit" class="text-sm bg-stone-800 text-white rounded px-3 py-1 hover:bg-stone-700">
Follow Back
</button>
</form>
{% endif %}
</div>
{% endif %}
</article>
{% endfor %}
{% if actors | length >= 20 %}
<div sx-get="{{ url_for('ap_social.' ~ list_type ~ '_list_page', page=page + 1) }}"
sx-trigger="revealed"
sx-swap="outerHTML">
</div>
{% endif %}

View File

@@ -1,53 +0,0 @@
<article class="bg-white rounded-lg shadow-sm border border-stone-200 p-4 mb-4">
{% if item.boosted_by %}
<div class="text-sm text-stone-500 mb-2">
Boosted by {{ item.boosted_by }}
</div>
{% endif %}
<div class="flex items-start gap-3">
{% if item.actor_icon %}
<img src="{{ item.actor_icon }}" alt="" class="w-10 h-10 rounded-full">
{% else %}
<div class="w-10 h-10 rounded-full bg-stone-300 flex items-center justify-center text-stone-600 font-bold text-sm">
{{ item.actor_name[0] | upper if item.actor_name else '?' }}
</div>
{% endif %}
<div class="flex-1 min-w-0">
<div class="flex items-baseline gap-2">
<span class="font-semibold text-stone-900">{{ item.actor_name }}</span>
<span class="text-sm text-stone-500">
@{{ item.actor_username }}{% if item.actor_domain %}@{{ item.actor_domain }}{% endif %}
</span>
<span class="text-sm text-stone-400 ml-auto">
{% if item.published %}
{{ item.published.strftime('%b %d, %H:%M') }}
{% endif %}
</span>
</div>
{% if item.summary %}
<details class="mt-2">
<summary class="text-stone-500 cursor-pointer">CW: {{ item.summary }}</summary>
<div class="mt-2 prose prose-sm prose-stone max-w-none">{{ item.content | safe }}</div>
</details>
{% else %}
<div class="mt-2 prose prose-sm prose-stone max-w-none">{{ item.content | safe }}</div>
{% endif %}
<div class="mt-2 flex gap-3 text-sm text-stone-400">
{% if item.url and item.post_type == "remote" %}
<a href="{{ item.url }}" target="_blank" rel="noopener" class="hover:underline">
original
</a>
{% endif %}
{% if item.object_id %}
<a href="{{ federation_url('/social/') }}" class="hover:underline">
View on Hub
</a>
{% endif %}
</div>
</div>
</div>
</article>

View File

@@ -1,61 +0,0 @@
{% for a in actors %}
<article class="bg-white rounded-lg shadow-sm border border-stone-200 p-4 mb-3 flex items-center gap-4"
id="actor-{{ a.actor_url | replace('/', '_') | replace(':', '_') }}">
{% if a.icon_url %}
<img src="{{ a.icon_url }}" alt="" class="w-12 h-12 rounded-full">
{% else %}
<div class="w-12 h-12 rounded-full bg-stone-300 flex items-center justify-center text-stone-600 font-bold">
{{ (a.display_name or a.preferred_username)[0] | upper }}
</div>
{% endif %}
<div class="flex-1 min-w-0">
{% if a.id %}
<a href="{{ url_for('ap_social.actor_timeline', id=a.id) }}" class="font-semibold text-stone-900 hover:underline">
{{ a.display_name or a.preferred_username }}
</a>
{% else %}
<span class="font-semibold text-stone-900">{{ a.display_name or a.preferred_username }}</span>
{% endif %}
<div class="text-sm text-stone-500">@{{ a.preferred_username }}@{{ a.domain }}</div>
{% if a.summary %}
<div class="text-sm text-stone-600 mt-1 truncate">{{ a.summary | striptags }}</div>
{% endif %}
</div>
{% if actor %}
<div class="flex-shrink-0">
{% if a.actor_url in (followed_urls or []) %}
<form method="post" action="{{ url_for('ap_social.unfollow') }}"
sx-post="{{ url_for('ap_social.unfollow') }}"
sx-target="closest article"
sx-swap="outerHTML">
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
<input type="hidden" name="actor_url" value="{{ a.actor_url }}">
<button type="submit" class="text-sm border border-stone-300 rounded px-3 py-1 hover:bg-stone-100">
Unfollow
</button>
</form>
{% else %}
<form method="post" action="{{ url_for('ap_social.follow') }}"
sx-post="{{ url_for('ap_social.follow') }}"
sx-target="closest article"
sx-swap="outerHTML">
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
<input type="hidden" name="actor_url" value="{{ a.actor_url }}">
<button type="submit" class="text-sm bg-stone-800 text-white rounded px-3 py-1 hover:bg-stone-700">
Follow
</button>
</form>
{% endif %}
</div>
{% endif %}
</article>
{% endfor %}
{% if actors | length >= 20 %}
<div sx-get="{{ url_for('ap_social.search_page', q=query, page=page + 1) }}"
sx-trigger="revealed"
sx-swap="outerHTML">
</div>
{% endif %}

View File

@@ -1,13 +0,0 @@
{% for item in items %}
{% include "social/_post_card.html" %}
{% endfor %}
{% if items %}
{% set last = items[-1] %}
{% if timeline_type == "actor" %}
<div sx-get="{{ url_for('ap_social.actor_timeline_page', id=actor_id, before=last.published.isoformat()) }}"
sx-trigger="revealed"
sx-swap="outerHTML">
</div>
{% endif %}
{% endif %}

View File

@@ -1,53 +0,0 @@
{% extends "_types/social_lite/index.html" %}
{% block title %}{{ remote_actor.display_name or remote_actor.preferred_username }} — Rose Ash{% endblock %}
{% block social_content %}
<div class="bg-white rounded-lg shadow-sm border border-stone-200 p-6 mb-6">
<div class="flex items-center gap-4">
{% if remote_actor.icon_url %}
<img src="{{ remote_actor.icon_url }}" alt="" class="w-16 h-16 rounded-full">
{% else %}
<div class="w-16 h-16 rounded-full bg-stone-300 flex items-center justify-center text-stone-600 font-bold text-xl">
{{ (remote_actor.display_name or remote_actor.preferred_username)[0] | upper }}
</div>
{% endif %}
<div class="flex-1">
<h1 class="text-xl font-bold">{{ remote_actor.display_name or remote_actor.preferred_username }}</h1>
<div class="text-stone-500">@{{ remote_actor.preferred_username }}@{{ remote_actor.domain }}</div>
{% if remote_actor.summary %}
<div class="text-sm text-stone-600 mt-2">{{ remote_actor.summary | safe }}</div>
{% endif %}
</div>
{% if actor %}
<div class="flex-shrink-0">
{% if is_following %}
<form method="post" action="{{ url_for('ap_social.unfollow') }}">
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
<input type="hidden" name="actor_url" value="{{ remote_actor.actor_url }}">
<button type="submit" class="border border-stone-300 rounded px-4 py-2 hover:bg-stone-100">
Unfollow
</button>
</form>
{% else %}
<form method="post" action="{{ url_for('ap_social.follow') }}">
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
<input type="hidden" name="actor_url" value="{{ remote_actor.actor_url }}">
<button type="submit" class="bg-stone-800 text-white rounded px-4 py-2 hover:bg-stone-700">
Follow
</button>
</form>
{% endif %}
</div>
{% endif %}
</div>
</div>
<div id="timeline">
{% set timeline_type = "actor" %}
{% set actor_id = remote_actor.id %}
{% include "social/_timeline_items.html" %}
</div>
{% endblock %}

View File

@@ -1,12 +0,0 @@
{% extends "_types/social_lite/index.html" %}
{% block title %}Followers — Rose Ash{% endblock %}
{% block social_content %}
<h1 class="text-2xl font-bold mb-6">Followers <span class="text-stone-400 font-normal">({{ total }})</span></h1>
<div id="actor-list">
{% set list_type = "followers" %}
{% include "social/_actor_list_items.html" %}
</div>
{% endblock %}

View File

@@ -1,13 +0,0 @@
{% extends "_types/social_lite/index.html" %}
{% block title %}Following — Rose Ash{% endblock %}
{% block social_content %}
<h1 class="text-2xl font-bold mb-6">Following <span class="text-stone-400 font-normal">({{ total }})</span></h1>
<div id="actor-list">
{% set list_type = "following" %}
{% set followed_urls = [] %}
{% include "social/_actor_list_items.html" %}
</div>
{% endblock %}

View File

@@ -1,33 +0,0 @@
{% extends "_types/social_lite/index.html" %}
{% block title %}Social — Rose Ash{% endblock %}
{% block social_content %}
<h1 class="text-2xl font-bold mb-6">Social</h1>
{% if actor %}
<div class="space-y-3">
<a href="{{ url_for('ap_social.search') }}" class="block bg-white rounded-lg shadow-sm border border-stone-200 p-4 hover:bg-stone-50">
<div class="font-semibold">Search</div>
<div class="text-sm text-stone-500">Find and follow accounts on the fediverse</div>
</a>
<a href="{{ url_for('ap_social.following_list') }}" class="block bg-white rounded-lg shadow-sm border border-stone-200 p-4 hover:bg-stone-50">
<div class="font-semibold">Following</div>
<div class="text-sm text-stone-500">Accounts you follow</div>
</a>
<a href="{{ url_for('ap_social.followers_list') }}" class="block bg-white rounded-lg shadow-sm border border-stone-200 p-4 hover:bg-stone-50">
<div class="font-semibold">Followers</div>
<div class="text-sm text-stone-500">Accounts following you here</div>
</a>
<a href="{{ federation_url('/social/') }}" class="block bg-white rounded-lg shadow-sm border border-stone-200 p-4 hover:bg-stone-50">
<div class="font-semibold">Hub</div>
<div class="text-sm text-stone-500">Full social experience — timeline, compose, notifications</div>
</a>
</div>
{% else %}
<p class="text-stone-500">
<a href="{{ url_for('ap_social.search') }}" class="underline">Search</a> for accounts on the fediverse, or visit the
<a href="{{ federation_url('/social/') }}" class="underline">Hub</a> to get started.
</p>
{% endif %}
{% endblock %}

View File

@@ -1,32 +0,0 @@
{% extends "_types/social_lite/index.html" %}
{% block title %}Search — Rose Ash{% endblock %}
{% block social_content %}
<h1 class="text-2xl font-bold mb-6">Search</h1>
<form method="get" action="{{ url_for('ap_social.search') }}" class="mb-6"
sx-get="{{ url_for('ap_social.search_page') }}"
sx-target="#search-results"
sx-push-url="{{ url_for('ap_social.search') }}">
<div class="flex gap-2">
<input type="text" name="q" value="{{ query }}"
class="flex-1 border border-stone-300 rounded-lg px-4 py-2 focus:outline-none focus:ring-2 focus:ring-stone-500"
placeholder="Search users or @user@instance.tld">
<button type="submit"
class="bg-stone-800 text-white px-6 py-2 rounded hover:bg-stone-700">
Search
</button>
</div>
</form>
{% if query and total %}
<p class="text-sm text-stone-500 mb-4">{{ total }} result{{ 's' if total != 1 }} for <strong>{{ query }}</strong></p>
{% elif query %}
<p class="text-stone-500 mb-4">No results found for <strong>{{ query }}</strong></p>
{% endif %}
<div id="search-results">
{% include "social/_search_results.html" %}
</div>
{% endblock %}

View File

@@ -57,6 +57,77 @@ def _is_aggregate(app_name: str) -> bool:
return app_name == "federation" return app_name == "federation"
async def _render_profile_sx(actor, activities, total):
"""Render the federation actor profile page using SX."""
from markupsafe import escape
from shared.sx.page import get_template_context
from shared.sx.helpers import full_page_sx, oob_page_sx, sx_response
from shared.browser.app.utils.htmx import is_htmx_request
from shared.config import config
def _e(v):
s = str(v) if v else ""
return str(escape(s)).replace('"', '\\"')
username = _e(actor.preferred_username)
display_name = _e(actor.display_name or actor.preferred_username)
ap_domain = config().get("ap_domain", "rose-ash.com")
summary_el = ""
if actor.summary:
summary_el = f'(p :class "mt-2" "{_e(actor.summary)}")'
activity_items = []
for a in activities:
ts = ""
if a.published:
ts = a.published.strftime("%Y-%m-%d %H:%M")
obj_el = ""
if a.object_type:
obj_el = f'(span :class "text-sm text-stone-500" "{_e(a.object_type)}")'
activity_items.append(
f'(div :class "bg-white rounded-lg shadow p-4"'
f' (div :class "flex justify-between items-start"'
f' (span :class "font-medium" "{_e(a.activity_type)}")'
f' (span :class "text-sm text-stone-400" "{_e(ts)}"))'
f' {obj_el})')
if activities:
activities_el = ('(div :class "space-y-4" ' +
" ".join(activity_items) + ")")
else:
activities_el = '(p :class "text-stone-500" "No activities yet.")'
content = (
f'(div :id "main-panel"'
f' (div :class "py-8"'
f' (div :class "bg-white rounded-lg shadow p-6 mb-6"'
f' (h1 :class "text-2xl font-bold" "{display_name}")'
f' (p :class "text-stone-500" "@{username}@{_e(ap_domain)}")'
f' {summary_el})'
f' (h2 :class "text-xl font-bold mb-4" "Activities ({total})")'
f' {activities_el}))')
tctx = await get_template_context()
if is_htmx_request():
# Import federation layout for OOB headers
try:
from federation.sxc.pages import _social_oob
oob_headers = _social_oob(tctx)
except ImportError:
oob_headers = ""
return sx_response(oob_page_sx(oobs=oob_headers, content=content))
else:
try:
from federation.sxc.pages import _social_full
header_rows = _social_full(tctx)
except ImportError:
from shared.sx.helpers import root_header_sx
header_rows = root_header_sx(tctx)
return full_page_sx(tctx, header_rows=header_rows, content=content)
def create_activitypub_blueprint(app_name: str) -> Blueprint: def create_activitypub_blueprint(app_name: str) -> Blueprint:
"""Return a Blueprint with AP endpoints for *app_name*.""" """Return a Blueprint with AP endpoints for *app_name*."""
bp = Blueprint("activitypub", __name__) bp = Blueprint("activitypub", __name__)
@@ -272,16 +343,10 @@ def create_activitypub_blueprint(app_name: str) -> Blueprint:
# HTML: federation renders its own profile; other apps redirect there # HTML: federation renders its own profile; other apps redirect there
if aggregate: if aggregate:
from quart import render_template
activities, total = await services.federation.get_outbox( activities, total = await services.federation.get_outbox(
g._ap_s, username, page=1, per_page=20, g._ap_s, username, page=1, per_page=20,
) )
return await render_template( return await _render_profile_sx(actor, activities, total)
"federation/profile.html",
actor=actor,
activities=activities,
total=total,
)
from quart import redirect from quart import redirect
return redirect(f"https://{fed_domain}/users/{username}") return redirect(f"https://{fed_domain}/users/{username}")

View File

@@ -2,13 +2,15 @@
Lightweight social UI for blog/market/events. Federation keeps the full Lightweight social UI for blog/market/events. Federation keeps the full
social hub (timeline, compose, notifications, interactions). social hub (timeline, compose, notifications, interactions).
All rendering uses s-expressions (no Jinja templates).
""" """
from __future__ import annotations from __future__ import annotations
import logging import logging
from datetime import datetime from datetime import datetime
from quart import Blueprint, request, g, redirect, url_for, abort, render_template, Response from quart import Blueprint, request, g, redirect, url_for, abort, Response
from shared.services.registry import services from shared.services.registry import services
@@ -77,15 +79,36 @@ def create_ap_social_blueprint(app_name: str) -> Blueprint:
abort(403, "You need to choose a federation username first") abort(403, "You need to choose a federation username first")
return actor return actor
async def _render_social_page(content: str, actor=None, title: str = "Social"):
"""Render a full social page or OOB response depending on request type."""
from shared.browser.app.utils.htmx import is_htmx_request
from shared.sx.page import get_template_context
from shared.sx.helpers import full_page_sx, oob_page_sx, sx_response
from shared.infrastructure.ap_social_sx import (
_social_full_headers, _social_oob_headers,
)
tctx = await get_template_context()
kw = {"actor": actor}
if is_htmx_request():
oob_headers = _social_oob_headers(tctx, **kw)
return sx_response(oob_page_sx(
oobs=oob_headers,
content=content,
))
else:
header_rows = _social_full_headers(tctx, **kw)
return full_page_sx(tctx, header_rows=header_rows, content=content)
# -- Index ---------------------------------------------------------------- # -- Index ----------------------------------------------------------------
@bp.get("/") @bp.get("/")
async def index(): async def index():
actor = getattr(g, "_social_actor", None) actor = getattr(g, "_social_actor", None)
return await render_template( from shared.infrastructure.ap_social_sx import social_index_content_sx
"social/index.html", content = social_index_content_sx(actor)
actor=actor, return await _render_social_page(content, actor, title="Social")
)
# -- Search --------------------------------------------------------------- # -- Search ---------------------------------------------------------------
@@ -103,15 +126,9 @@ def create_ap_social_blueprint(app_name: str) -> Blueprint:
g._ap_s, actor.preferred_username, page=1, per_page=1000, g._ap_s, actor.preferred_username, page=1, per_page=1000,
) )
followed_urls = {a.actor_url for a in following} followed_urls = {a.actor_url for a in following}
return await render_template( from shared.infrastructure.ap_social_sx import social_search_content_sx
"social/search.html", content = social_search_content_sx(query, actors, total, 1, followed_urls, actor)
query=query, return await _render_social_page(content, actor, title="Search")
actors=actors,
total=total,
page=1,
followed_urls=followed_urls,
actor=actor,
)
@bp.get("/search/page") @bp.get("/search/page")
async def search_page(): async def search_page():
@@ -130,15 +147,10 @@ def create_ap_social_blueprint(app_name: str) -> Blueprint:
g._ap_s, actor.preferred_username, page=1, per_page=1000, g._ap_s, actor.preferred_username, page=1, per_page=1000,
) )
followed_urls = {a.actor_url for a in following} followed_urls = {a.actor_url for a in following}
return await render_template( from shared.infrastructure.ap_social_sx import search_results_sx
"social/_search_results.html", from shared.sx.helpers import sx_response
actors=actors, content = search_results_sx(actors, total, page, query, followed_urls, actor)
total=total, return sx_response(content)
page=page,
query=query,
followed_urls=followed_urls,
actor=actor,
)
# -- Follow / Unfollow ---------------------------------------------------- # -- Follow / Unfollow ----------------------------------------------------
@@ -169,7 +181,7 @@ def create_ap_social_blueprint(app_name: str) -> Blueprint:
return redirect(request.referrer or url_for("ap_social.search")) return redirect(request.referrer or url_for("ap_social.search"))
async def _actor_card_response(actor, remote_actor_url, is_followed): async def _actor_card_response(actor, remote_actor_url, is_followed):
"""Re-render a single actor card after follow/unfollow via HTMX.""" """Re-render a single actor card after follow/unfollow."""
remote_dto = await services.federation.get_or_fetch_remote_actor( remote_dto = await services.federation.get_or_fetch_remote_actor(
g._ap_s, remote_actor_url, g._ap_s, remote_actor_url,
) )
@@ -181,15 +193,12 @@ def create_ap_social_blueprint(app_name: str) -> Blueprint:
list_type = "followers" list_type = "followers"
else: else:
list_type = "following" list_type = "following"
return await render_template( from shared.infrastructure.ap_social_sx import actor_list_items_sx
"social/_actor_list_items.html", from shared.sx.helpers import sx_response
actors=[remote_dto], content = actor_list_items_sx(
total=0, [remote_dto], 0, 1, list_type, followed_urls, actor,
page=1,
list_type=list_type,
followed_urls=followed_urls,
actor=actor,
) )
return sx_response(content)
# -- Followers ------------------------------------------------------------ # -- Followers ------------------------------------------------------------
@@ -203,14 +212,9 @@ def create_ap_social_blueprint(app_name: str) -> Blueprint:
g._ap_s, actor.preferred_username, page=1, per_page=1000, g._ap_s, actor.preferred_username, page=1, per_page=1000,
) )
followed_urls = {a.actor_url for a in following} followed_urls = {a.actor_url for a in following}
return await render_template( from shared.infrastructure.ap_social_sx import social_followers_content_sx
"social/followers.html", content = social_followers_content_sx(actors, total, 1, followed_urls, actor)
actors=actors, return await _render_social_page(content, actor, title="Followers")
total=total,
page=1,
followed_urls=followed_urls,
actor=actor,
)
@bp.get("/followers/page") @bp.get("/followers/page")
async def followers_list_page(): async def followers_list_page():
@@ -223,15 +227,10 @@ def create_ap_social_blueprint(app_name: str) -> Blueprint:
g._ap_s, actor.preferred_username, page=1, per_page=1000, g._ap_s, actor.preferred_username, page=1, per_page=1000,
) )
followed_urls = {a.actor_url for a in following} followed_urls = {a.actor_url for a in following}
return await render_template( from shared.infrastructure.ap_social_sx import actor_list_items_sx
"social/_actor_list_items.html", from shared.sx.helpers import sx_response
actors=actors, content = actor_list_items_sx(actors, total, page, "followers", followed_urls, actor)
total=total, return sx_response(content)
page=page,
list_type="followers",
followed_urls=followed_urls,
actor=actor,
)
# -- Following ------------------------------------------------------------ # -- Following ------------------------------------------------------------
@@ -241,13 +240,9 @@ def create_ap_social_blueprint(app_name: str) -> Blueprint:
actors, total = await services.federation.get_following( actors, total = await services.federation.get_following(
g._ap_s, actor.preferred_username, g._ap_s, actor.preferred_username,
) )
return await render_template( from shared.infrastructure.ap_social_sx import social_following_content_sx
"social/following.html", content = social_following_content_sx(actors, total, 1, actor)
actors=actors, return await _render_social_page(content, actor, title="Following")
total=total,
page=1,
actor=actor,
)
@bp.get("/following/page") @bp.get("/following/page")
async def following_list_page(): async def following_list_page():
@@ -256,15 +251,10 @@ def create_ap_social_blueprint(app_name: str) -> Blueprint:
actors, total = await services.federation.get_following( actors, total = await services.federation.get_following(
g._ap_s, actor.preferred_username, page=page, g._ap_s, actor.preferred_username, page=page,
) )
return await render_template( from shared.infrastructure.ap_social_sx import actor_list_items_sx
"social/_actor_list_items.html", from shared.sx.helpers import sx_response
actors=actors, content = actor_list_items_sx(actors, total, page, "following", set(), actor)
total=total, return sx_response(content)
page=page,
list_type="following",
followed_urls=set(),
actor=actor,
)
# -- Actor timeline ------------------------------------------------------- # -- Actor timeline -------------------------------------------------------
@@ -295,13 +285,9 @@ def create_ap_social_blueprint(app_name: str) -> Blueprint:
) )
).scalar_one_or_none() ).scalar_one_or_none()
is_following = existing is not None is_following = existing is not None
return await render_template( from shared.infrastructure.ap_social_sx import social_actor_timeline_content_sx
"social/actor_timeline.html", content = social_actor_timeline_content_sx(remote_dto, items, is_following, actor)
remote_actor=remote_dto, return await _render_social_page(content, actor)
items=items,
is_following=is_following,
actor=actor,
)
@bp.get("/actor/<int:id>/timeline") @bp.get("/actor/<int:id>/timeline")
async def actor_timeline_page(id: int): async def actor_timeline_page(id: int):
@@ -316,12 +302,9 @@ def create_ap_social_blueprint(app_name: str) -> Blueprint:
items = await services.federation.get_actor_timeline( items = await services.federation.get_actor_timeline(
g._ap_s, id, before=before, g._ap_s, id, before=before,
) )
return await render_template( from shared.infrastructure.ap_social_sx import timeline_items_sx
"social/_timeline_items.html", from shared.sx.helpers import sx_response
items=items, content = timeline_items_sx(items, "actor", id, actor)
timeline_type="actor", return sx_response(content)
actor_id=id,
actor=actor,
)
return bp return bp

View File

@@ -0,0 +1,593 @@
"""SX content builders for the per-app AP social blueprint.
Builds s-expression source strings for all social pages, replacing
the Jinja templates in shared/browser/templates/social/.
All dynamic values (URLs, CSRF tokens) are resolved server-side in Python
and embedded as string literals — the SX is rendered client-side where
server primitives like url-for and csrf-token are unavailable.
"""
from __future__ import annotations
from typing import Any
from markupsafe import escape
from shared.sx.helpers import (
sx_call, root_header_sx, oob_header_sx,
mobile_menu_sx, mobile_root_nav_sx, full_page_sx, oob_page_sx,
)
from shared.sx.parser import SxExpr
# ---------------------------------------------------------------------------
# Layout — "social-lite": root header + social nav row
# ---------------------------------------------------------------------------
def setup_social_layout() -> None:
"""Register the social-lite layout. Called once during app startup."""
from shared.sx.layouts import register_custom_layout
register_custom_layout(
"social-lite",
_social_full_headers,
_social_oob_headers,
_social_mobile,
)
def _social_nav_items(actor: Any) -> str:
"""Build the social nav items as sx source.
All URLs resolved server-side via Quart's url_for.
"""
from quart import url_for
from shared.infrastructure.urls import app_url
search_url = _e(url_for("ap_social.search"))
hub_url = _e(app_url("federation", "/social/"))
parts: list[str] = []
if actor:
following_url = _e(url_for("ap_social.following_list"))
followers_url = _e(url_for("ap_social.followers_list"))
username = _e(getattr(actor, "preferred_username", ""))
try:
profile_url = _e(url_for("activitypub.actor_profile",
username=actor.preferred_username))
except Exception:
profile_url = ""
parts.append(f'(a :href "{search_url}"'
f' :class "px-2 py-1 rounded hover:bg-stone-200 text-sm" "Search")')
parts.append(f'(a :href "{following_url}"'
f' :class "px-2 py-1 rounded hover:bg-stone-200 text-sm" "Following")')
parts.append(f'(a :href "{followers_url}"'
f' :class "px-2 py-1 rounded hover:bg-stone-200 text-sm" "Followers")')
if profile_url:
parts.append(f'(a :href "{profile_url}"'
f' :class "px-2 py-1 rounded hover:bg-stone-200 text-sm"'
f' "@{username}")')
parts.append(f'(a :href "{hub_url}"'
f' :class "px-2 py-1 rounded hover:bg-stone-200 text-sm text-stone-500"'
f' "Hub")')
else:
parts.append(f'(a :href "{search_url}"'
f' :class "px-2 py-1 rounded hover:bg-stone-200 text-sm" "Search")')
parts.append(f'(a :href "{hub_url}"'
f' :class "px-2 py-1 rounded hover:bg-stone-200 text-sm text-stone-500"'
f' "Hub")')
return " ".join(parts)
def _social_header_row(actor: Any) -> str:
"""Build the social nav header row as sx source."""
nav = _social_nav_items(actor)
return (
f'(div :id "social-lite-header-child"'
f' :class "flex flex-col items-center md:flex-row justify-center'
f' md:justify-between w-full p-1 bg-stone-300"'
f' (div :class "w-full flex flex-row items-center gap-2 flex-wrap"'
f' (nav :class "flex gap-3 text-sm items-center flex-wrap" {nav})))'
)
def _social_full_headers(ctx: dict, **kw: Any) -> str:
root_hdr = root_header_sx(ctx)
actor = kw.get("actor")
social_row = _social_header_row(actor)
return "(<> " + root_hdr + " " + social_row + ")"
def _social_oob_headers(ctx: dict, **kw: Any) -> str:
root_hdr = root_header_sx(ctx)
actor = kw.get("actor")
social_row = _social_header_row(actor)
rows = "(<> " + root_hdr + " " + social_row + ")"
return oob_header_sx("root-header-child", "social-lite-header-child", rows)
def _social_mobile(ctx: dict, **kw: Any) -> str:
return mobile_menu_sx(mobile_root_nav_sx(ctx))
# ---------------------------------------------------------------------------
# Shared helpers
# ---------------------------------------------------------------------------
def _e(val: Any) -> str:
"""Escape a value for safe embedding in sx source strings."""
s = str(val) if val else ""
return str(escape(s)).replace('"', '\\"')
def _esc_raw(html: str) -> str:
"""Escape raw HTML for embedding as a string literal in sx.
The string will be passed to (raw! ...) so it should NOT be HTML-escaped,
only the sx string delimiters need escaping.
"""
return html.replace("\\", "\\\\").replace('"', '\\"')
def _actor_initial(a: Any) -> str:
"""Get the uppercase first character of an actor's display name or username."""
name = _actor_name(a)
return name[0].upper() if name else "?"
def _actor_name(a: Any) -> str:
"""Get display name or preferred username from an actor (DTO or dict)."""
if isinstance(a, dict):
return a.get("display_name") or a.get("preferred_username") or ""
return getattr(a, "display_name", None) or getattr(a, "preferred_username", "") or ""
def _attr(a: Any, key: str, default: str = "") -> Any:
"""Get attribute from DTO or dict."""
if isinstance(a, dict):
return a.get(key, default)
return getattr(a, key, default)
def _strip_tags(s: str) -> str:
import re
return re.sub(r"<[^>]+>", "", s)
def _csrf() -> str:
"""Get the CSRF token as a string."""
from quart import current_app
fn = current_app.jinja_env.globals.get("csrf_token")
if callable(fn):
return str(fn())
return ""
# ---------------------------------------------------------------------------
# Actor card — used in search results, followers, following
# ---------------------------------------------------------------------------
def _actor_card_sx(a: Any, followed_urls: set, actor: Any,
list_type: str = "search") -> str:
"""Build sx source for a single actor card."""
from quart import url_for
actor_url = _attr(a, "actor_url", "")
safe_id = actor_url.replace("/", "_").replace(":", "_")
icon_url = _attr(a, "icon_url", "")
display_name = _actor_name(a)
username = _attr(a, "preferred_username", "")
domain = _attr(a, "domain", "")
summary = _attr(a, "summary", "")
actor_id = _attr(a, "id")
csrf = _e(_csrf())
# Avatar
if icon_url:
avatar = f'(img :src "{_e(icon_url)}" :alt "" :class "w-12 h-12 rounded-full")'
else:
initial = _actor_initial(a)
avatar = (f'(div :class "w-12 h-12 rounded-full bg-stone-300 flex items-center'
f' justify-center text-stone-600 font-bold" "{initial}")')
# Name link
if (list_type in ("following", "search")) and actor_id:
tl_url = _e(url_for("ap_social.actor_timeline", id=actor_id))
name_el = (f'(a :href "{tl_url}"'
f' :class "font-semibold text-stone-900 hover:underline"'
f' "{_e(display_name)}")')
else:
name_el = (f'(a :href "https://{_e(domain)}/@{_e(username)}"'
f' :target "_blank" :rel "noopener"'
f' :class "font-semibold text-stone-900 hover:underline"'
f' "{_e(display_name)}")')
handle = f'(div :class "text-sm text-stone-500" "@{_e(username)}@{_e(domain)}")'
# Summary
summary_el = ""
if summary:
clean = _strip_tags(summary)
summary_el = (f'(div :class "text-sm text-stone-600 mt-1 truncate"'
f' "{_e(clean)}")')
# Follow/unfollow button
button_el = ""
if actor:
is_followed = (list_type == "following" or actor_url in (followed_urls or set()))
if is_followed:
unfollow_url = _e(url_for("ap_social.unfollow"))
button_el = (
f'(div :class "flex-shrink-0"'
f' (form :method "post" :action "{unfollow_url}"'
f' :sx-post "{unfollow_url}"'
f' :sx-target "closest article" :sx-swap "outerHTML"'
f' (input :type "hidden" :name "csrf_token" :value "{csrf}")'
f' (input :type "hidden" :name "actor_url" :value "{_e(actor_url)}")'
f' (button :type "submit"'
f' :class "text-sm border border-stone-300 rounded px-3 py-1 hover:bg-stone-100"'
f' "Unfollow")))')
else:
follow_url = _e(url_for("ap_social.follow"))
label = "Follow Back" if list_type == "followers" else "Follow"
button_el = (
f'(div :class "flex-shrink-0"'
f' (form :method "post" :action "{follow_url}"'
f' :sx-post "{follow_url}"'
f' :sx-target "closest article" :sx-swap "outerHTML"'
f' (input :type "hidden" :name "csrf_token" :value "{csrf}")'
f' (input :type "hidden" :name "actor_url" :value "{_e(actor_url)}")'
f' (button :type "submit"'
f' :class "text-sm bg-stone-800 text-white rounded px-3 py-1 hover:bg-stone-700"'
f' "{label}")))')
return (
f'(article :class "bg-white rounded-lg shadow-sm border border-stone-200'
f' p-4 mb-3 flex items-center gap-4" :id "actor-{_e(safe_id)}"'
f' {avatar}'
f' (div :class "flex-1 min-w-0" {name_el} {handle} {summary_el})'
f' {button_el})'
)
# ---------------------------------------------------------------------------
# Actor list items — paginated fragment
# ---------------------------------------------------------------------------
def actor_list_items_sx(actors: list, total: int, page: int,
list_type: str, followed_urls: set, actor: Any) -> str:
"""Build sx source for a list of actor cards with pagination sentinel."""
from quart import url_for
parts = [_actor_card_sx(a, followed_urls, actor, list_type) for a in actors]
# Infinite scroll sentinel
if len(actors) >= 20:
next_page = page + 1
ep = f"ap_social.{list_type}_list_page"
next_url = _e(url_for(ep, page=next_page))
parts.append(
f'(div :sx-get "{next_url}"'
f' :sx-trigger "revealed" :sx-swap "outerHTML")')
return "(<> " + " ".join(parts) + ")" if parts else '""'
# ---------------------------------------------------------------------------
# Search results — paginated fragment
# ---------------------------------------------------------------------------
def search_results_sx(actors: list, total: int, page: int,
query: str, followed_urls: set, actor: Any) -> str:
"""Build sx source for search results with pagination sentinel."""
from quart import url_for
parts = [_actor_card_sx(a, followed_urls, actor, "search") for a in actors]
if len(actors) >= 20:
next_page = page + 1
next_url = _e(url_for("ap_social.search_page", q=query, page=next_page))
parts.append(
f'(div :sx-get "{next_url}"'
f' :sx-trigger "revealed" :sx-swap "outerHTML")')
return "(<> " + " ".join(parts) + ")" if parts else '""'
# ---------------------------------------------------------------------------
# Post card — timeline item
# ---------------------------------------------------------------------------
def _post_card_sx(item: Any) -> str:
"""Build sx source for a single post/status card."""
from shared.infrastructure.urls import app_url
actor_name = _attr(item, "actor_name", "")
actor_username = _attr(item, "actor_username", "")
actor_domain = _attr(item, "actor_domain", "")
actor_icon = _attr(item, "actor_icon", "")
content = _attr(item, "content", "")
summary = _attr(item, "summary", "")
published = _attr(item, "published")
boosted_by = _attr(item, "boosted_by", "")
url = _attr(item, "url", "")
object_id = _attr(item, "object_id", "")
post_type = _attr(item, "post_type", "")
boost_el = ""
if boosted_by:
boost_el = (f'(div :class "text-sm text-stone-500 mb-2"'
f' "Boosted by {_e(boosted_by)}")')
# Avatar
if actor_icon:
avatar = f'(img :src "{_e(actor_icon)}" :alt "" :class "w-10 h-10 rounded-full")'
else:
initial = actor_name[0].upper() if actor_name else "?"
avatar = (f'(div :class "w-10 h-10 rounded-full bg-stone-300 flex items-center'
f' justify-center text-stone-600 font-bold text-sm" "{initial}")')
# Handle
handle_text = f"@{_e(actor_username)}"
if actor_domain:
handle_text += f"@{_e(actor_domain)}"
# Timestamp
time_el = ""
if published:
if hasattr(published, "strftime"):
ts = published.strftime("%b %d, %H:%M")
else:
ts = str(published)
time_el = f'(span :class "text-sm text-stone-400 ml-auto" "{_e(ts)}")'
# Content — raw HTML from AP, render with raw!
if summary:
content_el = (
f'(details :class "mt-2"'
f' (summary :class "text-stone-500 cursor-pointer" "CW: {_e(summary)}")'
f' (div :class "mt-2 prose prose-sm prose-stone max-w-none"'
f' (raw! "{_esc_raw(content)}")))')
else:
content_el = (
f'(div :class "mt-2 prose prose-sm prose-stone max-w-none"'
f' (raw! "{_esc_raw(content)}"))')
# Links
links: list[str] = []
if url and post_type == "remote":
links.append(
f'(a :href "{_e(url)}" :target "_blank" :rel "noopener"'
f' :class "hover:underline" "original")')
if object_id:
hub_url = _e(app_url("federation", "/social/"))
links.append(
f'(a :href "{hub_url}"'
f' :class "hover:underline" "View on Hub")')
links_el = ""
if links:
links_el = ('(div :class "mt-2 flex gap-3 text-sm text-stone-400" '
+ " ".join(links) + ")")
return (
f'(article :class "bg-white rounded-lg shadow-sm border border-stone-200 p-4 mb-4"'
f' {boost_el}'
f' (div :class "flex items-start gap-3"'
f' {avatar}'
f' (div :class "flex-1 min-w-0"'
f' (div :class "flex items-baseline gap-2"'
f' (span :class "font-semibold text-stone-900" "{_e(actor_name)}")'
f' (span :class "text-sm text-stone-500" "{handle_text}")'
f' {time_el})'
f' {content_el}'
f' {links_el})))'
)
# ---------------------------------------------------------------------------
# Timeline items — paginated fragment
# ---------------------------------------------------------------------------
def timeline_items_sx(items: list, timeline_type: str = "",
actor_id: int | None = None, actor: Any = None) -> str:
"""Build sx source for timeline items with infinite scroll sentinel."""
from quart import url_for
parts = [_post_card_sx(item) for item in items]
if items and timeline_type == "actor" and actor_id:
last = items[-1]
published = _attr(last, "published")
if published and hasattr(published, "isoformat"):
before = published.isoformat()
else:
before = str(published) if published else ""
if before:
next_url = _e(url_for("ap_social.actor_timeline_page",
id=actor_id, before=before))
parts.append(
f'(div :sx-get "{next_url}"'
f' :sx-trigger "revealed" :sx-swap "outerHTML")')
return "(<> " + " ".join(parts) + ")" if parts else '""'
# ---------------------------------------------------------------------------
# Full page content builders
# ---------------------------------------------------------------------------
def social_index_content_sx(actor: Any) -> str:
"""Build sx source for the social index page content."""
from quart import url_for
from shared.infrastructure.urls import app_url
search_url = _e(url_for("ap_social.search"))
hub_url = _e(app_url("federation", "/social/"))
if actor:
following_url = _e(url_for("ap_social.following_list"))
followers_url = _e(url_for("ap_social.followers_list"))
return (
f'(div :id "main-panel"'
f' (h1 :class "text-2xl font-bold mb-6" "Social")'
f' (div :class "space-y-3"'
f' (a :href "{search_url}"'
f' :class "block bg-white rounded-lg shadow-sm border border-stone-200 p-4 hover:bg-stone-50"'
f' (div :class "font-semibold" "Search")'
f' (div :class "text-sm text-stone-500" "Find and follow accounts on the fediverse"))'
f' (a :href "{following_url}"'
f' :class "block bg-white rounded-lg shadow-sm border border-stone-200 p-4 hover:bg-stone-50"'
f' (div :class "font-semibold" "Following")'
f' (div :class "text-sm text-stone-500" "Accounts you follow"))'
f' (a :href "{followers_url}"'
f' :class "block bg-white rounded-lg shadow-sm border border-stone-200 p-4 hover:bg-stone-50"'
f' (div :class "font-semibold" "Followers")'
f' (div :class "text-sm text-stone-500" "Accounts following you here"))'
f' (a :href "{hub_url}"'
f' :class "block bg-white rounded-lg shadow-sm border border-stone-200 p-4 hover:bg-stone-50"'
f' (div :class "font-semibold" "Hub")'
f' (div :class "text-sm text-stone-500"'
f' "Full social experience \\u2014 timeline, compose, notifications"))))')
else:
return (
f'(div :id "main-panel"'
f' (h1 :class "text-2xl font-bold mb-6" "Social")'
f' (p :class "text-stone-500"'
f' (a :href "{search_url}" :class "underline" "Search")'
f' " for accounts on the fediverse, or visit the "'
f' (a :href "{hub_url}" :class "underline" "Hub")'
f' " to get started."))')
def social_search_content_sx(query: str, actors: list, total: int,
page: int, followed_urls: set, actor: Any) -> str:
"""Build sx source for the search page content."""
from quart import url_for
search_url = _e(url_for("ap_social.search"))
search_page_url = _e(url_for("ap_social.search_page"))
# Results message
msg = ""
if query and total:
s = "s" if total != 1 else ""
msg = (f'(p :class "text-sm text-stone-500 mb-4"'
f' "{total} result{s} for " (strong "{_e(query)}"))')
elif query:
msg = (f'(p :class "text-stone-500 mb-4"'
f' "No results found for " (strong "{_e(query)}"))')
results = search_results_sx(actors, total, page, query, followed_urls, actor)
return (
f'(div :id "main-panel"'
f' (h1 :class "text-2xl font-bold mb-6" "Search")'
f' (form :method "get" :action "{search_url}"'
f' :sx-get "{search_page_url}"'
f' :sx-target "#search-results"'
f' :sx-push-url "{search_url}"'
f' :class "mb-6"'
f' (div :class "flex gap-2"'
f' (input :type "text" :name "q" :value "{_e(query)}"'
f' :class "flex-1 border border-stone-300 rounded-lg px-4 py-2'
f' focus:outline-none focus:ring-2 focus:ring-stone-500"'
f' :placeholder "Search users or @user@instance.tld")'
f' (button :type "submit"'
f' :class "bg-stone-800 text-white px-6 py-2 rounded hover:bg-stone-700"'
f' "Search")))'
f' {msg}'
f' (div :id "search-results" {results}))'
)
def social_followers_content_sx(actors: list, total: int, page: int,
followed_urls: set, actor: Any) -> str:
"""Build sx source for the followers page content."""
items = actor_list_items_sx(actors, total, page, "followers", followed_urls, actor)
return (
f'(div :id "main-panel"'
f' (h1 :class "text-2xl font-bold mb-6" "Followers "'
f' (span :class "text-stone-400 font-normal" "({total})"))'
f' (div :id "actor-list" {items}))'
)
def social_following_content_sx(actors: list, total: int,
page: int, actor: Any) -> str:
"""Build sx source for the following page content."""
items = actor_list_items_sx(actors, total, page, "following", set(), actor)
return (
f'(div :id "main-panel"'
f' (h1 :class "text-2xl font-bold mb-6" "Following "'
f' (span :class "text-stone-400 font-normal" "({total})"))'
f' (div :id "actor-list" {items}))'
)
def social_actor_timeline_content_sx(remote_actor: Any, items: list,
is_following: bool, actor: Any) -> str:
"""Build sx source for the actor timeline page content."""
from quart import url_for
ra = remote_actor
display_name = _actor_name(ra)
username = _attr(ra, "preferred_username", "")
domain = _attr(ra, "domain", "")
icon_url = _attr(ra, "icon_url", "")
summary = _attr(ra, "summary", "")
actor_url = _attr(ra, "actor_url", "")
ra_id = _attr(ra, "id")
csrf = _e(_csrf())
# Avatar
if icon_url:
avatar = f'(img :src "{_e(icon_url)}" :alt "" :class "w-16 h-16 rounded-full")'
else:
initial = display_name[0].upper() if display_name else "?"
avatar = (f'(div :class "w-16 h-16 rounded-full bg-stone-300 flex items-center'
f' justify-center text-stone-600 font-bold text-xl" "{initial}")')
# Summary — raw HTML from AP
summary_el = ""
if summary:
summary_el = (f'(div :class "text-sm text-stone-600 mt-2"'
f' (raw! "{_esc_raw(summary)}"))')
# Follow/unfollow button
button_el = ""
if actor:
if is_following:
unfollow_url = _e(url_for("ap_social.unfollow"))
button_el = (
f'(div :class "flex-shrink-0"'
f' (form :method "post" :action "{unfollow_url}"'
f' (input :type "hidden" :name "csrf_token" :value "{csrf}")'
f' (input :type "hidden" :name "actor_url" :value "{_e(actor_url)}")'
f' (button :type "submit"'
f' :class "border border-stone-300 rounded px-4 py-2 hover:bg-stone-100"'
f' "Unfollow")))')
else:
follow_url = _e(url_for("ap_social.follow"))
button_el = (
f'(div :class "flex-shrink-0"'
f' (form :method "post" :action "{follow_url}"'
f' (input :type "hidden" :name "csrf_token" :value "{csrf}")'
f' (input :type "hidden" :name "actor_url" :value "{_e(actor_url)}")'
f' (button :type "submit"'
f' :class "bg-stone-800 text-white rounded px-4 py-2 hover:bg-stone-700"'
f' "Follow")))')
tl = timeline_items_sx(items, "actor", ra_id, actor)
return (
f'(div :id "main-panel"'
f' (div :class "bg-white rounded-lg shadow-sm border border-stone-200 p-6 mb-6"'
f' (div :class "flex items-center gap-4"'
f' {avatar}'
f' (div :class "flex-1"'
f' (h1 :class "text-xl font-bold" "{_e(display_name)}")'
f' (div :class "text-stone-500" "@{_e(username)}@{_e(domain)}")'
f' {summary_el})'
f' {button_el}))'
f' (div :id "timeline" {tl}))'
)

View File

@@ -160,6 +160,8 @@ def create_base_app(
# Auto-register per-app social blueprint (not federation — it has its own) # Auto-register per-app social blueprint (not federation — it has its own)
if name in AP_APPS and name != "federation": if name in AP_APPS and name != "federation":
from shared.infrastructure.ap_social import create_ap_social_blueprint from shared.infrastructure.ap_social import create_ap_social_blueprint
from shared.infrastructure.ap_social_sx import setup_social_layout
setup_social_layout()
app.register_blueprint(create_ap_social_blueprint(name)) app.register_blueprint(create_ap_social_blueprint(name))
# --- device id (all apps, including account) --- # --- device id (all apps, including account) ---

View File

@@ -19,6 +19,7 @@ Usage::
from __future__ import annotations from __future__ import annotations
import inspect
from typing import Any from typing import Any
from .types import Component, Keyword, Lambda, Macro, NIL, Symbol from .types import Component, Keyword, Lambda, Macro, NIL, Symbol
@@ -114,7 +115,10 @@ async def async_eval(expr: Any, env: dict[str, Any], ctx: RequestContext) -> Any
args = [await async_eval(a, env, ctx) for a in expr[1:]] args = [await async_eval(a, env, ctx) for a in expr[1:]]
if callable(fn) and not isinstance(fn, (Lambda, Component)): if callable(fn) and not isinstance(fn, (Lambda, Component)):
return fn(*args) result = fn(*args)
if inspect.iscoroutine(result):
return await result
return result
if isinstance(fn, Lambda): if isinstance(fn, Lambda):
return await _async_call_lambda(fn, args, env, ctx) return await _async_call_lambda(fn, args, env, ctx)
if isinstance(fn, Component): if isinstance(fn, Component):
@@ -369,6 +373,8 @@ async def _asf_thread_first(expr, env, ctx):
args = [result] args = [result]
if callable(fn) and not isinstance(fn, (Lambda, Component)): if callable(fn) and not isinstance(fn, (Lambda, Component)):
result = fn(*args) result = fn(*args)
if inspect.iscoroutine(result):
result = await result
elif isinstance(fn, Lambda): elif isinstance(fn, Lambda):
result = await _async_call_lambda(fn, args, env, ctx) result = await _async_call_lambda(fn, args, env, ctx)
else: else:
@@ -418,7 +424,8 @@ async def _aho_map(expr, env, ctx):
if isinstance(fn, Lambda): if isinstance(fn, Lambda):
results.append(await _async_call_lambda(fn, [item], env, ctx)) results.append(await _async_call_lambda(fn, [item], env, ctx))
elif callable(fn): elif callable(fn):
results.append(fn(item)) r = fn(item)
results.append(await r if inspect.iscoroutine(r) else r)
else: else:
raise EvalError(f"map requires callable, got {type(fn).__name__}") raise EvalError(f"map requires callable, got {type(fn).__name__}")
return results return results
@@ -432,7 +439,8 @@ async def _aho_map_indexed(expr, env, ctx):
if isinstance(fn, Lambda): if isinstance(fn, Lambda):
results.append(await _async_call_lambda(fn, [i, item], env, ctx)) results.append(await _async_call_lambda(fn, [i, item], env, ctx))
elif callable(fn): elif callable(fn):
results.append(fn(i, item)) r = fn(i, item)
results.append(await r if inspect.iscoroutine(r) else r)
else: else:
raise EvalError(f"map-indexed requires callable, got {type(fn).__name__}") raise EvalError(f"map-indexed requires callable, got {type(fn).__name__}")
return results return results
@@ -447,6 +455,8 @@ async def _aho_filter(expr, env, ctx):
val = await _async_call_lambda(fn, [item], env, ctx) val = await _async_call_lambda(fn, [item], env, ctx)
elif callable(fn): elif callable(fn):
val = fn(item) val = fn(item)
if inspect.iscoroutine(val):
val = await val
else: else:
raise EvalError(f"filter requires callable, got {type(fn).__name__}") raise EvalError(f"filter requires callable, got {type(fn).__name__}")
if val: if val:
@@ -459,7 +469,12 @@ async def _aho_reduce(expr, env, ctx):
acc = await async_eval(expr[2], env, ctx) acc = await async_eval(expr[2], env, ctx)
coll = await async_eval(expr[3], env, ctx) coll = await async_eval(expr[3], env, ctx)
for item in coll: for item in coll:
acc = await _async_call_lambda(fn, [acc, item], env, ctx) if isinstance(fn, Lambda) else fn(acc, item) if isinstance(fn, Lambda):
acc = await _async_call_lambda(fn, [acc, item], env, ctx)
else:
acc = fn(acc, item)
if inspect.iscoroutine(acc):
acc = await acc
return acc return acc
@@ -467,7 +482,12 @@ async def _aho_some(expr, env, ctx):
fn = await async_eval(expr[1], env, ctx) fn = await async_eval(expr[1], env, ctx)
coll = await async_eval(expr[2], env, ctx) coll = await async_eval(expr[2], env, ctx)
for item in coll: for item in coll:
result = await _async_call_lambda(fn, [item], env, ctx) if isinstance(fn, Lambda) else fn(item) if isinstance(fn, Lambda):
result = await _async_call_lambda(fn, [item], env, ctx)
else:
result = fn(item)
if inspect.iscoroutine(result):
result = await result
if result: if result:
return result return result
return NIL return NIL
@@ -477,7 +497,13 @@ async def _aho_every(expr, env, ctx):
fn = await async_eval(expr[1], env, ctx) fn = await async_eval(expr[1], env, ctx)
coll = await async_eval(expr[2], env, ctx) coll = await async_eval(expr[2], env, ctx)
for item in coll: for item in coll:
if not (await _async_call_lambda(fn, [item], env, ctx) if isinstance(fn, Lambda) else fn(item)): if isinstance(fn, Lambda):
val = await _async_call_lambda(fn, [item], env, ctx)
else:
val = fn(item)
if inspect.iscoroutine(val):
val = await val
if not val:
return False return False
return True return True
@@ -489,7 +515,9 @@ async def _aho_for_each(expr, env, ctx):
if isinstance(fn, Lambda): if isinstance(fn, Lambda):
await _async_call_lambda(fn, [item], env, ctx) await _async_call_lambda(fn, [item], env, ctx)
elif callable(fn): elif callable(fn):
fn(item) r = fn(item)
if inspect.iscoroutine(r):
await r
return NIL return NIL
@@ -782,7 +810,10 @@ async def _arsf_map(expr, env, ctx):
if isinstance(fn, Lambda): if isinstance(fn, Lambda):
parts.append(await _arender_lambda(fn, (item,), env, ctx)) parts.append(await _arender_lambda(fn, (item,), env, ctx))
elif callable(fn): elif callable(fn):
parts.append(await _arender(fn(item), env, ctx)) r = fn(item)
if inspect.iscoroutine(r):
r = await r
parts.append(await _arender(r, env, ctx))
else: else:
parts.append(await _arender(item, env, ctx)) parts.append(await _arender(item, env, ctx))
return "".join(parts) return "".join(parts)
@@ -796,7 +827,10 @@ async def _arsf_map_indexed(expr, env, ctx):
if isinstance(fn, Lambda): if isinstance(fn, Lambda):
parts.append(await _arender_lambda(fn, (i, item), env, ctx)) parts.append(await _arender_lambda(fn, (i, item), env, ctx))
elif callable(fn): elif callable(fn):
parts.append(await _arender(fn(i, item), env, ctx)) r = fn(i, item)
if inspect.iscoroutine(r):
r = await r
parts.append(await _arender(r, env, ctx))
else: else:
parts.append(await _arender(item, env, ctx)) parts.append(await _arender(item, env, ctx))
return "".join(parts) return "".join(parts)
@@ -815,7 +849,10 @@ async def _arsf_for_each(expr, env, ctx):
if isinstance(fn, Lambda): if isinstance(fn, Lambda):
parts.append(await _arender_lambda(fn, (item,), env, ctx)) parts.append(await _arender_lambda(fn, (item,), env, ctx))
elif callable(fn): elif callable(fn):
parts.append(await _arender(fn(item), env, ctx)) r = fn(item)
if inspect.iscoroutine(r):
r = await r
parts.append(await _arender(r, env, ctx))
else: else:
parts.append(await _arender(item, env, ctx)) parts.append(await _arender(item, env, ctx))
return "".join(parts) return "".join(parts)
@@ -956,7 +993,10 @@ async def _aser(expr: Any, env: dict[str, Any], ctx: RequestContext) -> Any:
args = [await async_eval(a, env, ctx) for a in expr[1:]] args = [await async_eval(a, env, ctx) for a in expr[1:]]
if callable(fn) and not isinstance(fn, (Lambda, Component)): if callable(fn) and not isinstance(fn, (Lambda, Component)):
return fn(*args) result = fn(*args)
if inspect.iscoroutine(result):
return await result
return result
if isinstance(fn, Lambda): if isinstance(fn, Lambda):
return await _async_call_lambda(fn, args, env, ctx) return await _async_call_lambda(fn, args, env, ctx)
if isinstance(fn, Component): if isinstance(fn, Component):
@@ -1151,7 +1191,8 @@ async def _asho_ser_map(expr, env, ctx):
local[p] = v local[p] = v
results.append(await _aser(fn.body, local, ctx)) results.append(await _aser(fn.body, local, ctx))
elif callable(fn): elif callable(fn):
results.append(fn(item)) r = fn(item)
results.append(await r if inspect.iscoroutine(r) else r)
else: else:
raise EvalError(f"map requires callable, got {type(fn).__name__}") raise EvalError(f"map requires callable, got {type(fn).__name__}")
return results return results
@@ -1169,7 +1210,8 @@ async def _asho_ser_map_indexed(expr, env, ctx):
local[fn.params[1]] = item local[fn.params[1]] = item
results.append(await _aser(fn.body, local, ctx)) results.append(await _aser(fn.body, local, ctx))
elif callable(fn): elif callable(fn):
results.append(fn(i, item)) r = fn(i, item)
results.append(await r if inspect.iscoroutine(r) else r)
else: else:
raise EvalError(f"map-indexed requires callable, got {type(fn).__name__}") raise EvalError(f"map-indexed requires callable, got {type(fn).__name__}")
return results return results
@@ -1191,7 +1233,8 @@ async def _asho_ser_for_each(expr, env, ctx):
local[fn.params[0]] = item local[fn.params[0]] = item
results.append(await _aser(fn.body, local, ctx)) results.append(await _aser(fn.body, local, ctx))
elif callable(fn): elif callable(fn):
results.append(fn(item)) r = fn(item)
results.append(await r if inspect.iscoroutine(r) else r)
return results return results

View File

@@ -290,6 +290,18 @@ async def execute_page(
# Blueprint mounting # Blueprint mounting
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
def auto_mount_pages(app: Any, service_name: str) -> None:
"""Auto-mount all registered defpages for a service directly on the app.
Pages must have absolute paths (from the service URL root).
Called once per service in app.py after setup_*_pages().
"""
pages = get_all_pages(service_name)
for page_def in pages.values():
_mount_one_page(app, service_name, page_def)
logger.info("Auto-mounted %d defpages for %s", len(pages), service_name)
def mount_pages(bp: Any, service_name: str, def mount_pages(bp: Any, service_name: str,
names: set[str] | list[str] | None = None) -> None: names: set[str] | list[str] | None = None) -> None:
"""Mount registered PageDef routes onto a Quart Blueprint. """Mount registered PageDef routes onto a Quart Blueprint.

View File

@@ -257,6 +257,24 @@ def prim_split(s: str, sep: str = " ") -> list[str]:
def prim_join(sep: str, coll: list) -> str: def prim_join(sep: str, coll: list) -> str:
return sep.join(str(x) for x in coll) return sep.join(str(x) for x in coll)
@register_primitive("replace")
def prim_replace(s: str, old: str, new: str) -> str:
return s.replace(old, new)
@register_primitive("strip-tags")
def prim_strip_tags(s: str) -> str:
"""Strip HTML tags from a string."""
import re
return re.sub(r"<[^>]+>", "", s)
@register_primitive("slice")
def prim_slice(coll: Any, start: int, end: Any = None) -> Any:
"""Slice a string or list: (slice coll start end?)."""
start = int(start)
if end is None or end is NIL:
return coll[start:]
return coll[start:int(end)]
@register_primitive("starts-with?") @register_primitive("starts-with?")
def prim_starts_with(s, prefix: str) -> bool: def prim_starts_with(s, prefix: str) -> bool:
if not isinstance(s, str): if not isinstance(s, str):

View File

@@ -41,6 +41,7 @@ IO_PRIMITIVES: frozenset[str] = frozenset({
"nav-tree", "nav-tree",
"get-children", "get-children",
"g", "g",
"csrf-token",
}) })
@@ -314,6 +315,17 @@ async def _io_g(
return getattr(g, key, None) return getattr(g, key, None)
async def _io_csrf_token(
args: list[Any], kwargs: dict[str, Any], ctx: RequestContext
) -> str:
"""``(csrf-token)`` → current CSRF token string."""
from quart import current_app
csrf = current_app.jinja_env.globals.get("csrf_token")
if callable(csrf):
return csrf()
return ""
_IO_HANDLERS: dict[str, Any] = { _IO_HANDLERS: dict[str, Any] = {
"frag": _io_frag, "frag": _io_frag,
"query": _io_query, "query": _io_query,
@@ -326,4 +338,5 @@ _IO_HANDLERS: dict[str, Any] = {
"nav-tree": _io_nav_tree, "nav-tree": _io_nav_tree,
"get-children": _io_get_children, "get-children": _io_get_children,
"g": _io_g, "g": _io_g,
"csrf-token": _io_csrf_token,
} }

View File

@@ -54,12 +54,11 @@ def create_app() -> "Quart":
setup_sx_pages() setup_sx_pages()
bp = register_pages(url_prefix="/") bp = register_pages(url_prefix="/")
from shared.sx.pages import mount_pages
mount_pages(bp, "sx")
app.register_blueprint(bp) app.register_blueprint(bp)
from shared.sx.pages import auto_mount_pages
auto_mount_pages(app, "sx")
return app return app

View File

@@ -709,4 +709,177 @@ def register(url_prefix: str = "/") -> Blueprint:
oob_comp = _oob_code("retry-comp", comp_text) oob_comp = _oob_code("retry-comp", comp_text)
return sx_response(f'(<> {sx_src} {oob_wire} {oob_comp})') return sx_response(f'(<> {sx_src} {oob_wire} {oob_comp})')
# ------------------------------------------------------------------
# Reference attribute detail API endpoints (for live demos)
# ------------------------------------------------------------------
def _ref_wire(wire_id: str, sx_src: str) -> str:
"""Build OOB swap showing the wire response text."""
from sxc.sx_components import _oob_code
return _oob_code(f"ref-wire-{wire_id}", sx_src)
@bp.get("/reference/api/time")
async def ref_time():
from shared.sx.helpers import sx_response
now = datetime.now().strftime("%H:%M:%S")
sx_src = f'(span :class "text-stone-800 text-sm" "Server time: " (strong "{now}"))'
oob = _ref_wire("sx-get", sx_src)
return sx_response(f'(<> {sx_src} {oob})')
@csrf_exempt
@bp.post("/reference/api/greet")
async def ref_greet():
from shared.sx.helpers import sx_response
form = await request.form
name = form.get("name") or "stranger"
sx_src = f'(span :class "text-stone-800 text-sm" "Hello, " (strong "{name}") "!")'
oob = _ref_wire("sx-post", sx_src)
return sx_response(f'(<> {sx_src} {oob})')
@csrf_exempt
@bp.put("/reference/api/status")
async def ref_status():
from shared.sx.helpers import sx_response
form = await request.form
status = form.get("status", "unknown")
sx_src = f'(span :class "text-stone-700 text-sm" "Status: " (strong "{status}") " — updated via PUT")'
oob = _ref_wire("sx-put", sx_src)
return sx_response(f'(<> {sx_src} {oob})')
@csrf_exempt
@bp.patch("/reference/api/theme")
async def ref_theme():
from shared.sx.helpers import sx_response
form = await request.form
theme = form.get("theme", "unknown")
sx_src = f'"{theme}"'
oob = _ref_wire("sx-patch", sx_src)
return sx_response(f'(<> {sx_src} {oob})')
@csrf_exempt
@bp.delete("/reference/api/item/<item_id>")
async def ref_delete(item_id: str):
from shared.sx.helpers import sx_response
oob = _ref_wire("sx-delete", '""')
return sx_response(f'(<> {oob})')
@bp.get("/reference/api/trigger-search")
async def ref_trigger_search():
from shared.sx.helpers import sx_response
q = request.args.get("q", "")
if not q:
sx_src = '(span :class "text-stone-400 text-sm" "Start typing to trigger a search.")'
else:
sx_src = f'(span :class "text-stone-800 text-sm" "Results for: " (strong "{q}"))'
oob = _ref_wire("sx-trigger", sx_src)
return sx_response(f'(<> {sx_src} {oob})')
@bp.get("/reference/api/swap-item")
async def ref_swap_item():
from shared.sx.helpers import sx_response
now = datetime.now().strftime("%H:%M:%S")
sx_src = f'(div :class "text-sm text-violet-700" "New item (" "{now}" ")")'
oob = _ref_wire("sx-swap", sx_src)
return sx_response(f'(<> {sx_src} {oob})')
@bp.get("/reference/api/oob")
async def ref_oob():
from shared.sx.helpers import sx_response
now = datetime.now().strftime("%H:%M:%S")
sx_src = (
f'(<>'
f' (span :class "text-emerald-700 text-sm" "Main updated at " "{now}")'
f' (div :id "ref-oob-side" :sx-swap-oob "innerHTML"'
f' (span :class "text-violet-700 text-sm" "OOB updated at " "{now}")))')
oob = _ref_wire("sx-swap-oob", sx_src)
return sx_response(f'(<> {sx_src} {oob})')
@bp.get("/reference/api/select-page")
async def ref_select_page():
from shared.sx.helpers import sx_response
now = datetime.now().strftime("%H:%M:%S")
sx_src = (
f'(<>'
f' (div :id "the-header" (h3 "Page header — not selected"))'
f' (div :id "the-content"'
f' (span :class "text-emerald-700 text-sm"'
f' "This fragment was selected from a larger response. Time: " "{now}"))'
f' (div :id "the-footer" (p "Page footer — not selected")))')
oob = _ref_wire("sx-select", sx_src)
return sx_response(f'(<> {sx_src} {oob})')
@bp.get("/reference/api/slow-echo")
async def ref_slow_echo():
from shared.sx.helpers import sx_response
await asyncio.sleep(0.8)
q = request.args.get("q", "")
sx_src = f'(span :class "text-stone-800 text-sm" "Echo: " (strong "{q}"))'
oob = _ref_wire("sx-sync", sx_src)
return sx_response(f'(<> {sx_src} {oob})')
@csrf_exempt
@bp.post("/reference/api/upload-name")
async def ref_upload_name():
from shared.sx.helpers import sx_response
files = await request.files
f = files.get("file")
name = f.filename if f else "(no file)"
sx_src = f'(span :class "text-stone-800 text-sm" "Received: " (strong "{name}"))'
oob = _ref_wire("sx-encoding", sx_src)
return sx_response(f'(<> {sx_src} {oob})')
@bp.get("/reference/api/echo-headers")
async def ref_echo_headers():
from shared.sx.helpers import sx_response
custom = [(k, v) for k, v in request.headers if k.lower().startswith("x-")]
if not custom:
sx_src = '(span :class "text-stone-400 text-sm" "No custom headers received.")'
else:
items = " ".join(
f'(li (strong "{k}") ": " "{v}")' for k, v in custom)
sx_src = f'(ul :class "text-sm text-stone-700 space-y-1" {items})'
oob = _ref_wire("sx-headers", sx_src)
return sx_response(f'(<> {sx_src} {oob})')
@bp.get("/reference/api/echo-vals")
async def ref_echo_vals_get():
from shared.sx.helpers import sx_response
vals = list(request.args.items())
if not vals:
sx_src = '(span :class "text-stone-400 text-sm" "No values received.")'
else:
items = " ".join(
f'(li (strong "{k}") ": " "{v}")' for k, v in vals)
sx_src = f'(ul :class "text-sm text-stone-700 space-y-1" {items})'
oob_include = _ref_wire("sx-include", sx_src)
return sx_response(f'(<> {sx_src} {oob_include})')
@csrf_exempt
@bp.post("/reference/api/echo-vals")
async def ref_echo_vals_post():
from shared.sx.helpers import sx_response
form = await request.form
vals = list(form.items())
if not vals:
sx_src = '(span :class "text-stone-400 text-sm" "No values received.")'
else:
items = " ".join(
f'(li (strong "{k}") ": " "{v}")' for k, v in vals)
sx_src = f'(ul :class "text-sm text-stone-700 space-y-1" {items})'
oob = _ref_wire("sx-vals", sx_src)
return sx_response(f'(<> {sx_src} {oob})')
_ref_flaky = {"n": 0}
@bp.get("/reference/api/flaky")
async def ref_flaky():
from shared.sx.helpers import sx_response
_ref_flaky["n"] += 1
n = _ref_flaky["n"]
if n % 3 != 0:
return Response("", status=503, content_type="text/plain")
sx_src = f'(span :class "text-emerald-700 text-sm" "Success on attempt " "{n}" "!")'
oob = _ref_wire("sx-retry", sx_src)
return sx_response(f'(<> {sx_src} {oob})')
return bp return bp

View File

@@ -20,7 +20,7 @@ DOCS_NAV = [
] ]
REFERENCE_NAV = [ REFERENCE_NAV = [
("Attributes", "/reference/"), ("Attributes", "/reference/attributes"),
("Headers", "/reference/headers"), ("Headers", "/reference/headers"),
("Events", "/reference/events"), ("Events", "/reference/events"),
("JS API", "/reference/js-api"), ("JS API", "/reference/js-api"),
@@ -107,6 +107,7 @@ BEHAVIOR_ATTRS = [
("sx-vals", "Add values to the request as a JSON string", True), ("sx-vals", "Add values to the request as a JSON string", True),
("sx-media", "Only enable this element when the media query matches", True), ("sx-media", "Only enable this element when the media query matches", True),
("sx-disable", "Disable sx processing on this element and its children", True), ("sx-disable", "Disable sx processing on this element and its children", True),
("sx-on:*", "Inline event handler — e.g. sx-on:click runs JavaScript on event", True),
] ]
SX_UNIQUE_ATTRS = [ SX_UNIQUE_ATTRS = [
@@ -236,3 +237,481 @@ EDIT_ROW_DATA = [
{"id": "3", "name": "Widget C", "price": "12.00", "stock": "305"}, {"id": "3", "name": "Widget C", "price": "12.00", "stock": "305"},
{"id": "4", "name": "Widget D", "price": "45.00", "stock": "67"}, {"id": "4", "name": "Widget D", "price": "45.00", "stock": "67"},
] ]
# ---------------------------------------------------------------------------
# Reference: Attribute detail pages
# ---------------------------------------------------------------------------
ATTR_DETAILS: dict[str, dict] = {
# --- Request Attributes ---
"sx-get": {
"description": (
"Issues a GET request to the given URL when triggered. "
"The response HTML is swapped into the target element. "
"This is the most common sx attribute — use it for loading content, "
"navigation, and any read operation."
),
"demo": "ref-get-demo",
"example": (
'(button :sx-get "/reference/api/time"\n'
' :sx-target "#ref-get-result"\n'
' :sx-swap "innerHTML"\n'
' "Load server time")'
),
"handler": (
'(defhandler ref-time (&key)\n'
' (let ((now (format-time (now) "%H:%M:%S")))\n'
' (span :class "text-stone-800 text-sm"\n'
' "Server time: " (strong now))))'
),
},
"sx-post": {
"description": (
"Issues a POST request to the given URL. "
"Form values from the enclosing form (or sx-include target) are sent as the request body. "
"Use for creating resources, submitting forms, and any write operation."
),
"demo": "ref-post-demo",
"example": (
'(form :sx-post "/reference/api/greet"\n'
' :sx-target "#ref-post-result"\n'
' :sx-swap "innerHTML"\n'
' (input :type "text" :name "name"\n'
' :placeholder "Your name")\n'
' (button :type "submit" "Greet"))'
),
"handler": (
'(defhandler ref-greet (&key)\n'
' (let ((name (or (form-data "name") "stranger")))\n'
' (span :class "text-stone-800 text-sm"\n'
' "Hello, " (strong name) "!")))'
),
},
"sx-put": {
"description": (
"Issues a PUT request to the given URL. "
"Used for full replacement updates of a resource. "
"Form values are sent as the request body."
),
"demo": "ref-put-demo",
"example": (
'(button :sx-put "/reference/api/status"\n'
' :sx-target "#ref-put-view"\n'
' :sx-swap "innerHTML"\n'
' :sx-vals "{\\"status\\": \\"published\\"}"\n'
' "Publish")'
),
"handler": (
'(defhandler ref-status (&key)\n'
' (let ((status (or (form-data "status") "unknown")))\n'
' (span :class "text-stone-700 text-sm"\n'
' "Status: " (strong status) " — updated via PUT")))'
),
},
"sx-delete": {
"description": (
"Issues a DELETE request to the given URL. "
"Commonly paired with sx-confirm for a confirmation dialog, "
'and sx-swap "delete" to remove the element from the DOM.'
),
"demo": "ref-delete-demo",
"example": (
'(button :sx-delete "/reference/api/item/1"\n'
' :sx-target "#ref-del-1"\n'
' :sx-swap "delete"\n'
' "Remove")'
),
"handler": (
'(defhandler ref-delete (&key item-id)\n'
' ;; Empty response — swap "delete" removes the target\n'
' "")'
),
},
"sx-patch": {
"description": (
"Issues a PATCH request to the given URL. "
"Used for partial updates — only changed fields are sent. "
"Form values are sent as the request body."
),
"demo": "ref-patch-demo",
"example": (
'(button :sx-patch "/reference/api/theme"\n'
' :sx-vals "{\\"theme\\": \\"dark\\"}"\n'
' :sx-target "#ref-patch-val"\n'
' :sx-swap "innerHTML"\n'
' "Dark")'
),
"handler": (
'(defhandler ref-theme (&key)\n'
' (let ((theme (or (form-data "theme") "unknown")))\n'
' (str theme)))'
),
},
# --- Behavior Attributes ---
"sx-trigger": {
"description": (
"Specifies which DOM event triggers the request. "
"Defaults to 'click' for most elements and 'submit' for forms. "
"Supports modifiers: once, changed, delay:<time>, from:<selector>, "
"intersect, revealed, load, every:<time>. "
"Multiple triggers can be comma-separated."
),
"demo": "ref-trigger-demo",
"example": (
'(input :type "text" :name "q"\n'
' :placeholder "Type to search..."\n'
' :sx-get "/reference/api/trigger-search"\n'
' :sx-trigger "input changed delay:300ms"\n'
' :sx-target "#ref-trigger-result"\n'
' :sx-swap "innerHTML")'
),
"handler": (
'(defhandler ref-trigger-search (&key)\n'
' (let ((q (or (request-arg "q") "")))\n'
' (if (empty? q)\n'
' (span "Start typing to trigger a search.")\n'
' (span "Results for: " (strong q)))))'
),
},
"sx-target": {
"description": (
"CSS selector identifying which element receives the response content. "
'Defaults to the element itself. Use "closest <selector>" to find '
"the nearest ancestor matching the selector."
),
"demo": "ref-target-demo",
"example": (
';; Two buttons targeting different elements\n'
'(button :sx-get "/reference/api/time"\n'
' :sx-target "#ref-target-a"\n'
' :sx-swap "innerHTML"\n'
' "Update Box A")\n'
'\n'
'(button :sx-get "/reference/api/time"\n'
' :sx-target "#ref-target-b"\n'
' :sx-swap "innerHTML"\n'
' "Update Box B")'
),
},
"sx-swap": {
"description": (
"Controls how the response is swapped into the target element. "
"Values: innerHTML (default), outerHTML, afterend, beforeend, "
"afterbegin, beforebegin, delete, none."
),
"demo": "ref-swap-demo",
"example": (
';; Append to the end of a list\n'
'(button :sx-get "/reference/api/swap-item"\n'
' :sx-target "#ref-swap-list"\n'
' :sx-swap "beforeend"\n'
' "beforeend")\n'
'\n'
';; Prepend to the start\n'
'(button :sx-get "/reference/api/swap-item"\n'
' :sx-target "#ref-swap-list"\n'
' :sx-swap "afterbegin"\n'
' "afterbegin")'
),
"handler": (
'(defhandler ref-swap-item (&key)\n'
' (let ((now (format-time (now) "%H:%M:%S")))\n'
' (div :class "text-sm text-violet-700"\n'
' "New item (" now ")")))'
),
},
"sx-swap-oob": {
"description": (
"Out-of-band swap — updates elements elsewhere in the DOM by ID, "
"outside the normal target. The server includes extra elements in "
"the response with sx-swap-oob attributes, and they are swapped "
"into matching elements in the page."
),
"demo": "ref-oob-demo",
"example": (
'(button :sx-get "/reference/api/oob"\n'
' :sx-target "#ref-oob-main"\n'
' :sx-swap "innerHTML"\n'
' "Update both boxes")'
),
"handler": (
'(defhandler ref-oob (&key)\n'
' (let ((now (format-time (now) "%H:%M:%S")))\n'
' (<>\n'
' (span "Main updated at " now)\n'
' (div :id "ref-oob-side"\n'
' :sx-swap-oob "innerHTML"\n'
' (span "OOB updated at " now)))))'
),
},
"sx-select": {
"description": (
"CSS selector to pick a fragment from the response HTML. "
"Only the matching element is swapped into the target. "
"Useful for extracting part of a full-page response."
),
"demo": "ref-select-demo",
"example": (
'(button :sx-get "/reference/api/select-page"\n'
' :sx-target "#ref-select-result"\n'
' :sx-select "#the-content"\n'
' :sx-swap "innerHTML"\n'
' "Load (selecting #the-content)")'
),
"handler": (
'(defhandler ref-select-page (&key)\n'
' (let ((now (format-time (now) "%H:%M:%S")))\n'
' (<>\n'
' (div :id "the-header" (h3 "Page header — not selected"))\n'
' (div :id "the-content"\n'
' (span "Selected fragment. Time: " now))\n'
' (div :id "the-footer" (p "Page footer — not selected")))))'
),
},
"sx-confirm": {
"description": (
"Shows a browser confirmation dialog before issuing the request. "
"The request is cancelled if the user clicks Cancel. "
"The value is the message shown in the dialog."
),
"demo": "ref-confirm-demo",
"example": (
'(button :sx-delete "/reference/api/item/confirm"\n'
' :sx-target "#ref-confirm-item"\n'
' :sx-swap "delete"\n'
' :sx-confirm "Are you sure you want to delete this file?"\n'
' "Delete")'
),
},
"sx-push-url": {
"description": (
'Push the request URL into the browser location bar, enabling '
'back/forward navigation. Set to "true" to push the request URL, '
'or provide a custom URL string.'
),
"demo": "ref-pushurl-demo",
"example": (
'(a :href "/reference/attributes/sx-get"\n'
' :sx-get "/reference/attributes/sx-get"\n'
' :sx-target "#main-panel"\n'
' :sx-select "#main-panel"\n'
' :sx-swap "outerHTML"\n'
' :sx-push-url "true"\n'
' "sx-get page")'
),
},
"sx-sync": {
"description": (
"Controls synchronization of concurrent requests from the same element. "
'Strategies: "drop" (ignore new while in-flight), '
'"replace" (abort in-flight, send new), '
'"queue" (queue and send after current completes).'
),
"demo": "ref-sync-demo",
"example": (
'(input :type "text" :name "q"\n'
' :placeholder "Type quickly..."\n'
' :sx-get "/reference/api/slow-echo"\n'
' :sx-trigger "input changed delay:100ms"\n'
' :sx-sync "replace"\n'
' :sx-target "#ref-sync-result"\n'
' :sx-swap "innerHTML")'
),
"handler": (
'(defhandler ref-slow-echo (&key)\n'
' (sleep 800)\n'
' (let ((q (or (request-arg "q") "")))\n'
' (span "Echo: " (strong q))))'
),
},
"sx-encoding": {
"description": (
"Sets the encoding type for the request body. "
'Use "multipart/form-data" for file uploads. '
"Defaults to application/x-www-form-urlencoded for forms."
),
"demo": "ref-encoding-demo",
"example": (
'(form :sx-post "/reference/api/upload-name"\n'
' :sx-encoding "multipart/form-data"\n'
' :sx-target "#ref-encoding-result"\n'
' :sx-swap "innerHTML"\n'
' (input :type "file" :name "file")\n'
' (button :type "submit" "Upload"))'
),
"handler": (
'(defhandler ref-upload-name (&key)\n'
' (let ((name (or (file-name "file") "(no file)")))\n'
' (span "Received: " (strong name))))'
),
},
"sx-headers": {
"description": (
"Adds custom headers to the request as a JSON object string. "
"Useful for passing metadata like API keys or content types."
),
"demo": "ref-headers-demo",
"example": (
'(button :sx-get "/reference/api/echo-headers"\n'
' :sx-headers \'{"X-Custom-Token": "abc123", "X-Request-Source": "demo"}\'\n'
' :sx-target "#ref-headers-result"\n'
' :sx-swap "innerHTML"\n'
' "Send with custom headers")'
),
"handler": (
'(defhandler ref-echo-headers (&key)\n'
' (let ((headers (request-headers :prefix "X-")))\n'
' (if (empty? headers)\n'
' (span "No custom headers received.")\n'
' (ul (map (fn (h)\n'
' (li (strong (first h)) ": " (last h)))\n'
' headers)))))'
),
},
"sx-include": {
"description": (
"Include values from additional elements in the request. "
"Takes a CSS selector. The matched element's form values "
"(inputs, selects, textareas) are added to the request."
),
"demo": "ref-include-demo",
"example": (
'(select :id "ref-inc-cat" :name "category"\n'
' (option :value "all" "All")\n'
' (option :value "books" "Books")\n'
' (option :value "tools" "Tools"))\n'
'\n'
'(button :sx-get "/reference/api/echo-vals"\n'
' :sx-include "#ref-inc-cat"\n'
' :sx-target "#ref-include-result"\n'
' :sx-swap "innerHTML"\n'
' "Filter")'
),
"handler": (
'(defhandler ref-echo-vals (&key)\n'
' (let ((vals (request-args)))\n'
' (if (empty? vals)\n'
' (span "No values received.")\n'
' (ul (map (fn (v)\n'
' (li (strong (first v)) ": " (last v)))\n'
' vals)))))'
),
},
"sx-vals": {
"description": (
"Adds extra values to the request as a JSON object string. "
"These are merged with any form values. "
"Useful for passing additional data without hidden inputs."
),
"demo": "ref-vals-demo",
"example": (
'(button :sx-post "/reference/api/echo-vals"\n'
' :sx-vals \'{"source": "demo", "page": "3"}\'\n'
' :sx-target "#ref-vals-result"\n'
' :sx-swap "innerHTML"\n'
' "Send with extra values")'
),
},
"sx-media": {
"description": (
"Only enables the sx attributes on this element when the given "
"CSS media query matches. When the media query does not match, "
"the element behaves as a normal HTML element."
),
"demo": "ref-media-demo",
"example": (
'(a :href "/reference/attributes/sx-get"\n'
' :sx-get "/reference/attributes/sx-get"\n'
' :sx-target "#main-panel"\n'
' :sx-select "#main-panel"\n'
' :sx-swap "outerHTML"\n'
' :sx-push-url "true"\n'
' :sx-media "(min-width: 768px)"\n'
' "sx navigation (desktop only)")'
),
},
"sx-disable": {
"description": (
"Disables sx processing on this element and all its children. "
"The element renders as normal HTML without any sx behavior. "
"Useful for opting out of sx in specific subtrees."
),
"demo": "ref-disable-demo",
"example": (
';; Left box: sx works normally\n'
';; Right box: sx-disable prevents any sx behavior\n'
'(div :sx-disable "true"\n'
' (button :sx-get "/reference/api/time"\n'
' :sx-target "#ref-dis-b"\n'
' :sx-swap "innerHTML"\n'
' "Load")\n'
' ;; This button will NOT fire an sx request\n'
' )'
),
},
"sx-on:*": {
"description": (
"Inline event handler — attaches JavaScript to a DOM event. "
'The * is replaced by the event name (e.g. sx-on:click, sx-on:keydown). '
"The handler code runs as inline JavaScript with 'this' bound to the element."
),
"demo": "ref-on-demo",
"example": (
'(button\n'
' :sx-on:click "document.getElementById(\'ref-on-result\')\n'
' .textContent = \'Clicked at \' + new Date()\n'
' .toLocaleTimeString()"\n'
' "Click me")'
),
},
# --- Unique to sx ---
"sx-retry": {
"description": (
"Enables exponential backoff retry on request failure. "
'Set to "true" for default retry behavior (3 attempts, 1s/2s/4s delays) '
"or provide a custom retry count."
),
"demo": "ref-retry-demo",
"example": (
'(button :sx-get "/reference/api/flaky"\n'
' :sx-target "#ref-retry-result"\n'
' :sx-swap "innerHTML"\n'
' :sx-retry "true"\n'
' "Call flaky endpoint")'
),
"handler": (
'(defhandler ref-flaky (&key)\n'
' (let ((n (inc-counter "ref-flaky")))\n'
' (if (!= (mod n 3) 0)\n'
' (error 503)\n'
' (span "Success on attempt " n "!"))))'
),
},
"data-sx": {
"description": (
"Client-side rendering — evaluates the s-expression source in this "
"attribute and renders the result into the element. No server request "
"is made. Useful for purely client-side UI and interactive components."
),
"demo": "ref-data-sx-demo",
"example": (
'(div :data-sx "(div :class \\"p-3 bg-violet-50 rounded\\"\n'
' (h3 :class \\"font-semibold\\" \\"Client-rendered\\")\n'
' (p \\"Evaluated in the browser.\\")")'
),
},
"data-sx-env": {
"description": (
"Provides environment variables as a JSON object for data-sx rendering. "
"These values are available as variables in the s-expression."
),
"demo": "ref-data-sx-env-demo",
"example": (
'(div\n'
' :data-sx "(div (h3 title) (p message))"\n'
' :data-sx-env \'{"title": "Dynamic", "message": "From env"}\')'
),
},
}

View File

@@ -35,9 +35,15 @@
(map (fn (cell) (td :class "px-3 py-2 text-stone-700" cell)) row))) (map (fn (cell) (td :class "px-3 py-2 text-stone-700" cell)) row)))
rows))))) rows)))))
(defcomp ~doc-attr-row (&key attr description exists) (defcomp ~doc-attr-row (&key attr description exists href)
(tr :class "border-b border-stone-100" (tr :class "border-b border-stone-100"
(td :class "px-3 py-2 font-mono text-sm text-violet-700 whitespace-nowrap" attr) (td :class "px-3 py-2 font-mono text-sm whitespace-nowrap"
(if href
(a :href href
:sx-get href :sx-target "#main-panel" :sx-select "#main-panel"
:sx-swap "outerHTML" :sx-push-url "true"
:class "text-violet-700 hover:text-violet-900 underline" attr)
(span :class "text-violet-700" attr)))
(td :class "px-3 py-2 text-stone-700 text-sm" description) (td :class "px-3 py-2 text-stone-700 text-sm" description)
(td :class "px-3 py-2 text-center" (td :class "px-3 py-2 text-center"
(if exists (if exists

View File

@@ -0,0 +1,149 @@
;; SX reference — defhandler definitions for attribute detail demos
;;
;; These serve the live demos on the Reference > Attributes detail pages.
;; ---------------------------------------------------------------------------
;; Shared: return server time
;; ---------------------------------------------------------------------------
(defhandler ref-time (&key)
(let ((now (format-time (now) "%H:%M:%S")))
(span :class "text-stone-800 text-sm"
"Server time: " (strong now))))
;; ---------------------------------------------------------------------------
;; sx-post: greet
;; ---------------------------------------------------------------------------
(defhandler ref-greet (&key)
(let ((name (or (form-data "name") "stranger")))
(span :class "text-stone-800 text-sm"
"Hello, " (strong name) "!")))
;; ---------------------------------------------------------------------------
;; sx-put: update status
;; ---------------------------------------------------------------------------
(defhandler ref-status (&key)
(let ((status (or (form-data "status") "unknown")))
(span :class "text-stone-700 text-sm"
"Status: " (strong status) " — updated via PUT")))
;; ---------------------------------------------------------------------------
;; sx-patch: update theme
;; ---------------------------------------------------------------------------
(defhandler ref-theme (&key)
(let ((theme (or (form-data "theme") "unknown")))
(str theme)))
;; ---------------------------------------------------------------------------
;; sx-delete: remove item
;; ---------------------------------------------------------------------------
(defhandler ref-delete (&key item-id)
"")
;; ---------------------------------------------------------------------------
;; sx-trigger: search
;; ---------------------------------------------------------------------------
(defhandler ref-trigger-search (&key)
(let ((q (or (request-arg "q") "")))
(if (empty? q)
(span :class "text-stone-400 text-sm" "Start typing to trigger a search.")
(span :class "text-stone-800 text-sm"
"Results for: " (strong q)))))
;; ---------------------------------------------------------------------------
;; sx-swap: new item
;; ---------------------------------------------------------------------------
(defhandler ref-swap-item (&key)
(let ((now (format-time (now) "%H:%M:%S")))
(div :class "text-sm text-violet-700"
"New item (" now ")")))
;; ---------------------------------------------------------------------------
;; sx-swap-oob: update two targets
;; ---------------------------------------------------------------------------
(defhandler ref-oob (&key)
(let ((now (format-time (now) "%H:%M:%S")))
(<>
(span :class "text-emerald-700 text-sm"
"Main updated at " now)
(div :id "ref-oob-side" :sx-swap-oob "innerHTML"
(span :class "text-violet-700 text-sm"
"OOB updated at " now)))))
;; ---------------------------------------------------------------------------
;; sx-select: page with multiple sections
;; ---------------------------------------------------------------------------
(defhandler ref-select-page (&key)
(let ((now (format-time (now) "%H:%M:%S")))
(<>
(div :id "the-header"
(h3 "Page header — not selected"))
(div :id "the-content"
(span :class "text-emerald-700 text-sm"
"This fragment was selected from a larger response. Time: " now))
(div :id "the-footer"
(p "Page footer — not selected")))))
;; ---------------------------------------------------------------------------
;; sx-sync: slow echo
;; ---------------------------------------------------------------------------
(defhandler ref-slow-echo (&key)
(sleep 800)
(let ((q (or (request-arg "q") "")))
(span :class "text-stone-800 text-sm"
"Echo: " (strong q))))
;; ---------------------------------------------------------------------------
;; sx-encoding: file upload name
;; ---------------------------------------------------------------------------
(defhandler ref-upload-name (&key)
(let ((name (or (file-name "file") "(no file)")))
(span :class "text-stone-800 text-sm"
"Received: " (strong name))))
;; ---------------------------------------------------------------------------
;; sx-headers: echo custom headers
;; ---------------------------------------------------------------------------
(defhandler ref-echo-headers (&key)
(let ((headers (request-headers :prefix "X-")))
(if (empty? headers)
(span :class "text-stone-400 text-sm" "No custom headers received.")
(ul :class "text-sm text-stone-700 space-y-1"
(map (fn (h)
(li (strong (first h)) ": " (last h)))
headers)))))
;; ---------------------------------------------------------------------------
;; sx-include / sx-vals: echo all values
;; ---------------------------------------------------------------------------
(defhandler ref-echo-vals (&key)
(let ((vals (request-args)))
(if (empty? vals)
(span :class "text-stone-400 text-sm" "No values received.")
(ul :class "text-sm text-stone-700 space-y-1"
(map (fn (v)
(li (strong (first v)) ": " (last v)))
vals)))))
;; ---------------------------------------------------------------------------
;; sx-retry: flaky endpoint
;; ---------------------------------------------------------------------------
(defhandler ref-flaky (&key)
(let ((n (inc-counter "ref-flaky")))
(if (!= (mod n 3) 0)
(error 503)
(span :class "text-emerald-700 text-sm"
"Success on attempt " n "!"))))

View File

@@ -148,6 +148,7 @@ def _register_sx_helpers() -> None:
from shared.sx.pages import register_page_helpers from shared.sx.pages import register_page_helpers
from sxc.sx_components import ( from sxc.sx_components import (
_docs_content_sx, _reference_content_sx, _docs_content_sx, _reference_content_sx,
_reference_index_sx, _reference_attr_detail_sx,
_protocol_content_sx, _examples_content_sx, _protocol_content_sx, _examples_content_sx,
_essay_content_sx, _essay_content_sx,
_docs_nav_sx, _reference_nav_sx, _docs_nav_sx, _reference_nav_sx,
@@ -189,6 +190,8 @@ def _register_sx_helpers() -> None:
"home-content": _home_content, "home-content": _home_content,
"docs-content": _docs_content_sx, "docs-content": _docs_content_sx,
"reference-content": _reference_content_sx, "reference-content": _reference_content_sx,
"reference-index-content": _reference_index_sx,
"reference-attr-detail": _reference_attr_detail_sx,
"protocol-content": _protocol_content_sx, "protocol-content": _protocol_content_sx,
"examples-content": _examples_content_sx, "examples-content": _examples_content_sx,
"essay-content": _essay_content_sx, "essay-content": _essay_content_sx,

View File

@@ -48,9 +48,9 @@
:section "Reference" :section "Reference"
:sub-label "Reference" :sub-label "Reference"
:sub-href "/reference/" :sub-href "/reference/"
:sub-nav (reference-nav "Attributes") :sub-nav (reference-nav "")
:selected "Attributes") :selected "")
:content (reference-content "")) :content (reference-index-content))
(defpage reference-page (defpage reference-page
:path "/reference/<slug>" :path "/reference/<slug>"
@@ -63,6 +63,17 @@
:selected (or (find-current REFERENCE_NAV slug) "")) :selected (or (find-current REFERENCE_NAV slug) ""))
:content (reference-content slug)) :content (reference-content slug))
(defpage reference-attr-detail
:path "/reference/attributes/<slug>"
:auth :public
:layout (:sx-section
:section "Reference"
:sub-label "Reference"
:sub-href "/reference/"
:sub-nav (reference-nav "Attributes")
:selected "Attributes")
:content (reference-attr-detail slug))
;; --------------------------------------------------------------------------- ;; ---------------------------------------------------------------------------
;; Protocols section ;; Protocols section
;; --------------------------------------------------------------------------- ;; ---------------------------------------------------------------------------

408
sx/sxc/reference.sx Normal file
View File

@@ -0,0 +1,408 @@
;; SX reference — demo components for attribute detail pages
;;
;; Each attribute gets a small, focused demo showing exactly
;; what that attribute does.
;; ---------------------------------------------------------------------------
;; sx-get
;; ---------------------------------------------------------------------------
(defcomp ~ref-get-demo ()
(div :class "space-y-3"
(button
:sx-get "/reference/api/time"
:sx-target "#ref-get-result"
:sx-swap "innerHTML"
:class "px-4 py-2 bg-violet-600 text-white rounded hover:bg-violet-700 transition-colors text-sm"
"Load server time")
(div :id "ref-get-result"
:class "p-3 rounded bg-stone-50 text-stone-400 text-sm"
"Click to load.")))
;; ---------------------------------------------------------------------------
;; sx-post
;; ---------------------------------------------------------------------------
(defcomp ~ref-post-demo ()
(div :class "space-y-3"
(form
:sx-post "/reference/api/greet"
:sx-target "#ref-post-result"
:sx-swap "innerHTML"
:class "flex gap-2"
(input :type "text" :name "name" :placeholder "Your name"
:class "flex-1 px-3 py-2 border border-stone-300 rounded text-sm focus:outline-none focus:ring-2 focus:ring-violet-500")
(button :type "submit"
:class "px-4 py-2 bg-violet-600 text-white rounded hover:bg-violet-700 transition-colors text-sm"
"Greet"))
(div :id "ref-post-result"
:class "p-3 rounded bg-stone-50 text-stone-400 text-sm"
"Submit to see greeting.")))
;; ---------------------------------------------------------------------------
;; sx-put
;; ---------------------------------------------------------------------------
(defcomp ~ref-put-demo ()
(div :id "ref-put-view"
(div :class "flex items-center justify-between p-3 bg-stone-50 rounded"
(span :class "text-stone-700 text-sm" "Status: " (strong "draft"))
(button
:sx-put "/reference/api/status"
:sx-target "#ref-put-view"
:sx-swap "innerHTML"
:sx-vals "{\"status\": \"published\"}"
:class "px-3 py-1 bg-violet-600 text-white rounded text-sm hover:bg-violet-700"
"Publish"))))
;; ---------------------------------------------------------------------------
;; sx-delete
;; ---------------------------------------------------------------------------
(defcomp ~ref-delete-demo ()
(div :class "space-y-2"
(div :id "ref-del-1" :class "flex items-center justify-between p-2 border border-stone-200 rounded"
(span :class "text-sm text-stone-700" "Item A")
(button :sx-delete "/reference/api/item/1"
:sx-target "#ref-del-1" :sx-swap "delete"
:class "text-red-500 text-sm hover:text-red-700" "Remove"))
(div :id "ref-del-2" :class "flex items-center justify-between p-2 border border-stone-200 rounded"
(span :class "text-sm text-stone-700" "Item B")
(button :sx-delete "/reference/api/item/2"
:sx-target "#ref-del-2" :sx-swap "delete"
:class "text-red-500 text-sm hover:text-red-700" "Remove"))
(div :id "ref-del-3" :class "flex items-center justify-between p-2 border border-stone-200 rounded"
(span :class "text-sm text-stone-700" "Item C")
(button :sx-delete "/reference/api/item/3"
:sx-target "#ref-del-3" :sx-swap "delete"
:class "text-red-500 text-sm hover:text-red-700" "Remove"))))
;; ---------------------------------------------------------------------------
;; sx-patch
;; ---------------------------------------------------------------------------
(defcomp ~ref-patch-demo ()
(div :id "ref-patch-view" :class "space-y-2"
(div :class "p-3 bg-stone-50 rounded"
(span :class "text-stone-700 text-sm" "Theme: " (strong :id "ref-patch-val" "light")))
(div :class "flex gap-2"
(button :sx-patch "/reference/api/theme"
:sx-vals "{\"theme\": \"dark\"}"
:sx-target "#ref-patch-val" :sx-swap "innerHTML"
:class "px-3 py-1 bg-stone-800 text-white rounded text-sm" "Dark")
(button :sx-patch "/reference/api/theme"
:sx-vals "{\"theme\": \"light\"}"
:sx-target "#ref-patch-val" :sx-swap "innerHTML"
:class "px-3 py-1 bg-white border border-stone-300 text-stone-700 rounded text-sm" "Light"))))
;; ---------------------------------------------------------------------------
;; sx-trigger
;; ---------------------------------------------------------------------------
(defcomp ~ref-trigger-demo ()
(div :class "space-y-3"
(input :type "text" :name "q" :placeholder "Type to search..."
:sx-get "/reference/api/trigger-search"
:sx-trigger "input changed delay:300ms"
:sx-target "#ref-trigger-result"
:sx-swap "innerHTML"
:class "w-full px-3 py-2 border border-stone-300 rounded text-sm focus:outline-none focus:ring-2 focus:ring-violet-500")
(div :id "ref-trigger-result"
:class "p-3 rounded bg-stone-50 text-stone-400 text-sm"
"Start typing to trigger a search.")))
;; ---------------------------------------------------------------------------
;; sx-target
;; ---------------------------------------------------------------------------
(defcomp ~ref-target-demo ()
(div :class "space-y-3"
(div :class "flex gap-2"
(button :sx-get "/reference/api/time"
:sx-target "#ref-target-a"
:sx-swap "innerHTML"
:class "px-3 py-1 bg-violet-600 text-white rounded text-sm hover:bg-violet-700"
"Update Box A")
(button :sx-get "/reference/api/time"
:sx-target "#ref-target-b"
:sx-swap "innerHTML"
:class "px-3 py-1 bg-emerald-600 text-white rounded text-sm hover:bg-emerald-700"
"Update Box B"))
(div :class "grid grid-cols-2 gap-3"
(div :id "ref-target-a" :class "p-3 rounded border border-violet-200 bg-violet-50 text-sm text-stone-500"
"Box A")
(div :id "ref-target-b" :class "p-3 rounded border border-emerald-200 bg-emerald-50 text-sm text-stone-500"
"Box B"))))
;; ---------------------------------------------------------------------------
;; sx-swap
;; ---------------------------------------------------------------------------
(defcomp ~ref-swap-demo ()
(div :class "space-y-3"
(div :class "flex gap-2 flex-wrap"
(button :sx-get "/reference/api/swap-item"
:sx-target "#ref-swap-list" :sx-swap "beforeend"
:class "px-3 py-1 bg-violet-600 text-white rounded text-sm" "beforeend")
(button :sx-get "/reference/api/swap-item"
:sx-target "#ref-swap-list" :sx-swap "afterbegin"
:class "px-3 py-1 bg-emerald-600 text-white rounded text-sm" "afterbegin")
(button :sx-get "/reference/api/swap-item"
:sx-target "#ref-swap-list" :sx-swap "innerHTML"
:class "px-3 py-1 bg-blue-600 text-white rounded text-sm" "innerHTML"))
(div :id "ref-swap-list"
:class "p-3 rounded border border-stone-200 space-y-1 min-h-[3rem]"
(div :class "text-sm text-stone-500" "Original item"))))
;; ---------------------------------------------------------------------------
;; sx-swap-oob
;; ---------------------------------------------------------------------------
(defcomp ~ref-oob-demo ()
(div :class "space-y-3"
(button :sx-get "/reference/api/oob"
:sx-target "#ref-oob-main"
:sx-swap "innerHTML"
:class "px-4 py-2 bg-violet-600 text-white rounded text-sm hover:bg-violet-700"
"Update both boxes")
(div :class "grid grid-cols-2 gap-3"
(div :class "rounded border border-stone-200 p-3"
(div :class "text-xs text-stone-400 mb-1" "Main target")
(div :id "ref-oob-main" :class "text-sm text-stone-500" "Waiting..."))
(div :class "rounded border border-stone-200 p-3"
(div :class "text-xs text-stone-400 mb-1" "OOB target")
(div :id "ref-oob-side" :class "text-sm text-stone-500" "Waiting...")))))
;; ---------------------------------------------------------------------------
;; sx-select
;; ---------------------------------------------------------------------------
(defcomp ~ref-select-demo ()
(div :class "space-y-3"
(button :sx-get "/reference/api/select-page"
:sx-target "#ref-select-result"
:sx-select "#the-content"
:sx-swap "innerHTML"
:class "px-4 py-2 bg-violet-600 text-white rounded text-sm hover:bg-violet-700"
"Load (selecting #the-content)")
(div :id "ref-select-result"
:class "p-3 rounded bg-stone-50 text-stone-400 text-sm"
"Only the selected fragment will appear here.")))
;; ---------------------------------------------------------------------------
;; sx-confirm
;; ---------------------------------------------------------------------------
(defcomp ~ref-confirm-demo ()
(div :class "space-y-2"
(div :id "ref-confirm-item"
:class "flex items-center justify-between p-3 border border-stone-200 rounded"
(span :class "text-sm text-stone-700" "Important file.txt")
(button :sx-delete "/reference/api/item/confirm"
:sx-target "#ref-confirm-item" :sx-swap "delete"
:sx-confirm "Are you sure you want to delete this file?"
:class "px-3 py-1 text-red-500 text-sm border border-red-200 rounded hover:bg-red-50"
"Delete"))))
;; ---------------------------------------------------------------------------
;; sx-push-url
;; ---------------------------------------------------------------------------
(defcomp ~ref-pushurl-demo ()
(div :class "space-y-3"
(div :class "flex gap-2"
(a :href "/reference/attributes/sx-get"
:sx-get "/reference/attributes/sx-get"
:sx-target "#main-panel" :sx-select "#main-panel"
:sx-swap "outerHTML" :sx-push-url "true"
:class "px-3 py-1 bg-violet-100 text-violet-700 rounded text-sm no-underline hover:bg-violet-200"
"sx-get page")
(a :href "/reference/attributes/sx-post"
:sx-get "/reference/attributes/sx-post"
:sx-target "#main-panel" :sx-select "#main-panel"
:sx-swap "outerHTML" :sx-push-url "true"
:class "px-3 py-1 bg-violet-100 text-violet-700 rounded text-sm no-underline hover:bg-violet-200"
"sx-post page"))
(p :class "text-sm text-stone-500"
"Click a link — the URL bar updates without a full page reload. Use browser back to return.")))
;; ---------------------------------------------------------------------------
;; sx-sync
;; ---------------------------------------------------------------------------
(defcomp ~ref-sync-demo ()
(div :class "space-y-3"
(input :type "text" :name "q" :placeholder "Type quickly..."
:sx-get "/reference/api/slow-echo"
:sx-trigger "input changed delay:100ms"
:sx-sync "replace"
:sx-target "#ref-sync-result"
:sx-swap "innerHTML"
:class "w-full px-3 py-2 border border-stone-300 rounded text-sm focus:outline-none focus:ring-2 focus:ring-violet-500")
(p :class "text-xs text-stone-400"
"With sync:replace, each new keystroke aborts the in-flight request.")
(div :id "ref-sync-result"
:class "p-3 rounded bg-stone-50 text-stone-400 text-sm"
"Type to see only the latest result.")))
;; ---------------------------------------------------------------------------
;; sx-encoding
;; ---------------------------------------------------------------------------
(defcomp ~ref-encoding-demo ()
(div :class "space-y-3"
(form :sx-post "/reference/api/upload-name"
:sx-encoding "multipart/form-data"
:sx-target "#ref-encoding-result"
:sx-swap "innerHTML"
:class "flex gap-2"
(input :type "file" :name "file"
:class "flex-1 text-sm text-stone-500 file:mr-2 file:px-3 file:py-1 file:rounded file:border-0 file:text-sm file:bg-violet-50 file:text-violet-700")
(button :type "submit"
:class "px-4 py-2 bg-violet-600 text-white rounded text-sm hover:bg-violet-700"
"Upload"))
(div :id "ref-encoding-result"
:class "p-3 rounded bg-stone-50 text-stone-400 text-sm"
"Select a file and submit.")))
;; ---------------------------------------------------------------------------
;; sx-headers
;; ---------------------------------------------------------------------------
(defcomp ~ref-headers-demo ()
(div :class "space-y-3"
(button :sx-get "/reference/api/echo-headers"
:sx-headers "{\"X-Custom-Token\": \"abc123\", \"X-Request-Source\": \"demo\"}"
:sx-target "#ref-headers-result"
:sx-swap "innerHTML"
:class "px-4 py-2 bg-violet-600 text-white rounded text-sm hover:bg-violet-700"
"Send with custom headers")
(div :id "ref-headers-result"
:class "p-3 rounded bg-stone-50 text-stone-400 text-sm"
"Click to see echoed headers.")))
;; ---------------------------------------------------------------------------
;; sx-include
;; ---------------------------------------------------------------------------
(defcomp ~ref-include-demo ()
(div :class "space-y-3"
(div :class "flex gap-2 items-end"
(div
(label :class "block text-xs text-stone-500 mb-1" "Category")
(select :id "ref-inc-cat" :name "category"
:class "px-3 py-2 border border-stone-300 rounded text-sm"
(option :value "all" "All")
(option :value "books" "Books")
(option :value "tools" "Tools")))
(button :sx-get "/reference/api/echo-vals"
:sx-include "#ref-inc-cat"
:sx-target "#ref-include-result"
:sx-swap "innerHTML"
:class "px-4 py-2 bg-violet-600 text-white rounded text-sm hover:bg-violet-700"
"Filter"))
(div :id "ref-include-result"
:class "p-3 rounded bg-stone-50 text-stone-400 text-sm"
"Click Filter — the select value is included in the request.")))
;; ---------------------------------------------------------------------------
;; sx-vals
;; ---------------------------------------------------------------------------
(defcomp ~ref-vals-demo ()
(div :class "space-y-3"
(button :sx-post "/reference/api/echo-vals"
:sx-vals "{\"source\": \"demo\", \"page\": \"3\"}"
:sx-target "#ref-vals-result"
:sx-swap "innerHTML"
:class "px-4 py-2 bg-violet-600 text-white rounded text-sm hover:bg-violet-700"
"Send with extra values")
(div :id "ref-vals-result"
:class "p-3 rounded bg-stone-50 text-stone-400 text-sm"
"Click to see echoed values.")))
;; ---------------------------------------------------------------------------
;; sx-media
;; ---------------------------------------------------------------------------
(defcomp ~ref-media-demo ()
(div :class "space-y-3"
(a :href "/reference/attributes/sx-get"
:sx-get "/reference/attributes/sx-get"
:sx-target "#main-panel" :sx-select "#main-panel"
:sx-swap "outerHTML" :sx-push-url "true"
:sx-media "(min-width: 768px)"
:class "inline-block px-4 py-2 bg-violet-600 text-white rounded text-sm no-underline hover:bg-violet-700"
"sx navigation (desktop only)")
(p :class "text-sm text-stone-500"
"On screens narrower than 768px this link uses normal navigation. On wider screens it uses sx.")))
;; ---------------------------------------------------------------------------
;; sx-disable
;; ---------------------------------------------------------------------------
(defcomp ~ref-disable-demo ()
(div :class "space-y-3"
(div :class "grid grid-cols-2 gap-3"
(div :class "p-3 border border-stone-200 rounded"
(p :class "text-xs text-stone-400 mb-2" "sx enabled")
(button :sx-get "/reference/api/time"
:sx-target "#ref-dis-a" :sx-swap "innerHTML"
:class "px-3 py-1 bg-violet-600 text-white rounded text-sm" "Load")
(div :id "ref-dis-a" :class "mt-2 text-sm text-stone-500" "—"))
(div :sx-disable "true" :class "p-3 border border-stone-200 rounded"
(p :class "text-xs text-stone-400 mb-2" "sx disabled")
(button :sx-get "/reference/api/time"
:sx-target "#ref-dis-b" :sx-swap "innerHTML"
:class "px-3 py-1 bg-stone-400 text-white rounded text-sm" "Load")
(div :id "ref-dis-b" :class "mt-2 text-sm text-stone-500"
"Button won't fire sx request")))))
;; ---------------------------------------------------------------------------
;; sx-on:*
;; ---------------------------------------------------------------------------
(defcomp ~ref-on-demo ()
(div :class "space-y-3"
(button
:sx-on:click "document.getElementById('ref-on-result').textContent = 'Clicked at ' + new Date().toLocaleTimeString()"
:class "px-4 py-2 bg-violet-600 text-white rounded text-sm hover:bg-violet-700"
"Click me")
(div :id "ref-on-result"
:class "p-3 rounded bg-stone-50 text-stone-400 text-sm"
"Click the button — runs JavaScript, no server request.")))
;; ---------------------------------------------------------------------------
;; sx-retry
;; ---------------------------------------------------------------------------
(defcomp ~ref-retry-demo ()
(div :class "space-y-3"
(button :sx-get "/reference/api/flaky"
:sx-target "#ref-retry-result"
:sx-swap "innerHTML"
:sx-retry "true"
:class "px-4 py-2 bg-violet-600 text-white rounded text-sm hover:bg-violet-700"
"Call flaky endpoint")
(div :id "ref-retry-result"
:class "p-3 rounded bg-stone-50 text-stone-400 text-sm"
"This endpoint fails 2 out of 3 times. sx-retry retries automatically.")))
;; ---------------------------------------------------------------------------
;; data-sx
;; ---------------------------------------------------------------------------
(defcomp ~ref-data-sx-demo ()
(div :class "space-y-3"
(div :data-sx "(div :class \"p-3 bg-violet-50 rounded\" (h3 :class \"font-semibold text-violet-800\" \"Client-rendered\") (p :class \"text-sm text-stone-600\" \"This was evaluated in the browser — no server request.\"))")
(p :class "text-xs text-stone-400" "The content above is rendered client-side from the data-sx attribute.")))
;; ---------------------------------------------------------------------------
;; data-sx-env
;; ---------------------------------------------------------------------------
(defcomp ~ref-data-sx-env-demo ()
(div :class "space-y-3"
(div :data-sx "(div :class \"p-3 bg-emerald-50 rounded\" (h3 :class \"font-semibold text-emerald-800\" title) (p :class \"text-sm text-stone-600\" message))"
:data-sx-env "{\"title\": \"Dynamic content\", \"message\": \"Variables passed via data-sx-env are available in the expression.\"}")
(p :class "text-xs text-stone-400" "The title and message above come from the data-sx-env JSON.")))

View File

@@ -189,10 +189,13 @@ def _doc_nav_sx(items: list[tuple[str, str]], current: str) -> str:
def _attr_table_sx(title: str, attrs: list[tuple[str, str, bool]]) -> str: def _attr_table_sx(title: str, attrs: list[tuple[str, str, bool]]) -> str:
"""Build an attribute reference table.""" """Build an attribute reference table."""
from content.pages import ATTR_DETAILS
rows = [] rows = []
for attr, desc, exists in attrs: 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(sx_call("doc-attr-row", attr=attr, description=desc,
exists="true" if exists else None)) exists="true" if exists else None,
href=href))
return ( return (
f'(div :class "space-y-3"' f'(div :class "space-y-3"'
f' (h3 :class "text-xl font-semibold text-stone-700" "{title}")' f' (h3 :class "text-xl font-semibold text-stone-700" "{title}")'
@@ -470,7 +473,6 @@ def _docs_server_rendering_sx() -> str:
def _reference_content_sx(slug: str) -> str: def _reference_content_sx(slug: str) -> str:
builders = { builders = {
"": _reference_attrs_sx,
"attributes": _reference_attrs_sx, "attributes": _reference_attrs_sx,
"headers": _reference_headers_sx, "headers": _reference_headers_sx,
"events": _reference_events_sx, "events": _reference_events_sx,
@@ -479,6 +481,98 @@ def _reference_content_sx(slug: str) -> str:
return builders.get(slug or "", _reference_attrs_sx)() return builders.get(slug or "", _reference_attrs_sx)()
def _reference_index_sx() -> str:
"""Build the reference index page with links to sub-sections."""
sections = [
("Attributes", "/reference/attributes",
"All sx attributes — request verbs, behavior modifiers, and sx-unique features."),
("Headers", "/reference/headers",
"Custom HTTP headers used to coordinate between the sx client and server."),
("Events", "/reference/events",
"DOM events fired during the sx request lifecycle."),
("JS API", "/reference/js-api",
"JavaScript functions for parsing, evaluating, and rendering s-expressions."),
]
cards = []
for label, href, desc in sections:
cards.append(
f'(a :href "{href}"'
f' :sx-get "{href}" :sx-target "#main-panel" :sx-select "#main-panel"'
f' :sx-swap "outerHTML" :sx-push-url "true"'
f' :class "block p-5 rounded-lg border border-stone-200 hover:border-violet-300'
f' hover:shadow-sm transition-all no-underline"'
f' (h3 :class "text-lg font-semibold text-violet-700 mb-1" "{label}")'
f' (p :class "text-stone-600 text-sm" "{desc}"))'
)
return (
f'(~doc-page :title "Reference"'
f' (p :class "text-stone-600 mb-6"'
f' "Complete reference for the sx client library.")'
f' (div :class "grid gap-4 sm:grid-cols-2"'
f' {" ".join(cards)}))'
)
def _reference_attr_detail_sx(slug: str) -> str:
"""Build a detail page for a single sx attribute."""
from content.pages import ATTR_DETAILS
detail = ATTR_DETAILS.get(slug)
if not detail:
return (
f'(~doc-page :title "Not Found"'
f' (p :class "text-stone-600"'
f' "No documentation found for \\"{slug}\\"."))'
)
title = slug
desc = detail["description"]
escaped_desc = desc.replace('\\', '\\\\').replace('"', '\\"')
# Live demo
demo_name = detail.get("demo")
demo_sx = ""
if demo_name:
demo_sx = (
f' (~example-card :title "Demo"'
f' (~example-demo (~{demo_name})))'
)
# S-expression source
example_sx = _example_code(detail["example"], "lisp")
# Server handler (s-expression)
handler_sx = ""
if "handler" in detail:
handler_sx = (
f' (h3 :class "text-lg font-semibold text-stone-700 mt-6"'
f' "Server handler")'
f' {_example_code(detail["handler"], "lisp")}'
)
# Wire response placeholder (only for attrs with server interaction)
wire_sx = ""
if "handler" in detail:
wire_id = slug.replace(":", "-").replace("*", "star")
wire_sx = (
f' (h3 :class "text-lg font-semibold text-stone-700 mt-6"'
f' "Wire response")'
f' (p :class "text-stone-500 text-sm mb-2"'
f' "Trigger the demo to see the raw response the server sends.")'
f' {_placeholder("ref-wire-" + wire_id)}'
)
return (
f'(~doc-page :title "{title}"'
f' (p :class "text-stone-600 mb-6" "{escaped_desc}")'
f' {demo_sx}'
f' (h3 :class "text-lg font-semibold text-stone-700 mt-6"'
f' "S-expression")'
f' {example_sx}'
f' {handler_sx}'
f' {wire_sx})'
)
def _reference_attrs_sx() -> str: def _reference_attrs_sx() -> str:
from content.pages import REQUEST_ATTRS, BEHAVIOR_ATTRS, SX_UNIQUE_ATTRS, HTMX_MISSING_ATTRS from content.pages import REQUEST_ATTRS, BEHAVIOR_ATTRS, SX_UNIQUE_ATTRS, HTMX_MISSING_ATTRS
return ( return (