Auto-mount defpages: eliminate Python route stubs across all 9 services

Defpages are now declared with absolute paths in .sx files and auto-mounted
directly on the Quart app, removing ~850 lines of blueprint mount_pages calls,
before_request hooks, and g.* wrapper boilerplate. A new page = one defpage
declaration, nothing else.

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

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

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-03-03 19:03:15 +00:00
parent 4ba63bda17
commit 293f7713d6
63 changed files with 1340 additions and 1216 deletions

View File

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

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

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

View File

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