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

@@ -1,11 +1,11 @@
<button
class="flex items-center gap-1 {% if liked %} text-red-600 {% else %} text-stone-300 {% endif %} hover:text-red-600 transition-colors w-[1em] h-[1em]"
hx-post="{{ like_url if like_url else url_for('market.browse.product.like_toggle', product_slug=slug)|host }}"
hx-target="this"
hx-swap="outerHTML"
hx-push-url="false"
hx-headers='{"X-CSRFToken": "{{ csrf_token() }}"}'
hx-swap-settle="0ms"
sx-post="{{ like_url if like_url else url_for('market.browse.product.like_toggle', product_slug=slug)|host }}"
sx-target="this"
sx-swap="outerHTML"
sx-push-url="false"
sx-headers='{"X-CSRFToken": "{{ csrf_token() }}"}'
sx-swap-settle="0ms"
{% if liked %}
aria-label="Unlike this {{ item_type if item_type else 'product' }}"
{% else %}

View File

@@ -5,9 +5,7 @@
<link rel="stylesheet" type="text/css" href="{{asset_url('styles/cards.css')}}">
<link rel="stylesheet" type="text/css" href="{{asset_url('styles/blog-content.css')}}">
<script src="https://unpkg.com/htmx.org@2.0.8"></script>
<meta name="htmx-config" content='{"selfRequestsOnly":false}'>
<script src="https://unpkg.com/hyperscript.org@0.9.12"></script>
<meta name="csrf-token" content="{{ csrf_token() }}">
<script src="https://cdn.tailwindcss.com"></script>
<link rel="stylesheet" href="{{asset_url('fontawesome/css/all.min.css')}}">
<link rel="stylesheet" href="{{asset_url('fontawesome/css/v4-shims.min.css')}}">
@@ -59,8 +57,10 @@
details.group > summary { list-style: none; }
details.group > summary::-webkit-details-marker { display:none; }
.htmx-indicator { display: none; }
.htmx-request .htmx-indicator { display: inline-flex; }
.sx-indicator { display: none; }
.sx-request .sx-indicator { display: inline-flex; }
.sx-error .sx-indicator { display: none; }
.sx-loading .sx-indicator { display: inline-flex; }
</style>
<style>
.js-wrap.open .js-pop { display:block; }

View File

@@ -1,7 +1,7 @@
{% macro header(id=False, oob=False) %}
<div
{% if id %}id="{{id}}"{% endif %}
{% if oob %}hx-swap-oob="outerHTML"{% endif %}
{% if oob %}sx-swap-oob="outerHTML"{% endif %}
class="w-full"
>
{{ caller() }}

View File

@@ -2,7 +2,7 @@
Shared mobile menu for both base templates and OOB updates
This macro can be used in two modes:
- oob=true: Outputs full wrapper with hx-swap-oob attribute (for OOB updates)
- oob=true: Outputs full wrapper with sx-swap-oob attribute (for OOB updates)
- oob=false: Outputs just content, assumes wrapper exists (for base templates)
The caller can pass section-specific nav items via section_nav parameter.
@@ -10,9 +10,9 @@
{% macro mobile_menu(section_nav='', oob=true) %}
{% if oob %}
<div id="root-menu" hx-swap-oob="outerHTML" class="md:hidden">
<div id="root-menu" sx-swap-oob="outerHTML" class="md:hidden">
{% endif %}
<nav id="nav-panel" {% if oob %}hx-swap-oob="true"{% endif %} class="flex flex-col gap-2 mt-2 px-2 pb-2" role="listbox">
<nav id="nav-panel" {% if oob %}sx-swap-oob="true"{% endif %} class="flex flex-col gap-2 mt-2 px-2 pb-2" role="listbox">
{% if not g.user %}
{% include '_types/root/_sign_in.html' %}
{% endif %}
@@ -35,7 +35,7 @@
{% macro oob_mobile_menu() %}
<div id="root-menu" hx-swap-oob="outerHTML" class="md:hidden">
<div id="root-menu" sx-swap-oob="outerHTML" class="md:hidden">
<nav id="nav-panel" class="flex flex-col gap-2 mt-2 px-2 pb-2" role="listbox">
{{caller()}}
</nav>

View File

@@ -2,7 +2,7 @@
Shared root header for both base templates and OOB updates
This macro can be used in two modes:
- oob=true: Outputs full div with hx-swap-oob attribute (for OOB updates)
- oob=true: Outputs full div with sx-swap-oob attribute (for OOB updates)
- oob=false: Outputs just content, assumes wrapper div exists (for base templates)
Usage:
@@ -19,7 +19,7 @@
"%}
{% if oob %}
<div id="root-header" hx-swap-oob="outerHTML" class="flex items-start gap-2 p-1 bg-{{ menu_colour }}-{{ (500-(level()*100))|string }}">
<div id="root-header" sx-swap-oob="outerHTML" class="flex items-start gap-2 p-1 bg-{{ menu_colour }}-{{ (500-(level()*100))|string }}">
{% endif %}
<div class="flex flex-col items-center flex-1">
<div class="flex w-full justify-center md:justify-start">

View File

@@ -2,7 +2,7 @@
Shared root header for both base templates and OOB updates
This macro can be used in two modes:
- oob=true: Outputs full div with hx-swap-oob attribute (for OOB updates)
- oob=true: Outputs full div with sx-swap-oob attribute (for OOB updates)
- oob=false: Outputs just content, assumes wrapper div exists (for base templates)
Usage:
@@ -20,7 +20,7 @@
{% if oob %}
<div id="root-header"
hx-swap-oob="outerHTML"
sx-swap-oob="outerHTML"
class="flex items-start gap-2 p-1 bg-{{ menu_colour }}-{{ (500-(level()*100))|string }}">
{% endif %}
<div class="flex flex-col items-center flex-1">

View File

@@ -1,7 +1,7 @@
{# Cart icon/badge — shows logo when empty, cart icon with count when items present #}
{% macro cart_icon(count=0, oob=False) %}
<div id="cart-mini" {% if oob %}hx-swap-oob="{{oob}}"{% endif %}>
<div id="cart-mini" {% if oob %}sx-swap-oob="{{oob}}"{% endif %}>
{% if count == 0 %}
<div class="h-12 w-12 rounded-full overflow-hidden border border-stone-300 flex-shrink-0">
<a

View File

@@ -13,7 +13,7 @@
<div
id="{{id}}"
{% if oob %}
hx-swap-oob="true"
sx-swap-oob="true"
{% endif %}
class="{{'flex justify-between items-start gap-2' if not _class else _class}}">
{{ caller() }}
@@ -55,7 +55,7 @@
{% macro menu(id, _class="") %}
<div id="{{id}}" hx-swap-oob="outerHTML" class="{{_class}}">
<div id="{{id}}" sx-swap-oob="outerHTML" class="{{_class}}">
{{ caller() }}
</div>
{%- endmacro %}

View File

@@ -5,11 +5,11 @@
<div class="relative nav-group {{_class}}">
<a
href="{{ href }}"
hx-get="{{ href }}"
hx-target="#main-panel"
hx-select="{{select}}"
hx-swap="outerHTML"
hx-push-url="true"
sx-get="{{ href }}"
sx-target="#main-panel"
sx-select="{{select}}"
sx-swap="outerHTML"
sx-push-url="true"
aria-selected="{{ 'true' if (request.path|host).startswith(href) else 'false' }}"
{% if aclass %}
class="{{aclass}}"
@@ -35,7 +35,7 @@
id="{{id}}"
{% endif %}
{% if oob %}
hx-swap-oob="outerHTML"
sx-swap-oob="outerHTML"
{% endif %}
class="flex flex-col items-center md:flex-row justify-center md:justify-between w-full p-1 bg-{{menu_colour}}-{{(500-(level()*100))|string}}"
>

View File

@@ -14,26 +14,19 @@
{% if has_items %}
<div class="flex flex-col sm:flex-row sm:items-center gap-2 border-r border-stone-200 mr-2 sm:max-w-2xl"
id="entries-calendars-nav-wrapper"
hx-swap-oob="true">
sx-swap-oob="true">
<button
class="entries-nav-arrow hidden flex-shrink-0 p-2 hover:bg-stone-200 rounded"
aria-label="Scroll left"
_="on click
set #associated-items-container.scrollLeft to #associated-items-container.scrollLeft - 200">
onclick="document.getElementById('associated-items-container').scrollLeft -= 200">
<i class="fa fa-chevron-left"></i>
</button>
<div id="associated-items-container"
class="overflow-y-auto sm:overflow-x-auto sm:overflow-y-visible scrollbar-hide max-h-[50vh] sm:max-h-none"
style="scroll-behavior: smooth;"
_="on load or scroll
if window.innerWidth >= 640 and my.scrollWidth > my.clientWidth
remove .hidden from .entries-nav-arrow
add .flex to .entries-nav-arrow
else
add .hidden to .entries-nav-arrow
remove .flex from .entries-nav-arrow
end">
data-scroll-arrows="entries-nav-arrow"
onscroll="(function(el){var arrows=document.getElementsByClassName('entries-nav-arrow');var show=window.innerWidth>=640&&el.scrollWidth>el.clientWidth;for(var i=0;i<arrows.length;i++){if(show){arrows[i].classList.remove('hidden');arrows[i].classList.add('flex')}else{arrows[i].classList.add('hidden');arrows[i].classList.remove('flex')}}})(this)">
<div class="flex flex-col sm:flex-row gap-1">
{{ caller() }}
</div>
@@ -47,12 +40,11 @@
<button
class="entries-nav-arrow hidden flex-shrink-0 p-2 hover:bg-stone-200 rounded"
aria-label="Scroll right"
_="on click
set #associated-items-container.scrollLeft to #associated-items-container.scrollLeft + 200">
onclick="document.getElementById('associated-items-container').scrollLeft += 200">
<i class="fa fa-chevron-right"></i>
</button>
</div>
{% else %}
<div id="entries-calendars-nav-wrapper" hx-swap-oob="true"></div>
<div id="entries-calendars-nav-wrapper" sx-swap-oob="true"></div>
{% endif %}
{% endmacro %}

View File

@@ -19,8 +19,7 @@
<button
class="scrolling-menu-arrow-{{ container_id }} hidden flex-shrink-0 p-2 hover:bg-stone-200 rounded"
aria-label="Scroll left"
_="on click
set #{{ container_id }}.scrollLeft to #{{ container_id }}.scrollLeft - 200">
onclick="document.getElementById('{{ container_id }}').scrollLeft -= 200">
<i class="fa fa-chevron-left"></i>
</button>
@@ -28,15 +27,8 @@
<div id="{{ container_id }}"
class="overflow-y-auto sm:overflow-x-auto sm:overflow-y-visible scrollbar-hide max-h-[50vh] sm:max-h-none {{ container_class }}"
style="scroll-behavior: smooth;"
_="on load or scroll
-- Show arrows if content overflows (desktop only)
if window.innerWidth >= 640 and my.scrollWidth > my.clientWidth
remove .hidden from .scrolling-menu-arrow-{{ container_id }}
add .flex to .scrolling-menu-arrow-{{ container_id }}
else
add .hidden to .scrolling-menu-arrow-{{ container_id }}
remove .flex from .scrolling-menu-arrow-{{ container_id }}
end">
data-scroll-arrows="scrolling-menu-arrow-{{ container_id }}"
onscroll="(function(el){var cls='scrolling-menu-arrow-{{ container_id }}';var arrows=document.getElementsByClassName(cls);var show=window.innerWidth>=640&&el.scrollWidth>el.clientWidth;for(var i=0;i<arrows.length;i++){if(show){arrows[i].classList.remove('hidden');arrows[i].classList.add('flex')}else{arrows[i].classList.add('hidden');arrows[i].classList.remove('flex')}}})(this)">
<div class="flex flex-col sm:flex-row gap-1 {{ wrapper_class }}">
{% for item in items %}
<div class="{{ item_class }}">
@@ -60,8 +52,7 @@
<button
class="scrolling-menu-arrow-{{ container_id }} hidden flex-shrink-0 p-2 hover:bg-stone-200 rounded"
aria-label="Scroll right"
_="on click
set #{{ container_id }}.scrollLeft to #{{ container_id }}.scrollLeft + 200">
onclick="document.getElementById('{{ container_id }}').scrollLeft += 200">
<i class="fa fa-chevron-right"></i>
</button>
{% endif %}

View File

@@ -11,18 +11,18 @@
name="search"
aria-label="search"
class="text-base md:text-sm col-span-5 rounded-md px-3 py-2 mb-2 w-full min-w-0 max-w-full border-2 border-stone-200 placeholder-shown:border-stone-200 [&:not(:placeholder-shown)]:border-yellow-200"
hx-preserve
sx-preserve
value="{{ search|default('', true) }}"
placeholder="search"
hx-trigger="input changed delay:300ms"
hx-target="#main-panel"
sx-trigger="input changed delay:300ms"
sx-target="#main-panel"
hx-select="{{hx_select}}, #search-mobile-wrapper, #search-desktop-wrapper"
hx-get="{{ (current_local_href ~ {'search': None}|qs)|host }}"
hx-swap="outerHTML"
hx-push-url="true"
hx-headers='{"X-Origin":"search-mobile", "X-Search":"true"}'
hx-sync="this:replace"
sx-select="{{hx_select}}, #search-mobile-wrapper, #search-desktop-wrapper"
sx-get="{{ (current_local_href ~ {'search': None}|qs)|host }}"
sx-swap="outerHTML"
sx-push-url="true"
sx-headers='{"X-Origin":"search-mobile", "X-Search":"true"}'
sx-sync="this:replace"
autocomplete="off"
>
@@ -51,18 +51,18 @@
name="search"
aria-label="search"
class="w-full mx-1 my-3 px-3 py-2 text-md rounded-xl border-2 shadow-sm border-white placeholder-shown:border-white [&:not(:placeholder-shown)]:border-yellow-200"
hx-preserve
sx-preserve
value="{{ search|default('', true) }}"
placeholder="search"
hx-trigger="input changed delay:300ms"
hx-target="#main-panel"
sx-trigger="input changed delay:300ms"
sx-target="#main-panel"
hx-select="{{hx_select}}, #search-mobile-wrapper, #search-desktop-wrapper"
hx-get="{{ (current_local_href ~ {'search': None}|qs)|host}}"
hx-swap="outerHTML"
hx-push-url="true"
hx-headers='{"X-Origin":"search-desktop", "X-Search":"true"}'
hx-sync="this:replace"
sx-select="{{hx_select}}, #search-mobile-wrapper, #search-desktop-wrapper"
sx-get="{{ (current_local_href ~ {'search': None}|qs)|host}}"
sx-swap="outerHTML"
sx-push-url="true"
sx-headers='{"X-Origin":"search-desktop", "X-Search":"true"}'
sx-sync="this:replace"
autocomplete="off"
>

View File

@@ -4,7 +4,7 @@
<div
id="filter"
hx-swap-oob="outerHTML"
sx-swap-oob="outerHTML"
>
{% block filter %}
{% endblock %}
@@ -14,14 +14,14 @@
<aside
id="aside"
hx-swap-oob="outerHTML"
sx-swap-oob="outerHTML"
class="hidden md:flex md:flex-col max-w-xs md:h-full md:min-h-0 mr-3"
>
{% block aside %}
{% endblock %}
</aside>
<div id="root-menu" hx-swap-oob="outerHTML" class="md:hidden">
<div id="root-menu" sx-swap-oob="outerHTML" class="md:hidden">
{% block mobile_menu %}
{% endblock %}
</div>

View File

@@ -29,9 +29,9 @@
<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') }}"
hx-post="{{ url_for('ap_social.unfollow') }}"
hx-target="closest article"
hx-swap="outerHTML">
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">
@@ -40,9 +40,9 @@
</form>
{% else %}
<form method="post" action="{{ url_for('ap_social.follow') }}"
hx-post="{{ url_for('ap_social.follow') }}"
hx-target="closest article"
hx-swap="outerHTML">
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">
@@ -56,8 +56,8 @@
{% endfor %}
{% if actors | length >= 20 %}
<div hx-get="{{ url_for('ap_social.' ~ list_type ~ '_list_page', page=page + 1) }}"
hx-trigger="revealed"
hx-swap="outerHTML">
<div sx-get="{{ url_for('ap_social.' ~ list_type ~ '_list_page', page=page + 1) }}"
sx-trigger="revealed"
sx-swap="outerHTML">
</div>
{% endif %}

View File

@@ -27,9 +27,9 @@
<div class="flex-shrink-0">
{% if a.actor_url in (followed_urls or []) %}
<form method="post" action="{{ url_for('ap_social.unfollow') }}"
hx-post="{{ url_for('ap_social.unfollow') }}"
hx-target="closest article"
hx-swap="outerHTML">
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">
@@ -38,9 +38,9 @@
</form>
{% else %}
<form method="post" action="{{ url_for('ap_social.follow') }}"
hx-post="{{ url_for('ap_social.follow') }}"
hx-target="closest article"
hx-swap="outerHTML">
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">
@@ -54,8 +54,8 @@
{% endfor %}
{% if actors | length >= 20 %}
<div hx-get="{{ url_for('ap_social.search_page', q=query, page=page + 1) }}"
hx-trigger="revealed"
hx-swap="outerHTML">
<div sx-get="{{ url_for('ap_social.search_page', q=query, page=page + 1) }}"
sx-trigger="revealed"
sx-swap="outerHTML">
</div>
{% endif %}

View File

@@ -5,9 +5,9 @@
{% if items %}
{% set last = items[-1] %}
{% if timeline_type == "actor" %}
<div hx-get="{{ url_for('ap_social.actor_timeline_page', id=actor_id, before=last.published.isoformat()) }}"
hx-trigger="revealed"
hx-swap="outerHTML">
<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

@@ -6,9 +6,9 @@
<h1 class="text-2xl font-bold mb-6">Search</h1>
<form method="get" action="{{ url_for('ap_social.search') }}" class="mb-6"
hx-get="{{ url_for('ap_social.search_page') }}"
hx-target="#search-results"
hx-push-url="{{ url_for('ap_social.search') }}">
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"