Send all responses as sexp wire format with client-side rendering

- 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

@@ -14,6 +14,7 @@ from quart import (
from shared.browser.app.redis_cacher import clear_all_cache
from shared.browser.app.authz import require_admin
from shared.browser.app.utils.htmx import is_htmx_request
from shared.sexp.helpers import sexp_response
from shared.config import config
from datetime import datetime
@@ -35,10 +36,10 @@ def register(url_prefix):
tctx = await get_template_context()
if not is_htmx_request():
html = await render_settings_page(tctx)
return await make_response(html)
else:
html = await render_settings_oob(tctx)
return await make_response(html)
sexp_src = await render_settings_oob(tctx)
return sexp_response(sexp_src)
@bp.get("/cache/")
@require_admin
@@ -49,9 +50,10 @@ def register(url_prefix):
tctx = await get_template_context()
if not is_htmx_request():
html = await render_cache_page(tctx)
return await make_response(html)
else:
html = await render_cache_oob(tctx)
return await make_response(html)
sexp_src = await render_cache_oob(tctx)
return sexp_response(sexp_src)
@bp.post("/cache_clear/")
@require_admin
@@ -61,7 +63,7 @@ def register(url_prefix):
now = datetime.now()
from shared.sexp.jinja_bridge import render as render_comp
html = render_comp("cache-cleared", time_str=now.strftime("%H:%M:%S"))
return html
return sexp_response(html)
return redirect(url_for("settings.cache"))
return bp

View File

@@ -15,6 +15,7 @@ from sqlalchemy import select, delete
from shared.browser.app.authz import require_admin
from shared.browser.app.utils.htmx import is_htmx_request
from shared.browser.app.redis_cacher import invalidate_tag_cache
from shared.sexp.helpers import sexp_response
from models.tag_group import TagGroup, TagGroupTag
from models.ghost_content import Tag
@@ -65,7 +66,7 @@ def register():
if not is_htmx_request():
return await make_response(await render_tag_groups_page(tctx))
else:
return await make_response(await render_tag_groups_oob(tctx))
return sexp_response(await render_tag_groups_oob(tctx))
@bp.post("/")
@require_admin
@@ -130,7 +131,7 @@ def register():
if not is_htmx_request():
return await make_response(await render_tag_group_edit_page(tctx))
else:
return await make_response(await render_tag_group_edit_oob(tctx))
return sexp_response(await render_tag_group_edit_oob(tctx))
@bp.post("/<int:id>/")
@require_admin

View File

@@ -22,6 +22,7 @@ from .services.pages_data import pages_data
from shared.browser.app.redis_cacher import cache_page, invalidate_tag_cache
from shared.browser.app.utils.htmx import is_htmx_request
from shared.browser.app.authz import require_admin
from shared.sexp.helpers import sexp_response
from shared.utils import host_url
def register(url_prefix, title):
@@ -117,7 +118,7 @@ def register(url_prefix, title):
post_slug = p_data["post"]["slug"]
# Fetch container nav from relations service
container_nav_html = await fetch_fragment("relations", "container-nav", params={
container_nav = await fetch_fragment("relations", "container-nav", params={
"container_type": "page",
"container_id": str(db_post_id),
"post_slug": post_slug,
@@ -126,7 +127,7 @@ def register(url_prefix, title):
ctx = {
**p_data,
"base_title": get_config()["title"],
"container_nav_html": container_nav_html,
"container_nav": container_nav,
}
# Page cart badge via HTTP
@@ -149,9 +150,10 @@ def register(url_prefix, title):
tctx.update(ctx)
if not is_htmx_request():
html = await render_home_page(tctx)
return await make_response(html)
else:
html = await render_home_oob(tctx)
return await make_response(html)
sexp_src = await render_home_oob(tctx)
return sexp_response(sexp_src)
@blogs_bp.get("/index")
@blogs_bp.get("/index/")
@@ -186,11 +188,13 @@ def register(url_prefix, title):
tctx.update(context)
if not is_htmx_request():
html = await render_blog_page(tctx)
return await make_response(html)
elif q.page > 1:
html = await render_blog_page_cards(tctx)
sexp_src = await render_blog_page_cards(tctx)
return sexp_response(sexp_src)
else:
html = await render_blog_oob(tctx)
return await make_response(html)
sexp_src = await render_blog_oob(tctx)
return sexp_response(sexp_src)
# Default: posts listing
# Drafts filter requires login; ignore if not logged in
@@ -227,12 +231,14 @@ def register(url_prefix, title):
tctx.update(context)
if not is_htmx_request():
html = await render_blog_page(tctx)
return await make_response(html)
elif q.page > 1:
html = await render_blog_cards(tctx)
# Sexp wire format — client renders blog cards
sexp_src = await render_blog_cards(tctx)
return sexp_response(sexp_src)
else:
html = await render_blog_oob(tctx)
return await make_response(html)
sexp_src = await render_blog_oob(tctx)
return sexp_response(sexp_src)
@blogs_bp.get("/new/")
@require_admin
@@ -244,9 +250,10 @@ def register(url_prefix, title):
tctx["editor_html"] = render_editor_panel()
if not is_htmx_request():
html = await render_new_post_page(tctx)
return await make_response(html)
else:
html = await render_new_post_oob(tctx)
return await make_response(html)
sexp_src = await render_new_post_oob(tctx)
return sexp_response(sexp_src)
@blogs_bp.post("/new/")
@require_admin
@@ -325,9 +332,10 @@ def register(url_prefix, title):
tctx["is_page"] = True
if not is_htmx_request():
html = await render_new_post_page(tctx)
return await make_response(html)
else:
html = await render_new_post_oob(tctx)
return await make_response(html)
sexp_src = await render_new_post_oob(tctx)
return sexp_response(sexp_src)
@blogs_bp.post("/new-page/")
@require_admin

View File

@@ -1,6 +1,6 @@
"""Blog app fragment endpoints.
Exposes HTML fragments at ``/internal/fragments/<type>`` for consumption
Exposes sexp fragments at ``/internal/fragments/<type>`` for consumption
by other coop apps via the fragment client.
"""
@@ -10,13 +10,11 @@ from quart import Blueprint, Response, g, render_template, request
from shared.infrastructure.fragments import FRAGMENT_HEADER
from shared.services.navigation import get_navigation_tree
from shared.sexp.jinja_bridge import sexp
def register():
bp = Blueprint("fragments", __name__, url_prefix="/internal/fragments")
# Registry of fragment handlers: type -> async callable returning HTML str
_handlers: dict[str, object] = {}
@bp.before_request
@@ -28,18 +26,19 @@ def register():
async def get_fragment(fragment_type: str):
handler = _handlers.get(fragment_type)
if handler is None:
return Response("", status=200, content_type="text/html")
html = await handler()
return Response(html, status=200, content_type="text/html")
return Response("", status=200, content_type="text/sexp")
result = await handler()
# nav-tree still returns HTML (Jinja template) for now
ct = "text/html" if fragment_type == "nav-tree" else "text/sexp"
return Response(result, status=200, content_type=ct)
# --- nav-tree fragment ---
# --- nav-tree fragment (still Jinja for now — complex template) ---
async def _nav_tree_handler():
app_name = request.args.get("app_name", "")
path = request.args.get("path", "/")
first_seg = path.strip("/").split("/")[0]
menu_items = list(await get_navigation_tree(g.s))
# Append Art-DAG as a synthetic nav entry (not a DB MenuNode)
class _NavItem:
__slots__ = ("slug", "label", "feature_image")
def __init__(self, slug, label, feature_image=None):
@@ -58,20 +57,18 @@ def register():
_handlers["nav-tree"] = _nav_tree_handler
# --- link-card fragment (s-expression rendered) ---
def _render_blog_link_card(post, link: str) -> str:
"""Render a blog link-card via the ~link-card s-expression component."""
# --- link-card fragment — returns sexp source ---
def _blog_link_card_sexp(post, link: str) -> str:
from shared.sexp.helpers import sexp_call
published = post.published_at.strftime("%d %b %Y") if post.published_at else None
return sexp(
'(~link-card :link link :title title :image image'
' :icon "fas fa-file-alt" :subtitle excerpt'
' :detail published :data-app "blog")',
link=link,
title=post.title,
image=post.feature_image,
excerpt=post.custom_excerpt or post.excerpt,
published=published,
)
return sexp_call("link-card",
link=link,
title=post.title,
image=post.feature_image,
icon="fas fa-file-alt",
subtitle=post.custom_excerpt or post.excerpt,
detail=published,
data_app="blog")
async def _link_card_handler():
from shared.services.registry import services
@@ -88,7 +85,7 @@ def register():
parts.append(f"<!-- fragment:{s} -->")
post = await services.blog.get_post_by_slug(g.s, s)
if post:
parts.append(_render_blog_link_card(post, blog_url(f"/{post.slug}")))
parts.append(_blog_link_card_sexp(post, blog_url(f"/{post.slug}")))
return "\n".join(parts)
# Single mode
@@ -97,11 +94,10 @@ def register():
post = await services.blog.get_post_by_slug(g.s, slug)
if not post:
return ""
return _render_blog_link_card(post, blog_url(f"/{post.slug}"))
return _blog_link_card_sexp(post, blog_url(f"/{post.slug}"))
_handlers["link-card"] = _link_card_handler
# Store handlers dict on blueprint so app code can register handlers
bp._fragment_handlers = _handlers
return bp

View File

@@ -13,6 +13,7 @@ from .services.menu_items import (
MenuItemError,
)
from shared.browser.app.utils.htmx import is_htmx_request
from shared.sexp.helpers import sexp_response
def register():
bp = Blueprint("menu_items", __name__, url_prefix='/settings/menu_items')
@@ -36,10 +37,10 @@ def register():
tctx["menu_items"] = menu_items
if not is_htmx_request():
html = await render_menu_items_page(tctx)
return await make_response(html)
else:
html = await render_menu_items_oob(tctx)
return await make_response(html)
sexp_src = await render_menu_items_oob(tctx)
return sexp_response(sexp_src)
@bp.get("/new/")
@require_admin
@@ -75,7 +76,7 @@ def register():
from sexp.sexp_components import render_menu_items_list
html = render_menu_items_list(menu_items)
nav_oob = get_menu_items_nav_oob_sync(menu_items)
return await make_response(html + nav_oob, 200)
return sexp_response(html + nav_oob)
except MenuItemError as e:
return jsonify({"message": str(e), "errors": {}}), 400
@@ -118,7 +119,7 @@ def register():
from sexp.sexp_components import render_menu_items_list
html = render_menu_items_list(menu_items)
nav_oob = get_menu_items_nav_oob_sync(menu_items)
return await make_response(html + nav_oob, 200)
return sexp_response(html + nav_oob)
except MenuItemError as e:
return jsonify({"message": str(e), "errors": {}}), 400
@@ -139,7 +140,7 @@ def register():
from sexp.sexp_components import render_menu_items_list
html = render_menu_items_list(menu_items)
nav_oob = get_menu_items_nav_oob_sync(menu_items)
return await make_response(html + nav_oob, 200)
return sexp_response(html + nav_oob)
@bp.get("/pages/search/")
@require_admin
@@ -186,6 +187,6 @@ def register():
from sexp.sexp_components import render_menu_items_list
html = render_menu_items_list(menu_items)
nav_oob = get_menu_items_nav_oob_sync(menu_items)
return await make_response(html + nav_oob, 200)
return sexp_response(html + nav_oob)
return bp

View File

@@ -12,6 +12,7 @@ from quart import (
)
from shared.browser.app.authz import require_admin, require_post_author
from shared.browser.app.utils.htmx import is_htmx_request
from shared.sexp.helpers import sexp_response
from shared.utils import host_url
def register():
@@ -58,10 +59,10 @@ def register():
tctx.update(ctx)
if not is_htmx_request():
html = await render_post_admin_page(tctx)
return await make_response(html)
else:
html = await render_post_admin_oob(tctx)
return await make_response(html)
sexp_src = await render_post_admin_oob(tctx)
return sexp_response(sexp_src)
@bp.put("/features/")
@require_admin
@@ -105,7 +106,7 @@ def register():
sumup_merchant_code=result.get("sumup_merchant_code") or "",
sumup_checkout_prefix=result.get("sumup_checkout_prefix") or "",
)
return await make_response(html)
return sexp_response(html)
@bp.put("/admin/sumup/")
@require_admin
@@ -144,7 +145,7 @@ def register():
sumup_merchant_code=result.get("sumup_merchant_code") or "",
sumup_checkout_prefix=result.get("sumup_checkout_prefix") or "",
)
return await make_response(html)
return sexp_response(html)
@bp.get("/data/")
@require_admin
@@ -157,10 +158,10 @@ def register():
tctx["data_html"] = data_html
if not is_htmx_request():
html = await render_post_data_page(tctx)
return await make_response(html)
else:
html = await render_post_data_oob(tctx)
return await make_response(html)
sexp_src = await render_post_data_oob(tctx)
return sexp_response(sexp_src)
@bp.get("/entries/calendar/<int:calendar_id>/")
@require_admin
@@ -280,10 +281,10 @@ def register():
tctx["entries_html"] = entries_html
if not is_htmx_request():
html = await render_post_entries_page(tctx)
return await make_response(html)
else:
html = await render_post_entries_oob(tctx)
return await make_response(html)
sexp_src = await render_post_entries_oob(tctx)
return sexp_response(sexp_src)
@bp.post("/entries/<int:entry_id>/toggle/")
@require_admin
@@ -335,7 +336,7 @@ def register():
admin_list = render_associated_entries(all_calendars, associated_entry_ids, post["slug"])
nav_entries_html = render_nav_entries_oob(associated_entries, calendars, post)
return await make_response(admin_list + nav_entries_html)
return sexp_response(admin_list + nav_entries_html)
@bp.get("/settings/")
@require_post_author
@@ -359,10 +360,10 @@ def register():
tctx["settings_html"] = settings_html
if not is_htmx_request():
html = await render_post_settings_page(tctx)
return await make_response(html)
else:
html = await render_post_settings_oob(tctx)
return await make_response(html)
sexp_src = await render_post_settings_oob(tctx)
return sexp_response(sexp_src)
@bp.post("/settings/")
@require_post_author
@@ -465,10 +466,10 @@ def register():
tctx["edit_html"] = edit_html
if not is_htmx_request():
html = await render_post_edit_page(tctx)
return await make_response(html)
else:
html = await render_post_edit_oob(tctx)
return await make_response(html)
sexp_src = await render_post_edit_oob(tctx)
return sexp_response(sexp_src)
@bp.post("/edit/")
@require_post_author
@@ -598,8 +599,7 @@ def register():
page_markets = await _fetch_page_markets(post_id)
from sexp.sexp_components import render_markets_panel
html = render_markets_panel(page_markets, post)
return await make_response(html)
return sexp_response(render_markets_panel(page_markets, post))
@bp.post("/markets/new/")
@require_admin
@@ -625,8 +625,7 @@ def register():
page_markets = await _fetch_page_markets(post_id)
from sexp.sexp_components import render_markets_panel
html = render_markets_panel(page_markets, post)
return await make_response(html)
return sexp_response(render_markets_panel(page_markets, post))
@bp.delete("/markets/<market_slug>/")
@require_admin
@@ -646,7 +645,6 @@ def register():
page_markets = await _fetch_page_markets(post_id)
from sexp.sexp_components import render_markets_panel
html = render_markets_panel(page_markets, post)
return await make_response(html)
return sexp_response(render_markets_panel(page_markets, post))
return bp

View File

@@ -21,6 +21,7 @@ from shared.browser.app.redis_cacher import cache_page, clear_cache
from .admin.routes import register as register_admin
from shared.config import config
from shared.browser.app.utils.htmx import is_htmx_request
from shared.sexp.helpers import sexp_response
def register():
bp = Blueprint("post", __name__, url_prefix='/<slug>')
@@ -70,7 +71,7 @@ def register():
post_slug = (g.post_data.get("post") or {}).get("slug", "")
# Fetch container nav from relations service
container_nav_html = await fetch_fragment("relations", "container-nav", params={
container_nav = await fetch_fragment("relations", "container-nav", params={
"container_type": "page",
"container_id": str(db_post_id),
"post_slug": post_slug,
@@ -79,7 +80,7 @@ def register():
ctx = {
**p_data,
"base_title": config()["title"],
"container_nav_html": container_nav_html,
"container_nav": container_nav,
}
# Page cart badge via HTTP
@@ -109,10 +110,10 @@ def register():
tctx = await get_template_context()
if not is_htmx_request():
html = await render_post_page(tctx)
return await make_response(html)
else:
html = await render_post_oob(tctx)
return await make_response(html)
sexp_src = await render_post_oob(tctx)
return sexp_response(sexp_src)
@bp.post("/like/toggle/")
@clear_cache(tag="post.post_detail", tag_scope="user")
@@ -124,9 +125,7 @@ def register():
# Get post_id from g.post_data
if not g.user:
html = render_like_toggle_button(slug, False, like_url)
resp = make_response(html, 403)
return resp
return sexp_response(render_like_toggle_button(slug, False, like_url), status=403)
post_id = g.post_data["post"]["id"]
user_id = g.user.id
@@ -136,8 +135,7 @@ def register():
})
liked = result["liked"]
html = render_like_toggle_button(slug, liked, like_url)
return html
return sexp_response(render_like_toggle_button(slug, liked, like_url))
@bp.get("/w/<widget_domain>/")
async def widget_paginate(slug: str, widget_domain: str):

View File

@@ -6,6 +6,7 @@ from sqlalchemy.orm import selectinload
from shared.browser.app.authz import require_login
from shared.browser.app.utils.htmx import is_htmx_request
from shared.sexp.helpers import sexp_response
from models import Snippet
@@ -46,10 +47,10 @@ def register():
tctx["is_admin"] = is_admin
if not is_htmx_request():
html = await render_snippets_page(tctx)
return await make_response(html)
else:
html = await render_snippets_oob(tctx)
return await make_response(html)
sexp_src = await render_snippets_oob(tctx)
return sexp_response(sexp_src)
@bp.delete("/<int:snippet_id>/")
@require_login
@@ -68,8 +69,7 @@ def register():
snippets = await _visible_snippets(g.s)
from sexp.sexp_components import render_snippets_list
html = render_snippets_list(snippets, is_admin)
return await make_response(html)
return sexp_response(render_snippets_list(snippets, is_admin))
@bp.patch("/<int:snippet_id>/visibility/")
@require_login
@@ -93,7 +93,6 @@ def register():
snippets = await _visible_snippets(g.s)
from sexp.sexp_components import render_snippets_list
html = render_snippets_list(snippets, True)
return await make_response(html)
return sexp_response(render_snippets_list(snippets, True))
return bp