Send all responses as sexp wire format with client-side rendering
All checks were successful
Build and Deploy / build-and-deploy (push) Successful in 5m35s

- Server sends sexp source text, client (sexp.js) renders everything
- SexpExpr marker class for nested sexp composition in serialize()
- sexp_page() HTML shell with data-mount="body" for full page loads
- sexp_response() returns text/sexp for OOB/partial responses
- ~app-body layout component replaces ~app-layout (no raw!)
- ~rich-text is the only component using raw! (for CMS HTML content)
- Fragment endpoints return text/sexp, auto-wrapped in SexpExpr
- All _*_html() helpers converted to _*_sexp() returning sexp source
- Head auto-hoist: sexp.js moves meta/title/link/script[ld+json]
  from rendered body to document.head automatically
- Unknown components render warning box instead of crashing page
- Component kwargs preserve AST for lazy rendering (fixes <> in kwargs)
- Fix unterminated paren in events/sexp/tickets.sexpr

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-03-01 09:45:07 +00:00
parent 0d48fd22ee
commit 22802bd36b
270 changed files with 7153 additions and 5382 deletions

View File

@@ -10,8 +10,11 @@ import os
from typing import Any
from markupsafe import escape
from shared.sexp.jinja_bridge import render, load_service_components
from shared.sexp.helpers import root_header_html, full_page
from shared.sexp.jinja_bridge import load_service_components
from shared.sexp.helpers import (
sexp_call, SexpExpr,
root_header_sexp, full_page_sexp, header_child_sexp,
)
# Load federation-specific .sexpr components at import time
load_service_components(os.path.dirname(os.path.dirname(__file__)))
@@ -21,13 +24,13 @@ load_service_components(os.path.dirname(os.path.dirname(__file__)))
# Social header nav
# ---------------------------------------------------------------------------
def _social_nav_html(actor: Any) -> str:
def _social_nav_sexp(actor: Any) -> str:
"""Build the social header nav bar content."""
from quart import url_for, request
if not actor:
choose_url = url_for("identity.choose_username_form")
return render("federation-nav-choose-username", url=choose_url)
return sexp_call("federation-nav-choose-username", url=choose_url)
links = [
("social.home_timeline", "Timeline"),
@@ -42,7 +45,7 @@ def _social_nav_html(actor: Any) -> str:
for endpoint, label in links:
href = url_for(endpoint)
bold = " font-bold" if request.path == href else ""
parts.append(render(
parts.append(sexp_call(
"federation-nav-link",
href=href,
cls=f"px-2 py-1 rounded hover:bg-stone-200{bold}",
@@ -53,7 +56,7 @@ def _social_nav_html(actor: Any) -> str:
notif_url = url_for("social.notifications")
notif_count_url = url_for("social.notification_count")
notif_bold = " font-bold" if request.path == notif_url else ""
parts.append(render(
parts.append(sexp_call(
"federation-nav-notification-link",
href=notif_url,
cls=f"px-2 py-1 rounded hover:bg-stone-200 relative{notif_bold}",
@@ -62,36 +65,39 @@ def _social_nav_html(actor: Any) -> str:
# Profile link
profile_url = url_for("activitypub.actor_profile", username=actor.preferred_username)
parts.append(render(
parts.append(sexp_call(
"federation-nav-link",
href=profile_url,
cls="px-2 py-1 rounded hover:bg-stone-200",
label=f"@{actor.preferred_username}",
))
return render("federation-nav-bar", items_html="".join(parts))
items_sexp = "(<> " + " ".join(parts) + ")"
return sexp_call("federation-nav-bar", items=SexpExpr(items_sexp))
def _social_header_html(actor: Any) -> str:
def _social_header_sexp(actor: Any) -> str:
"""Build the social section header row."""
nav_html = _social_nav_html(actor)
return render("federation-social-header", nav_html=nav_html)
nav_sexp = _social_nav_sexp(actor)
return sexp_call("federation-social-header", nav=SexpExpr(nav_sexp))
def _social_page(ctx: dict, actor: Any, *, content_html: str,
def _social_page(ctx: dict, actor: Any, *, content: str,
title: str = "Rose Ash", meta_html: str = "") -> str:
"""Render a social page with header and content."""
hdr = root_header_html(ctx)
hdr += render("federation-header-child", inner_html=_social_header_html(actor))
return full_page(ctx, header_rows_html=hdr, content_html=content_html,
meta_html=meta_html or f'<title>{escape(title)}</title>')
hdr = root_header_sexp(ctx)
social_hdr = _social_header_sexp(actor)
child = header_child_sexp(social_hdr)
header_rows = "(<> " + hdr + " " + child + ")"
return full_page_sexp(ctx, header_rows=header_rows, content=content,
meta_html=meta_html or f'<title>{escape(title)}</title>')
# ---------------------------------------------------------------------------
# Post card
# ---------------------------------------------------------------------------
def _interaction_buttons_html(item: Any, actor: Any) -> str:
def _interaction_buttons_sexp(item: Any, actor: Any) -> str:
"""Render like/boost/reply buttons for a post."""
from shared.browser.app.csrf import generate_csrf_token
from quart import url_for
@@ -124,29 +130,31 @@ def _interaction_buttons_html(item: Any, actor: Any) -> str:
boost_cls = "hover:text-green-600"
reply_url = url_for("social.compose_form", reply_to=oid) if oid else ""
reply_html = render("federation-reply-link", url=reply_url) if reply_url else ""
reply_sexp = sexp_call("federation-reply-link", url=reply_url) if reply_url else ""
like_form = render(
like_form = sexp_call(
"federation-like-form",
action=like_action, target=target, oid=oid, ainbox=ainbox,
csrf=csrf, cls=f"flex items-center gap-1 {like_cls}",
icon=like_icon, count=str(lcount),
)
boost_form = render(
boost_form = sexp_call(
"federation-boost-form",
action=boost_action, target=target, oid=oid, ainbox=ainbox,
csrf=csrf, cls=f"flex items-center gap-1 {boost_cls}",
count=str(bcount),
)
return render(
return sexp_call(
"federation-interaction-buttons",
like_html=like_form, boost_html=boost_form, reply_html=reply_html,
like=SexpExpr(like_form),
boost=SexpExpr(boost_form),
reply=SexpExpr(reply_sexp) if reply_sexp else None,
)
def _post_card_html(item: Any, actor: Any) -> str:
def _post_card_sexp(item: Any, actor: Any) -> str:
"""Render a single timeline post card."""
boosted_by = getattr(item, "boosted_by", None)
actor_icon = getattr(item, "actor_icon", None)
@@ -159,53 +167,55 @@ def _post_card_html(item: Any, actor: Any) -> str:
url = getattr(item, "url", None)
post_type = getattr(item, "post_type", "")
boost_html = render(
boost_sexp = sexp_call(
"federation-boost-label", name=str(escape(boosted_by)),
) if boosted_by else ""
if actor_icon:
avatar = render("federation-avatar-img", src=actor_icon, cls="w-10 h-10 rounded-full")
avatar = sexp_call("federation-avatar-img", src=actor_icon, cls="w-10 h-10 rounded-full")
else:
initial = actor_name[0].upper() if actor_name else "?"
avatar = render(
avatar = sexp_call(
"federation-avatar-placeholder",
cls="w-10 h-10 rounded-full bg-stone-300 flex items-center justify-center text-stone-600 font-bold text-sm",
initial=initial,
)
domain_html = f"@{escape(actor_domain)}" if actor_domain else ""
time_html = published.strftime("%b %d, %H:%M") if published else ""
domain_str = f"@{escape(actor_domain)}" if actor_domain else ""
time_str = published.strftime("%b %d, %H:%M") if published else ""
if summary:
content_html = render(
content_sexp = sexp_call(
"federation-content-cw",
summary=str(escape(summary)), content=content,
)
else:
content_html = render("federation-content-plain", content=content)
content_sexp = sexp_call("federation-content-plain", content=content)
original_html = ""
original_sexp = ""
if url and post_type == "remote":
original_html = render("federation-original-link", url=url)
original_sexp = sexp_call("federation-original-link", url=url)
interactions_html = ""
interactions_sexp = ""
if actor:
oid = getattr(item, "object_id", "") or ""
safe_id = oid.replace("/", "_").replace(":", "_")
interactions_html = render(
interactions_sexp = sexp_call(
"federation-interactions-wrap",
id=f"interactions-{safe_id}",
buttons_html=_interaction_buttons_html(item, actor),
buttons=SexpExpr(_interaction_buttons_sexp(item, actor)),
)
return render(
return sexp_call(
"federation-post-card",
boost_html=boost_html, avatar_html=avatar,
boost=SexpExpr(boost_sexp) if boost_sexp else None,
avatar=SexpExpr(avatar),
actor_name=str(escape(actor_name)),
actor_username=str(escape(actor_username)),
domain_html=domain_html, time_html=time_html,
content_html=content_html, original_html=original_html,
interactions_html=interactions_html,
domain=domain_str, time=time_str,
content=SexpExpr(content_sexp),
original=SexpExpr(original_sexp) if original_sexp else None,
interactions=SexpExpr(interactions_sexp) if interactions_sexp else None,
)
@@ -213,12 +223,12 @@ def _post_card_html(item: Any, actor: Any) -> str:
# Timeline items (pagination fragment)
# ---------------------------------------------------------------------------
def _timeline_items_html(items: list, timeline_type: str, actor: Any,
def _timeline_items_sexp(items: list, timeline_type: str, actor: Any,
actor_id: int | None = None) -> str:
"""Render timeline items with infinite scroll sentinel."""
from quart import url_for
parts = [_post_card_html(item, actor) for item in items]
parts = [_post_card_sexp(item, actor) for item in items]
if items:
last = items[-1]
@@ -227,16 +237,16 @@ def _timeline_items_html(items: list, timeline_type: str, actor: Any,
next_url = url_for("social.actor_timeline_page", id=actor_id, before=before)
else:
next_url = url_for(f"social.{timeline_type}_timeline_page", before=before)
parts.append(render("federation-scroll-sentinel", url=next_url))
parts.append(sexp_call("federation-scroll-sentinel", url=next_url))
return "".join(parts)
return "(<> " + " ".join(parts) + ")" if parts else ""
# ---------------------------------------------------------------------------
# Search results (pagination fragment)
# ---------------------------------------------------------------------------
def _actor_card_html(a: Any, actor: Any, followed_urls: set,
def _actor_card_sexp(a: Any, actor: Any, followed_urls: set,
*, list_type: str = "search") -> str:
"""Render a single actor card with follow/unfollow button."""
from shared.browser.app.csrf import generate_csrf_token
@@ -254,10 +264,10 @@ def _actor_card_html(a: Any, actor: Any, followed_urls: set,
safe_id = actor_url.replace("/", "_").replace(":", "_")
if icon_url:
avatar = render("federation-actor-avatar-img", src=icon_url, cls="w-12 h-12 rounded-full")
avatar = sexp_call("federation-actor-avatar-img", src=icon_url, cls="w-12 h-12 rounded-full")
else:
initial = (display_name or username)[0].upper() if (display_name or username) else "?"
avatar = render(
avatar = sexp_call(
"federation-actor-avatar-placeholder",
cls="w-12 h-12 rounded-full bg-stone-300 flex items-center justify-center text-stone-600 font-bold",
initial=initial,
@@ -265,75 +275,77 @@ def _actor_card_html(a: Any, actor: Any, followed_urls: set,
# Name link
if (list_type in ("following", "search")) and aid:
name_html = render(
name_sexp = sexp_call(
"federation-actor-name-link",
href=url_for("social.actor_timeline", id=aid),
name=str(escape(display_name)),
)
else:
name_html = render(
name_sexp = sexp_call(
"federation-actor-name-link-external",
href=f"https://{domain}/@{username}",
name=str(escape(display_name)),
)
summary_html = render("federation-actor-summary", summary=summary) if summary else ""
summary_sexp = sexp_call("federation-actor-summary", summary=summary) if summary else ""
# Follow/unfollow button
button_html = ""
button_sexp = ""
if actor:
is_followed = actor_url in (followed_urls or set())
if list_type == "following" or is_followed:
button_html = render(
button_sexp = sexp_call(
"federation-unfollow-button",
action=url_for("social.unfollow"), csrf=csrf, actor_url=actor_url,
)
else:
label = "Follow Back" if list_type == "followers" else "Follow"
button_html = render(
button_sexp = sexp_call(
"federation-follow-button",
action=url_for("social.follow"), csrf=csrf, actor_url=actor_url, label=label,
)
return render(
return sexp_call(
"federation-actor-card",
cls="bg-white rounded-lg shadow-sm border border-stone-200 p-4 mb-3 flex items-center gap-4",
id=f"actor-{safe_id}",
avatar_html=avatar, name_html=name_html,
avatar=SexpExpr(avatar),
name=SexpExpr(name_sexp),
username=str(escape(username)), domain=str(escape(domain)),
summary_html=summary_html, button_html=button_html,
summary=SexpExpr(summary_sexp) if summary_sexp else None,
button=SexpExpr(button_sexp) if button_sexp else None,
)
def _search_results_html(actors: list, query: str, page: int,
followed_urls: set, actor: Any) -> str:
def _search_results_sexp(actors: list, query: str, page: int,
followed_urls: set, actor: Any) -> str:
"""Render search results with pagination sentinel."""
from quart import url_for
parts = [_actor_card_html(a, actor, followed_urls, list_type="search") for a in actors]
parts = [_actor_card_sexp(a, actor, followed_urls, list_type="search") for a in actors]
if len(actors) >= 20:
next_url = url_for("social.search_page", q=query, page=page + 1)
parts.append(render("federation-scroll-sentinel", url=next_url))
return "".join(parts)
parts.append(sexp_call("federation-scroll-sentinel", url=next_url))
return "(<> " + " ".join(parts) + ")" if parts else ""
def _actor_list_items_html(actors: list, page: int, list_type: str,
followed_urls: set, actor: Any) -> str:
def _actor_list_items_sexp(actors: list, page: int, list_type: str,
followed_urls: set, actor: Any) -> str:
"""Render actor list items (following/followers) with pagination sentinel."""
from quart import url_for
parts = [_actor_card_html(a, actor, followed_urls, list_type=list_type) for a in actors]
parts = [_actor_card_sexp(a, actor, followed_urls, list_type=list_type) for a in actors]
if len(actors) >= 20:
next_url = url_for(f"social.{list_type}_list_page", page=page + 1)
parts.append(render("federation-scroll-sentinel", url=next_url))
return "".join(parts)
parts.append(sexp_call("federation-scroll-sentinel", url=next_url))
return "(<> " + " ".join(parts) + ")" if parts else ""
# ---------------------------------------------------------------------------
# Notification card
# ---------------------------------------------------------------------------
def _notification_html(notif: Any) -> str:
def _notification_sexp(notif: Any) -> str:
"""Render a single notification."""
from_name = getattr(notif, "from_actor_name", "?")
from_username = getattr(notif, "from_actor_username", "")
@@ -348,16 +360,16 @@ def _notification_html(notif: Any) -> str:
border = " border-l-4 border-l-stone-400" if not read else ""
if from_icon:
avatar = render("federation-avatar-img", src=from_icon, cls="w-8 h-8 rounded-full")
avatar = sexp_call("federation-avatar-img", src=from_icon, cls="w-8 h-8 rounded-full")
else:
initial = from_name[0].upper() if from_name else "?"
avatar = render(
avatar = sexp_call(
"federation-avatar-placeholder",
cls="w-8 h-8 rounded-full bg-stone-300 flex items-center justify-center text-stone-600 font-bold text-xs",
initial=initial,
)
domain_html = f"@{escape(from_domain)}" if from_domain else ""
domain_str = f"@{escape(from_domain)}" if from_domain else ""
type_map = {
"follow": "followed you",
@@ -370,19 +382,20 @@ def _notification_html(notif: Any) -> str:
if ntype == "follow" and app_domain and app_domain != "federation":
action += f" on {escape(app_domain)}"
preview_html = render(
preview_sexp = sexp_call(
"federation-notification-preview", preview=str(escape(preview)),
) if preview else ""
time_html = created.strftime("%b %d, %H:%M") if created else ""
time_str = created.strftime("%b %d, %H:%M") if created else ""
return render(
return sexp_call(
"federation-notification-card",
cls=f"bg-white rounded-lg shadow-sm border border-stone-200 p-4{border}",
avatar_html=avatar,
avatar=SexpExpr(avatar),
from_name=str(escape(from_name)),
from_username=str(escape(from_username)),
from_domain=domain_html, action_text=action,
preview_html=preview_html, time_html=time_html,
from_domain=domain_str, action_text=action,
preview=SexpExpr(preview_sexp) if preview_sexp else None,
time=time_str,
)
@@ -392,8 +405,8 @@ def _notification_html(notif: Any) -> str:
async def render_federation_home(ctx: dict) -> str:
"""Full page: federation home (minimal)."""
hdr = root_header_html(ctx)
return full_page(ctx, header_rows_html=hdr)
hdr = root_header_sexp(ctx)
return full_page_sexp(ctx, header_rows=hdr)
# ---------------------------------------------------------------------------
@@ -410,17 +423,17 @@ async def render_login_page(ctx: dict) -> str:
action = url_for("auth.start_login")
csrf = generate_csrf_token()
error_html = render("federation-error-banner", error=error) if error else ""
error_sexp = sexp_call("federation-error-banner", error=error) if error else ""
content = render(
content = sexp_call(
"federation-login-form",
error_html=error_html, action=action, csrf=csrf,
error=SexpExpr(error_sexp) if error_sexp else None,
action=action, csrf=csrf,
email=str(escape(email)),
)
hdr = root_header_html(ctx)
return full_page(ctx, header_rows_html=hdr, content_html=content,
meta_html='<title>Login \u2014 Rose Ash</title>')
return _social_page(ctx, None, content=content,
title="Login \u2014 Rose Ash")
async def render_check_email_page(ctx: dict) -> str:
@@ -428,18 +441,18 @@ async def render_check_email_page(ctx: dict) -> str:
email = ctx.get("email", "")
email_error = ctx.get("email_error")
error_html = render(
error_sexp = sexp_call(
"federation-check-email-error", error=str(escape(email_error)),
) if email_error else ""
content = render(
content = sexp_call(
"federation-check-email",
email=str(escape(email)), error_html=error_html,
email=str(escape(email)),
error=SexpExpr(error_sexp) if error_sexp else None,
)
hdr = root_header_html(ctx)
return full_page(ctx, header_rows_html=hdr, content_html=content,
meta_html='<title>Check your email \u2014 Rose Ash</title>')
return _social_page(ctx, None, content=content,
title="Check your email \u2014 Rose Ash")
# ---------------------------------------------------------------------------
@@ -452,26 +465,28 @@ async def render_timeline_page(ctx: dict, items: list, timeline_type: str,
from quart import url_for
label = "Home" if timeline_type == "home" else "Public"
compose_html = ""
compose_sexp = ""
if actor:
compose_url = url_for("social.compose_form")
compose_html = render("federation-compose-button", url=compose_url)
compose_sexp = sexp_call("federation-compose-button", url=compose_url)
timeline_html = _timeline_items_html(items, timeline_type, actor)
timeline_sexp = _timeline_items_sexp(items, timeline_type, actor)
content = render(
content = sexp_call(
"federation-timeline-page",
label=label, compose_html=compose_html, timeline_html=timeline_html,
label=label,
compose=SexpExpr(compose_sexp) if compose_sexp else None,
timeline=SexpExpr(timeline_sexp) if timeline_sexp else None,
)
return _social_page(ctx, actor, content_html=content,
return _social_page(ctx, actor, content=content,
title=f"{label} Timeline \u2014 Rose Ash")
async def render_timeline_items(items: list, timeline_type: str,
actor: Any, actor_id: int | None = None) -> str:
"""Pagination fragment: timeline items."""
return _timeline_items_html(items, timeline_type, actor, actor_id)
return _timeline_items_sexp(items, timeline_type, actor, actor_id)
# ---------------------------------------------------------------------------
@@ -486,19 +501,20 @@ async def render_compose_page(ctx: dict, actor: Any, reply_to: str | None) -> st
csrf = generate_csrf_token()
action = url_for("social.compose_submit")
reply_html = ""
reply_sexp = ""
if reply_to:
reply_html = render(
reply_sexp = sexp_call(
"federation-compose-reply",
reply_to=str(escape(reply_to)),
)
content = render(
content = sexp_call(
"federation-compose-form",
action=action, csrf=csrf, reply_html=reply_html,
action=action, csrf=csrf,
reply=SexpExpr(reply_sexp) if reply_sexp else None,
)
return _social_page(ctx, actor, content_html=content,
return _social_page(ctx, actor, content=content,
title="Compose \u2014 Rose Ash")
@@ -514,38 +530,39 @@ async def render_search_page(ctx: dict, query: str, actors: list, total: int,
search_url = url_for("social.search")
search_page_url = url_for("social.search_page")
results_html = _search_results_html(actors, query, page, followed_urls, actor)
results_sexp = _search_results_sexp(actors, query, page, followed_urls, actor)
info_html = ""
info_sexp = ""
if query and total:
s = "s" if total != 1 else ""
info_html = render(
info_sexp = sexp_call(
"federation-search-info",
cls="text-sm text-stone-500 mb-4",
text=f"{total} result{s} for <strong>{escape(query)}</strong>",
)
elif query:
info_html = render(
info_sexp = sexp_call(
"federation-search-info",
cls="text-stone-500 mb-4",
text=f"No results found for <strong>{escape(query)}</strong>",
)
content = render(
content = sexp_call(
"federation-search-page",
search_url=search_url, search_page_url=search_page_url,
query=str(escape(query)),
info_html=info_html, results_html=results_html,
info=SexpExpr(info_sexp) if info_sexp else None,
results=SexpExpr(results_sexp) if results_sexp else None,
)
return _social_page(ctx, actor, content_html=content,
return _social_page(ctx, actor, content=content,
title="Search \u2014 Rose Ash")
async def render_search_results(actors: list, query: str, page: int,
followed_urls: set, actor: Any) -> str:
"""Pagination fragment: search results."""
return _search_results_html(actors, query, page, followed_urls, actor)
return _search_results_sexp(actors, query, page, followed_urls, actor)
# ---------------------------------------------------------------------------
@@ -555,36 +572,38 @@ async def render_search_results(actors: list, query: str, page: int,
async def render_following_page(ctx: dict, actors: list, total: int,
actor: Any) -> str:
"""Full page: following list."""
items_html = _actor_list_items_html(actors, 1, "following", set(), actor)
content = render(
items_sexp = _actor_list_items_sexp(actors, 1, "following", set(), actor)
content = sexp_call(
"federation-actor-list-page",
title="Following", count_str=f"({total})", items_html=items_html,
title="Following", count_str=f"({total})",
items=SexpExpr(items_sexp) if items_sexp else None,
)
return _social_page(ctx, actor, content_html=content,
return _social_page(ctx, actor, content=content,
title="Following \u2014 Rose Ash")
async def render_following_items(actors: list, page: int, actor: Any) -> str:
"""Pagination fragment: following items."""
return _actor_list_items_html(actors, page, "following", set(), actor)
return _actor_list_items_sexp(actors, page, "following", set(), actor)
async def render_followers_page(ctx: dict, actors: list, total: int,
followed_urls: set, actor: Any) -> str:
"""Full page: followers list."""
items_html = _actor_list_items_html(actors, 1, "followers", followed_urls, actor)
content = render(
items_sexp = _actor_list_items_sexp(actors, 1, "followers", followed_urls, actor)
content = sexp_call(
"federation-actor-list-page",
title="Followers", count_str=f"({total})", items_html=items_html,
title="Followers", count_str=f"({total})",
items=SexpExpr(items_sexp) if items_sexp else None,
)
return _social_page(ctx, actor, content_html=content,
return _social_page(ctx, actor, content=content,
title="Followers \u2014 Rose Ash")
async def render_followers_items(actors: list, page: int,
followed_urls: set, actor: Any) -> str:
"""Pagination fragment: followers items."""
return _actor_list_items_html(actors, page, "followers", followed_urls, actor)
return _actor_list_items_sexp(actors, page, "followers", followed_urls, actor)
# ---------------------------------------------------------------------------
@@ -604,58 +623,60 @@ async def render_actor_timeline_page(ctx: dict, remote_actor: Any, items: list,
actor_url = getattr(remote_actor, "actor_url", "")
if icon_url:
avatar = render("federation-avatar-img", src=icon_url, cls="w-16 h-16 rounded-full")
avatar = sexp_call("federation-avatar-img", src=icon_url, cls="w-16 h-16 rounded-full")
else:
initial = display_name[0].upper() if display_name else "?"
avatar = render(
avatar = sexp_call(
"federation-avatar-placeholder",
cls="w-16 h-16 rounded-full bg-stone-300 flex items-center justify-center text-stone-600 font-bold text-xl",
initial=initial,
)
summary_html = render("federation-profile-summary", summary=summary) if summary else ""
summary_sexp = sexp_call("federation-profile-summary", summary=summary) if summary else ""
follow_html = ""
follow_sexp = ""
if actor:
if is_following:
follow_html = render(
follow_sexp = sexp_call(
"federation-follow-form",
action=url_for("social.unfollow"), csrf=csrf, actor_url=actor_url,
label="Unfollow",
cls="border border-stone-300 rounded px-4 py-2 hover:bg-stone-100",
)
else:
follow_html = render(
follow_sexp = sexp_call(
"federation-follow-form",
action=url_for("social.follow"), csrf=csrf, actor_url=actor_url,
label="Follow",
cls="bg-stone-800 text-white rounded px-4 py-2 hover:bg-stone-700",
)
timeline_html = _timeline_items_html(items, "actor", actor, remote_actor.id)
timeline_sexp = _timeline_items_sexp(items, "actor", actor, remote_actor.id)
header_html = render(
header_sexp = sexp_call(
"federation-actor-profile-header",
avatar_html=avatar,
avatar=SexpExpr(avatar),
display_name=str(escape(display_name)),
username=str(escape(remote_actor.preferred_username)),
domain=str(escape(remote_actor.domain)),
summary_html=summary_html, follow_html=follow_html,
summary=SexpExpr(summary_sexp) if summary_sexp else None,
follow=SexpExpr(follow_sexp) if follow_sexp else None,
)
content = render(
content = sexp_call(
"federation-actor-timeline-layout",
header_html=header_html, timeline_html=timeline_html,
header=SexpExpr(header_sexp),
timeline=SexpExpr(timeline_sexp) if timeline_sexp else None,
)
return _social_page(ctx, actor, content_html=content,
return _social_page(ctx, actor, content=content,
title=f"{display_name} \u2014 Rose Ash")
async def render_actor_timeline_items(items: list, actor_id: int,
actor: Any) -> str:
"""Pagination fragment: actor timeline items."""
return _timeline_items_html(items, "actor", actor, actor_id)
return _timeline_items_sexp(items, "actor", actor, actor_id)
# ---------------------------------------------------------------------------
@@ -666,15 +687,16 @@ async def render_notifications_page(ctx: dict, notifications: list,
actor: Any) -> str:
"""Full page: notifications."""
if not notifications:
notif_html = render("federation-notifications-empty")
notif_sexp = sexp_call("federation-notifications-empty")
else:
notif_html = render(
items_sexp = "(<> " + " ".join(_notification_sexp(n) for n in notifications) + ")"
notif_sexp = sexp_call(
"federation-notifications-list",
items_html="".join(_notification_html(n) for n in notifications),
items=SexpExpr(items_sexp),
)
content = render("federation-notifications-page", notifs_html=notif_html)
return _social_page(ctx, actor, content_html=content,
content = sexp_call("federation-notifications-page", notifs=SexpExpr(notif_sexp))
return _social_page(ctx, actor, content=content,
title="Notifications \u2014 Rose Ash")
@@ -695,16 +717,17 @@ async def render_choose_username_page(ctx: dict) -> str:
check_url = url_for("identity.check_username")
actor = ctx.get("actor")
error_html = render("federation-error-banner", error=error) if error else ""
error_sexp = sexp_call("federation-error-banner", error=error) if error else ""
content = render(
content = sexp_call(
"federation-choose-username",
domain=str(escape(ap_domain)), error_html=error_html,
domain=str(escape(ap_domain)),
error=SexpExpr(error_sexp) if error_sexp else None,
csrf=csrf, username=str(escape(username)),
check_url=check_url,
)
return _social_page(ctx, actor, content_html=content,
return _social_page(ctx, actor, content=content,
title="Choose Username \u2014 Rose Ash")
@@ -719,38 +742,39 @@ async def render_profile_page(ctx: dict, actor: Any, activities: list,
ap_domain = config().get("ap_domain", "rose-ash.com")
display_name = actor.display_name or actor.preferred_username
summary_html = render(
summary_sexp = sexp_call(
"federation-profile-summary-text", text=str(escape(actor.summary)),
) if actor.summary else ""
activities_html = ""
activities_sexp = ""
if activities:
parts = []
for a in activities:
published = a.published.strftime("%Y-%m-%d %H:%M") if a.published else ""
obj_type_html = render(
obj_type_sexp = sexp_call(
"federation-activity-obj-type", obj_type=a.object_type,
) if a.object_type else ""
parts.append(render(
parts.append(sexp_call(
"federation-activity-card",
activity_type=a.activity_type, published=published,
obj_type_html=obj_type_html,
obj_type=SexpExpr(obj_type_sexp) if obj_type_sexp else None,
))
activities_html = render("federation-activities-list", items_html="".join(parts))
items_sexp = "(<> " + " ".join(parts) + ")"
activities_sexp = sexp_call("federation-activities-list", items=SexpExpr(items_sexp))
else:
activities_html = render("federation-activities-empty")
activities_sexp = sexp_call("federation-activities-empty")
content = render(
content = sexp_call(
"federation-profile-page",
display_name=str(escape(display_name)),
username=str(escape(actor.preferred_username)),
domain=str(escape(ap_domain)),
summary_html=summary_html,
summary=SexpExpr(summary_sexp) if summary_sexp else None,
activities_heading=f"Activities ({total})",
activities_html=activities_html,
activities=SexpExpr(activities_sexp),
)
return _social_page(ctx, actor, content_html=content,
return _social_page(ctx, actor, content=content,
title=f"@{actor.preferred_username} \u2014 Rose Ash")
@@ -772,10 +796,10 @@ def render_interaction_buttons(object_id: str, author_inbox: str,
liked_by_me=liked_by_me,
boosted_by_me=boosted_by_me,
)
return _interaction_buttons_html(item, actor)
return _interaction_buttons_sexp(item, actor)
def render_actor_card(actor_dto: Any, actor: Any, followed_urls: set,
*, list_type: str = "following") -> str:
"""Render a single actor card fragment for HTMX POST response."""
return _actor_card_html(actor_dto, actor, followed_urls, list_type=list_type)
return _actor_card_sexp(actor_dto, actor, followed_urls, list_type=list_type)