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

@@ -83,8 +83,6 @@ async def _rich_error_page(errnum: str, message: str, image: str | None = None)
return None
try:
from shared.sexp.helpers import full_page
# Build a minimal context — avoid get_template_context() which
# calls cross-service fragment fetches that may fail.
from shared.infrastructure.context import base_context
@@ -110,18 +108,18 @@ async def _rich_error_page(errnum: str, message: str, image: str | None = None)
cart_params["user_id"] = ident["user_id"]
if ident["session_id"] is not None:
cart_params["session_id"] = ident["session_id"]
cart_mini_html, auth_menu_html, nav_tree_html = await fetch_fragments([
cart_mini, auth_menu, nav_tree = await fetch_fragments([
("cart", "cart-mini", cart_params or None),
("account", "auth-menu", {"email": user.email} if user else None),
("blog", "nav-tree", {"app_name": current_app.name, "path": request.path}),
])
ctx["cart_mini_html"] = cart_mini_html
ctx["auth_menu_html"] = auth_menu_html
ctx["nav_tree_html"] = nav_tree_html
ctx["cart_mini"] = cart_mini
ctx["auth_menu"] = auth_menu
ctx["nav_tree"] = nav_tree
except Exception:
ctx.setdefault("cart_mini_html", "")
ctx.setdefault("auth_menu_html", "")
ctx.setdefault("nav_tree_html", "")
ctx.setdefault("cart_mini", "")
ctx.setdefault("auth_menu", "")
ctx.setdefault("nav_tree", "")
# Try to hydrate post data from slug if not already available
segments = [s for s in request.path.strip("/").split("/") if s]
@@ -150,23 +148,23 @@ async def _rich_error_page(errnum: str, message: str, image: str | None = None)
# Root header (site nav bar)
from shared.sexp.helpers import (
root_header_html, post_header_html,
header_child_html, error_content_html,
root_header_sexp, post_header_sexp,
header_child_sexp, full_page_sexp, sexp_call,
)
hdr = root_header_html(ctx)
hdr = root_header_sexp(ctx)
# Post breadcrumb if we resolved a post
post = (post_data or {}).get("post") or ctx.get("post") or {}
if post.get("slug"):
ctx["post"] = post
post_row = post_header_html(ctx)
post_row = post_header_sexp(ctx)
if post_row:
hdr += header_child_html(post_row)
hdr = "(<> " + hdr + " " + header_child_sexp(post_row) + ")"
# Error content
error_html = error_content_html(errnum, message, image)
error_content = sexp_call("error-content", errnum=errnum, message=message, image=image)
return full_page(ctx, header_rows_html=hdr, content_html=error_html)
return full_page_sexp(ctx, header_rows=hdr, content=error_content)
except Exception:
current_app.logger.debug("Rich error page failed, falling back", exc_info=True)
return None
@@ -245,7 +243,7 @@ def errors(app):
status = getattr(e, "status_code", 400)
messages = getattr(e, "messages", [str(e)])
if request.headers.get("HX-Request") == "true":
if request.headers.get("SX-Request") == "true" or request.headers.get("HX-Request") == "true":
from shared.sexp.jinja_bridge import render as render_comp
items = "".join(
render_comp("error-list-item", message=str(escape(m)))
@@ -267,7 +265,7 @@ def errors(app):
msg = str(e)
# Extract service name from "Fragment account/auth-menu failed: ..."
service = msg.split("/")[0].replace("Fragment ", "") if "/" in msg else "unknown"
if request.headers.get("HX-Request") == "true":
if request.headers.get("SX-Request") == "true" or request.headers.get("HX-Request") == "true":
from shared.sexp.jinja_bridge import render as render_comp
return await make_response(
render_comp("fragment-error", service=str(escape(service))),
@@ -287,7 +285,7 @@ def errors(app):
if isinstance(e, HTTPException):
status = e.code or 500
if request.headers.get("HX-Request") == "true":
if request.headers.get("SX-Request") == "true" or request.headers.get("HX-Request") == "true":
return await make_response(
"Something went wrong. Please try again.",
status,

View File

@@ -75,7 +75,7 @@ def make_cache_key(cache_user_id: str) -> str:
qs = request.query_string.decode() if request.query_string else ""
# Check if this is an HTMX request
is_htmx = request.headers.get("HX-Request", "").lower() == "true"
is_htmx = request.headers.get("SX-Request", "").lower() == "true" or request.headers.get("HX-Request", "").lower() == "true"
htmx_suffix = ":htmx" if is_htmx else ""
if qs:

View File

@@ -1,46 +1,28 @@
"""HTMX utilities for detecting and handling HTMX requests."""
"""SxEngine request detection utilities."""
from quart import request
def is_htmx_request() -> bool:
"""
Check if the current request is an HTMX request.
"""Check if the current request is an SxEngine request."""
return request.headers.get("SX-Request", "").lower() == "true"
Returns:
bool: True if HX-Request header is present and true
"""
return request.headers.get("HX-Request", "").lower() == "true"
def is_sx_request() -> bool:
"""Check if the current request is an SxEngine request."""
return request.headers.get("SX-Request", "").lower() == "true"
def get_htmx_target() -> str | None:
"""
Get the target element ID from HTMX request headers.
Returns:
str | None: Target element ID or None
"""
return request.headers.get("HX-Target")
"""Get the target element ID from SX request headers."""
return request.headers.get("SX-Target")
def get_htmx_trigger() -> str | None:
"""
Get the trigger element ID from HTMX request headers.
Returns:
str | None: Trigger element ID or None
"""
"""Get the trigger element ID from request headers."""
return request.headers.get("HX-Trigger")
def should_return_fragment() -> bool:
"""
Determine if we should return a fragment vs full page.
For HTMX requests, return fragment.
For normal requests, return full page.
Returns:
bool: True if fragment should be returned
"""
"""Determine if we should return a fragment vs full page."""
return is_htmx_request()

View File

@@ -41,7 +41,7 @@ def vary(resp: Response) -> Response:
"""
v = resp.headers.get("Vary", "")
parts = [p.strip() for p in v.split(",") if p.strip()]
for h in ("HX-Request", "X-Origin"):
for h in ("SX-Request", "HX-Request", "X-Origin"):
if h not in parts:
parts.append(h)
if parts:

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"

View File

@@ -151,7 +151,7 @@ def create_ap_social_blueprint(app_name: str) -> Blueprint:
await services.federation.send_follow(
g._ap_s, actor.preferred_username, remote_actor_url,
)
if 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 redirect(request.referrer or url_for("ap_social.search"))
@@ -164,7 +164,7 @@ def create_ap_social_blueprint(app_name: str) -> Blueprint:
await services.federation.unfollow(
g._ap_s, actor.preferred_username, remote_actor_url,
)
if 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 redirect(request.referrer or url_for("ap_social.search"))

View File

@@ -69,7 +69,7 @@ async def base_context() -> dict:
Does NOT include cart, calendar_cart_entries, total, calendar_total,
or menu_items — those are added by each app's context_fn.
"""
is_htmx = request.headers.get("HX-Request") == "true"
is_htmx = request.headers.get("SX-Request") == "true" or request.headers.get("HX-Request") == "true"
search = request.headers.get("X-Search", "")
zap_filter = is_htmx and search == ""

View File

@@ -241,7 +241,7 @@ def create_base_app(
# Case 2: not logged in — prompt=none OAuth (GET, non-HTMX only)
if not uid and request.method == "GET":
if request.headers.get("HX-Request"):
if request.headers.get("SX-Request") or request.headers.get("HX-Request"):
return
import time as _time
now = _time.time()
@@ -295,7 +295,7 @@ def create_base_app(
if origin.endswith(".rose-ash.com") or origin.endswith(".localhost"):
response.headers["Access-Control-Allow-Origin"] = origin
response.headers["Access-Control-Allow-Credentials"] = "true"
response.headers["Access-Control-Allow-Headers"] = "HX-Request, HX-Target, HX-Current-URL, HX-Trigger, HX-Boosted, Content-Type"
response.headers["Access-Control-Allow-Headers"] = "SX-Request, SX-Target, SX-Current-URL, HX-Request, HX-Target, HX-Current-URL, HX-Trigger, Content-Type, X-CSRFToken"
return response
@app.after_request

View File

@@ -76,15 +76,18 @@ async def fetch_fragment(
timeout: float = _DEFAULT_TIMEOUT,
required: bool = True,
) -> str:
"""Fetch an HTML fragment from another app.
"""Fetch a fragment from another app.
Returns the raw HTML string. When *required* is True (default),
raises ``FragmentError`` on network errors or non-200 responses.
Returns an HTML string or a ``SexpExpr`` (when the provider responds
with ``text/sexp``). When *required* is True (default), raises
``FragmentError`` on network errors or non-200 responses.
When *required* is False, returns ``""`` on failure.
Automatically returns ``""`` when called inside a fragment request
to prevent circular dependencies between apps.
"""
from shared.sexp.parser import SexpExpr
if _is_fragment_request():
return ""
@@ -98,6 +101,9 @@ async def fetch_fragment(
timeout=timeout,
)
if resp.status_code == 200:
ct = resp.headers.get("content-type", "")
if "text/sexp" in ct:
return SexpExpr(resp.text)
return resp.text
msg = f"Fragment {app_name}/{fragment_type} returned {resp.status_code}"
if required:

View File

@@ -14,12 +14,12 @@ from shared.utils import host_url
def vary(resp):
"""
Ensure HX-Request and X-Origin are part of the Vary header
so caches distinguish HTMX from full-page requests.
Ensure SX-Request/HX-Request and X-Origin are part of the Vary header
so caches distinguish fragment from full-page requests.
"""
v = resp.headers.get("Vary", "")
parts = [p.strip() for p in v.split(",") if p.strip()]
for h in ("HX-Request", "X-Origin"):
for h in ("SX-Request", "HX-Request", "X-Origin"):
if h not in parts:
parts.append(h)
if parts:

View File

@@ -10,8 +10,8 @@ from typing import Any
from markupsafe import escape
from .jinja_bridge import render
from .page import SEARCH_HEADERS_MOBILE, SEARCH_HEADERS_DESKTOP
from .parser import SexpExpr
def call_url(ctx: dict, key: str, path: str = "/") -> str:
@@ -31,30 +31,33 @@ def get_asset_url(ctx: dict) -> str:
return au or ""
def root_header_html(ctx: dict, *, oob: bool = False) -> str:
"""Build the root header row HTML."""
# ---------------------------------------------------------------------------
# Sexp-native helper functions — return sexp source (not HTML)
# ---------------------------------------------------------------------------
def root_header_sexp(ctx: dict, *, oob: bool = False) -> str:
"""Build the root header row as a sexp call string."""
rights = ctx.get("rights") or {}
is_admin = rights.get("admin") if isinstance(rights, dict) else getattr(rights, "admin", False)
settings_url = call_url(ctx, "blog_url", "/settings/") if is_admin else ""
return render(
"header-row",
cart_mini_html=ctx.get("cart_mini_html", ""),
return sexp_call("header-row-sx",
cart_mini=ctx.get("cart_mini") and SexpExpr(str(ctx.get("cart_mini"))),
blog_url=call_url(ctx, "blog_url", ""),
site_title=ctx.get("base_title", ""),
app_label=ctx.get("app_label", ""),
nav_tree_html=ctx.get("nav_tree_html", ""),
auth_menu_html=ctx.get("auth_menu_html", ""),
nav_panel_html=ctx.get("nav_panel_html", ""),
nav_tree=ctx.get("nav_tree") and SexpExpr(str(ctx.get("nav_tree"))),
auth_menu=ctx.get("auth_menu") and SexpExpr(str(ctx.get("auth_menu"))),
nav_panel=ctx.get("nav_panel") and SexpExpr(str(ctx.get("nav_panel"))),
settings_url=settings_url,
is_admin=is_admin,
oob=oob,
)
def search_mobile_html(ctx: dict) -> str:
"""Build mobile search input HTML."""
return render(
"search-mobile",
def search_mobile_sexp(ctx: dict) -> str:
"""Build mobile search input as sexp call string."""
return sexp_call("search-mobile",
current_local_href=ctx.get("current_local_href", "/"),
search=ctx.get("search", ""),
search_count=ctx.get("search_count", ""),
@@ -63,10 +66,9 @@ def search_mobile_html(ctx: dict) -> str:
)
def search_desktop_html(ctx: dict) -> str:
"""Build desktop search input HTML."""
return render(
"search-desktop",
def search_desktop_sexp(ctx: dict) -> str:
"""Build desktop search input as sexp call string."""
return sexp_call("search-desktop",
current_local_href=ctx.get("current_local_href", "/"),
search=ctx.get("search", ""),
search_count=ctx.get("search_count", ""),
@@ -75,8 +77,8 @@ def search_desktop_html(ctx: dict) -> str:
)
def post_header_html(ctx: dict, *, oob: bool = False) -> str:
"""Build the post-level header row (level 1). Used by all apps + error pages."""
def post_header_sexp(ctx: dict, *, oob: bool = False) -> str:
"""Build the post-level header row as sexp call string."""
post = ctx.get("post") or {}
slug = post.get("slug", "")
if not slug:
@@ -84,25 +86,24 @@ def post_header_html(ctx: dict, *, oob: bool = False) -> str:
title = (post.get("title") or "")[:160]
feature_image = post.get("feature_image")
label_html = render("post-label", feature_image=feature_image, title=title)
label_sexp = sexp_call("post-label", feature_image=feature_image, title=title)
nav_parts: list[str] = []
page_cart_count = ctx.get("page_cart_count", 0)
if page_cart_count and page_cart_count > 0:
cart_href = call_url(ctx, "cart_url", f"/{slug}/")
nav_parts.append(render("page-cart-badge", href=cart_href, count=str(page_cart_count)))
nav_parts.append(sexp_call("page-cart-badge", href=cart_href, count=str(page_cart_count)))
container_nav = ctx.get("container_nav_html", "")
container_nav = ctx.get("container_nav")
if container_nav:
nav_parts.append(
'<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">'
f'{container_nav}</div>'
f'(div :id "entries-calendars-nav-wrapper"'
f' :class "flex flex-col sm:flex-row sm:items-center gap-2 border-r border-stone-200 mr-2 sm:max-w-2xl"'
f' {container_nav})'
)
# Admin cog — external link to blog admin (generic across all services)
admin_nav = ctx.get("post_admin_nav_html", "")
# Admin cog
admin_nav = ctx.get("post_admin_nav")
if not admin_nav:
rights = ctx.get("rights") or {}
has_admin = rights.get("admin") if isinstance(rights, dict) else getattr(rights, "admin", False)
@@ -114,39 +115,37 @@ def post_header_html(ctx: dict, *, oob: bool = False) -> str:
base_cls = ("justify-center cursor-pointer flex flex-row"
" items-center gap-2 rounded bg-stone-200 text-black p-3")
admin_nav = (
f'<div class="relative nav-group">'
f'<a href="{escape(admin_href)}"'
f' class="{base_cls} {sel_cls}">'
f'<i class="fa fa-cog" aria-hidden="true"></i></a></div>'
f'(div :class "relative nav-group"'
f' (a :href "{admin_href}"'
f' :class "{base_cls} {sel_cls}"'
f' (i :class "fa fa-cog" :aria-hidden "true")))'
)
if admin_nav:
nav_parts.append(admin_nav)
nav_html = "".join(nav_parts)
nav_sexp = "(<> " + " ".join(nav_parts) + ")" if nav_parts else None
link_href = call_url(ctx, "blog_url", f"/{slug}/")
return render("menu-row",
return sexp_call("menu-row-sx",
id="post-row", level=1,
link_href=link_href, link_label_html=label_html,
nav_html=nav_html, child_id="post-header-child",
link_href=link_href,
link_label_content=SexpExpr(label_sexp),
nav=SexpExpr(nav_sexp) if nav_sexp else None,
child_id="post-header-child",
oob=oob, external=True,
)
def post_admin_header_html(ctx: dict, slug: str, *, oob: bool = False,
selected: str = "", admin_href: str = "") -> str:
"""Shared post admin header row with unified nav across all services.
Shows: calendars | markets | payments | entries | data | edit | settings
All links are external (cross-service). The *selected* item is
highlighted on the nav and shown in white next to the admin label.
"""
# Label: shield icon + "admin" + optional selected sub-page in white
label_html = '<i class="fa fa-shield-halved" aria-hidden="true"></i> admin'
def post_admin_header_sexp(ctx: dict, slug: str, *, oob: bool = False,
selected: str = "", admin_href: str = "") -> str:
"""Post admin header row as sexp call string."""
# Label
label_parts = ['(i :class "fa fa-shield-halved" :aria-hidden "true")', '" admin"']
if selected:
label_html += f' <span class="text-white">{escape(selected)}</span>'
label_parts.append(f'(span :class "text-white" "{escape(selected)}")')
label_sexp = "(<> " + " ".join(label_parts) + ")"
# Nav items — all external links to the appropriate service
# Nav items
select_colours = ctx.get("select_colours", "")
base_cls = ("justify-center cursor-pointer flex flex-row items-center"
" gap-2 rounded bg-stone-200 text-black p-3")
@@ -169,75 +168,117 @@ def post_admin_header_html(ctx: dict, slug: str, *, oob: bool = False,
href = url_fn(path)
is_sel = label == selected
cls = selected_cls if is_sel else base_cls
aria = ' aria-selected="true"' if is_sel else ""
aria = "true" if is_sel else None
nav_parts.append(
f'<div class="relative nav-group">'
f'<a href="{escape(href)}"{aria}'
f' class="{cls} {escape(select_colours)}">'
f'{escape(label)}</a></div>'
f'(div :class "relative nav-group"'
f' (a :href "{escape(href)}"'
+ (f' :aria-selected "true"' if aria else "")
+ f' :class "{cls} {escape(select_colours)}"'
+ f' "{escape(label)}"))'
)
nav_html = "".join(nav_parts)
nav_sexp = "(<> " + " ".join(nav_parts) + ")" if nav_parts else None
if not admin_href:
blog_fn = ctx.get("blog_url")
admin_href = blog_fn(f"/{slug}/admin/") if callable(blog_fn) else f"/{slug}/admin/"
return render("menu-row",
return sexp_call("menu-row-sx",
id="post-admin-row", level=2,
link_href=admin_href, link_label_html=label_html,
nav_html=nav_html, child_id="post-admin-header-child", oob=oob,
link_href=admin_href,
link_label_content=SexpExpr(label_sexp),
nav=SexpExpr(nav_sexp) if nav_sexp else None,
child_id="post-admin-header-child", oob=oob,
)
def oob_header_html(parent_id: str, child_id: str, row_html: str) -> str:
"""Wrap a header row in an OOB swap div with child placeholder."""
return render("oob-header",
parent_id=parent_id, child_id=child_id, row_html=row_html,
def oob_header_sexp(parent_id: str, child_id: str, row_sexp: str) -> str:
"""Wrap a header row sexp in an OOB swap."""
return sexp_call("oob-header-sx",
parent_id=parent_id, child_id=child_id,
row=SexpExpr(row_sexp),
)
def header_child_html(inner_html: str, *, id: str = "root-header-child") -> str:
"""Wrap inner HTML in a header-child div."""
return render("header-child", id=id, inner_html=inner_html)
def error_content_html(errnum: str, message: str, image: str | None = None) -> str:
"""Render the error content block."""
return render("error-content", errnum=errnum, message=message, image=image)
def full_page(ctx: dict, *, header_rows_html: str,
filter_html: str = "", aside_html: str = "",
content_html: str = "", menu_html: str = "",
body_end_html: str = "", meta_html: str = "") -> str:
"""Render a full app page with the standard layout."""
return render(
"app-layout",
title=ctx.get("base_title", "Rose Ash"),
asset_url=get_asset_url(ctx),
meta_html=meta_html,
header_rows_html=header_rows_html,
menu_html=menu_html,
filter_html=filter_html,
aside_html=aside_html,
content_html=content_html,
body_end_html=body_end_html,
def header_child_sexp(inner_sexp: str, *, id: str = "root-header-child") -> str:
"""Wrap inner sexp in a header-child div."""
return sexp_call("header-child-sx",
id=id, inner=SexpExpr(inner_sexp),
)
def sexp_response(sexp_source: str, status: int = 200,
headers: dict | None = None):
def oob_page_sexp(*, oobs: str = "", filter: str = "", aside: str = "",
content: str = "", menu: str = "") -> str:
"""Build OOB response as sexp call string."""
return sexp_call("oob-sexp",
oobs=SexpExpr(oobs) if oobs else None,
filter=SexpExpr(filter) if filter else None,
aside=SexpExpr(aside) if aside else None,
menu=SexpExpr(menu) if menu else None,
content=SexpExpr(content) if content else None,
)
def full_page_sexp(ctx: dict, *, header_rows: str,
filter: str = "", aside: str = "",
content: str = "", menu: str = "",
meta_html: str = "", meta: str = "") -> str:
"""Build a full page using sexp_page() with ~app-body.
meta_html: raw HTML injected into the <head> shell (legacy).
meta: sexp source for meta tags — auto-hoisted to <head> by sexp.js.
"""
body_sexp = sexp_call("app-body",
header_rows=SexpExpr(header_rows) if header_rows else None,
filter=SexpExpr(filter) if filter else None,
aside=SexpExpr(aside) if aside else None,
menu=SexpExpr(menu) if menu else None,
content=SexpExpr(content) if content else None,
)
if meta:
# Wrap body + meta in a fragment so sexp.js renders both;
# auto-hoist moves meta/title/link elements to <head>.
body_sexp = "(<> " + meta + " " + body_sexp + ")"
return sexp_page(ctx, body_sexp, meta_html=meta_html)
def sexp_call(component_name: str, **kwargs: Any) -> str:
"""Build an s-expression component call string from Python kwargs.
Converts snake_case to kebab-case automatically::
sexp_call("test-row", nodeid="foo", outcome="passed")
# => '(~test-row :nodeid "foo" :outcome "passed")'
Values are serialized: strings are quoted, None becomes nil,
bools become true/false, numbers stay as-is.
"""
from .parser import serialize
name = component_name if component_name.startswith("~") else f"~{component_name}"
parts = [name]
for key, val in kwargs.items():
parts.append(f":{key.replace('_', '-')}")
parts.append(serialize(val))
return "(" + " ".join(parts) + ")"
def sexp_response(source_or_component: str, status: int = 200,
headers: dict | None = None, **kwargs: Any):
"""Return an s-expression wire-format response.
The client-side sexp.js will intercept responses with Content-Type
text/sexp and render them before HTMX swaps the result in.
Can be called with a raw sexp string::
Usage in a route handler::
return sexp_response('(~test-row :nodeid "foo")')
return sexp_response('(~test-row :nodeid "test_foo" :outcome "passed")')
Or with a component name + kwargs (builds the sexp call)::
return sexp_response("test-row", nodeid="foo", outcome="passed")
"""
from quart import Response
resp = Response(sexp_source, status=status, content_type="text/sexp")
if kwargs:
source = sexp_call(source_or_component, **kwargs)
else:
source = source_or_component
resp = Response(source, status=status, content_type="text/sexp")
if headers:
for k, v in headers.items():
resp.headers[k] = v
@@ -256,3 +297,105 @@ def oob_page(ctx: dict, *, oobs_html: str = "",
menu_html=menu_html,
content_html=content_html,
)
# ---------------------------------------------------------------------------
# Sexp wire-format full page shell
# ---------------------------------------------------------------------------
_SEXP_PAGE_TEMPLATE = """\
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<meta name="robots" content="index,follow">
<meta name="theme-color" content="#ffffff">
<title>{title}</title>
{meta_html}
<style>@media (min-width: 768px) {{ .js-mobile-sentinel {{ display:none !important; }} }}</style>
<link rel="stylesheet" type="text/css" href="{asset_url}/styles/basics.css">
<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">
<meta name="csrf-token" content="{csrf}">
<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">
<link href="https://unpkg.com/prismjs/themes/prism.css" rel="stylesheet">
<script src="https://unpkg.com/prismjs/prism.js"></script>
<script src="https://unpkg.com/prismjs/components/prism-javascript.min.js"></script>
<script src="https://unpkg.com/prismjs/components/prism-python.min.js"></script>
<script src="https://unpkg.com/prismjs/components/prism-bash.min.js"></script>
<script src="https://cdn.jsdelivr.net/npm/sweetalert2@11"></script>
<script>if(matchMedia('(hover:hover) and (pointer:fine)').matches){{document.documentElement.classList.add('hover-capable')}}</script>
<script>document.addEventListener('click',function(e){{var t=e.target.closest('[data-close-details]');if(!t)return;var d=t.closest('details');if(d)d.removeAttribute('open')}})</script>
<style>
details[data-toggle-group="mobile-panels"]>summary{{list-style:none}}
details[data-toggle-group="mobile-panels"]>summary::-webkit-details-marker{{display:none}}
@media(min-width:768px){{.nav-group:focus-within .submenu,.nav-group:hover .submenu{{display:block}}}}
img{{max-width:100%;height:auto}}
.clamp-2{{display:-webkit-box;-webkit-line-clamp:2;-webkit-box-orient:vertical;overflow:hidden}}
.clamp-3{{display:-webkit-box;-webkit-line-clamp:3;-webkit-box-orient:vertical;overflow:hidden}}
.no-scrollbar::-webkit-scrollbar{{display:none}}.no-scrollbar{{-ms-overflow-style:none;scrollbar-width:none}}
details.group{{overflow:hidden}}details.group>summary{{list-style:none}}details.group>summary::-webkit-details-marker{{display:none}}
.sx-indicator{{display:none}}.sx-request .sx-indicator{{display:inline-flex}}
.sx-error .sx-indicator{{display:none}}.sx-loading .sx-indicator{{display:inline-flex}}
.js-wrap.open .js-pop{{display:block}}.js-wrap.open .js-backdrop{{display:block}}
</style>
</head>
<body class="bg-stone-50 text-stone-900">
<script type="text/sexp" data-components>{component_defs}</script>
<script type="text/sexp" data-mount="body">{page_sexp}</script>
<script src="{asset_url}/scripts/sexp.js"></script>
<script src="{asset_url}/scripts/body.js"></script>
</body>
</html>"""
def sexp_page(ctx: dict, page_sexp: str, *,
meta_html: str = "") -> str:
"""Return a minimal HTML shell that boots the page from sexp source.
The browser loads component definitions and page sexp, then sexp.js
renders everything client-side.
"""
from .jinja_bridge import client_components_tag
components_tag = client_components_tag()
# Extract just the inner source from the <script> tag
component_defs = ""
if components_tag:
# Strip <script type="text/sexp" data-components>...</script>
start = components_tag.find(">") + 1
end = components_tag.rfind("</script>")
if start > 0 and end > start:
component_defs = components_tag[start:end]
asset_url = get_asset_url(ctx)
title = ctx.get("base_title", "Rose Ash")
csrf = _get_csrf_token()
return _SEXP_PAGE_TEMPLATE.format(
title=_html_escape(title),
asset_url=asset_url,
meta_html=meta_html,
csrf=_html_escape(csrf),
component_defs=component_defs,
page_sexp=page_sexp,
)
def _get_csrf_token() -> str:
"""Get the CSRF token from the current request context."""
try:
from quart import g
return getattr(g, "csrf_token", "")
except Exception:
return ""
def _html_escape(s: str) -> str:
"""Minimal HTML escaping for attribute values."""
return (s.replace("&", "&amp;")
.replace("<", "&lt;")
.replace(">", "&gt;")
.replace('"', "&quot;"))

View File

@@ -191,7 +191,7 @@ def _get_request_context():
try:
from quart import g, request
user = getattr(g, "user", None)
is_htmx = bool(request.headers.get("HX-Request"))
is_htmx = bool(request.headers.get("SX-Request") or request.headers.get("HX-Request"))
return RequestContext(user=user, is_htmx=is_htmx)
except Exception:
return RequestContext()

View File

@@ -88,7 +88,7 @@ async def render_sexp_response(source: str, **kwargs: Any) -> str:
"""Render an s-expression with the full app template context.
Calls the app's registered context processors (which provide
cart_mini_html, auth_menu_html, nav_tree_html, asset_url, etc.)
cart_mini, auth_menu, nav_tree, asset_url, etc.)
and merges them with the caller's kwargs before rendering.
Returns the rendered HTML string (caller wraps in Response as needed).

View File

@@ -21,6 +21,37 @@ from typing import Any
from .types import Keyword, Symbol, NIL
# ---------------------------------------------------------------------------
# SexpExpr — pre-built sexp source marker
# ---------------------------------------------------------------------------
class SexpExpr:
"""Pre-built sexp source that serialize() outputs unquoted.
Use this to nest sexp call strings inside other sexp_call() invocations
without them being quoted as strings::
sexp_call("parent", child=SexpExpr(sexp_call("child", x=1)))
# => (~parent :child (~child :x 1))
"""
__slots__ = ("source",)
def __init__(self, source: str):
self.source = source
def __repr__(self) -> str:
return f"SexpExpr({self.source!r})"
def __str__(self) -> str:
return self.source
def __add__(self, other: object) -> "SexpExpr":
return SexpExpr(self.source + str(other))
def __radd__(self, other: object) -> "SexpExpr":
return SexpExpr(str(other) + self.source)
# ---------------------------------------------------------------------------
# Errors
# ---------------------------------------------------------------------------
@@ -230,6 +261,9 @@ def _parse_map(tok: Tokenizer) -> dict[str, Any]:
def serialize(expr: Any, indent: int = 0, pretty: bool = False) -> str:
"""Serialize a value back to s-expression text."""
if isinstance(expr, SexpExpr):
return expr.source
if isinstance(expr, list):
if not expr:
return "()"
@@ -269,6 +303,13 @@ def serialize(expr: Any, indent: int = 0, pretty: bool = False) -> str:
items.append(serialize(v, indent, pretty))
return "{" + " ".join(items) + "}"
# Catch callables (Python functions leaked into sexp data)
if callable(expr):
import logging
logging.getLogger("sexp").error(
"serialize: callable leaked into sexp data: %r", expr)
return "nil"
# Fallback for Lambda/Component — show repr
return repr(expr)

View File

@@ -1,14 +1,14 @@
(defcomp ~post-card (&key title slug href feature-image excerpt
status published-at updated-at publish-requested
hx-select like-html widgets-html at-bar-html)
hx-select like widgets at-bar)
(article :class "border-b pb-6 last:border-b-0 relative"
(when like-html (raw! like-html))
(when like like)
(a :href href
:hx-get href
:hx-target "#main-panel"
:hx-select hx-select
:hx-swap "outerHTML"
:hx-push-url "true"
:sx-get href
:sx-target "#main-panel"
:sx-select hx-select
:sx-swap "outerHTML"
:sx-push-url "true"
:class "block rounded-xl bg-white shadow hover:shadow-md transition overflow-hidden"
(header :class "mb-2 text-center"
(h2 :class "text-4xl font-bold text-stone-900" title)
@@ -28,8 +28,8 @@
(img :src feature-image :alt "" :class "rounded-lg w-full object-cover")))
(when excerpt
(p :class "text-stone-700 text-lg leading-relaxed text-center" excerpt)))
(when widgets-html (raw! widgets-html))
(when at-bar-html (raw! at-bar-html))))
(when widgets widgets)
(when at-bar at-bar)))
(defcomp ~order-summary-card (&key order-id created-at description status currency total-amount)
(div :class "rounded-2xl border border-stone-200 bg-white/80 p-4 sm:p-6 space-y-2 text-xs sm:text-sm text-stone-800"

View File

@@ -4,21 +4,21 @@
(input :id "search-mobile"
:type "text" :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 true
:sx-preserve true
:value (or search "")
:placeholder "search"
:hx-trigger "input changed delay:300ms"
:hx-target "#main-panel"
:hx-select (str (or hx-select "#main-panel") ", #search-mobile-wrapper, #search-desktop-wrapper")
:hx-get current-local-href
:hx-swap "outerHTML"
:hx-push-url "true"
:hx-headers search-headers-mobile
:hx-sync "this:replace"
:sx-trigger "input changed delay:300ms"
:sx-target "#main-panel"
:sx-select (str (or hx-select "#main-panel") ", #search-mobile-wrapper, #search-desktop-wrapper")
:sx-get current-local-href
:sx-swap "outerHTML"
:sx-push-url "true"
:sx-headers search-headers-mobile
:sx-sync "this:replace"
:autocomplete "off")
(div :id "search-count-mobile" :aria-label "search count"
:class (if (not search-count) "text-xl text-red-500" "")
(when search (raw! (str search-count))))))
(when search (str search-count)))))
(defcomp ~search-desktop (&key current-local-href search search-count hx-select search-headers-desktop)
(div :id "search-desktop-wrapper"
@@ -26,23 +26,23 @@
(input :id "search-desktop"
:type "text" :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 true
:sx-preserve true
:value (or search "")
:placeholder "search"
:hx-trigger "input changed delay:300ms"
:hx-target "#main-panel"
:hx-select (str (or hx-select "#main-panel") ", #search-mobile-wrapper, #search-desktop-wrapper")
:hx-get current-local-href
:hx-swap "outerHTML"
:hx-push-url "true"
:hx-headers search-headers-desktop
:hx-sync "this:replace"
:sx-trigger "input changed delay:300ms"
:sx-target "#main-panel"
:sx-select (str (or hx-select "#main-panel") ", #search-mobile-wrapper, #search-desktop-wrapper")
:sx-get current-local-href
:sx-swap "outerHTML"
:sx-push-url "true"
:sx-headers search-headers-desktop
:sx-sync "this:replace"
:autocomplete "off")
(div :id "search-count-desktop" :aria-label "search count"
:class (if (not search-count) "text-xl text-red-500" "")
(when search (raw! (str search-count))))))
(when search (str search-count)))))
(defcomp ~mobile-filter (&key filter-summary-html action-buttons-html filter-details-html)
(defcomp ~mobile-filter (&key filter-summary action-buttons filter-details)
(details :class "group/filter p-2 md:hidden" :data-toggle-group "mobile-panels"
(summary :class "bg-white/90"
(div :class "flex flex-row items-start"
@@ -57,58 +57,30 @@
(div :id "filter-summary-mobile"
:class "flex-1 md:hidden grid grid-cols-12 items-center gap-3"
(div :class "flex flex-col items-start gap-2"
(raw! filter-summary-html)))))
(raw! (or action-buttons-html ""))
(when filter-summary filter-summary)))))
(when action-buttons action-buttons)
(div :id "filter-details-mobile" :style "display:contents"
(raw! (or filter-details-html "")))))
(when filter-details filter-details))))
(defcomp ~infinite-scroll (&key url page total-pages id-prefix colspan)
(if (< page total-pages)
(raw! (str
"<tr id=\"" id-prefix "-sentinel-" page "\""
" hx-get=\"" url "\""
" hx-trigger=\"intersect once delay:250ms, sentinel:retry\""
" hx-swap=\"outerHTML\""
" _=\""
"init "
"if not me.dataset.retryMs then set me.dataset.retryMs to 1000 end "
"on sentinel:retry "
"remove .hidden from .js-loading in me "
"add .hidden to .js-neterr in me "
"set me.style.pointerEvents to 'none' "
"set me.style.opacity to '0' "
"trigger htmx:consume on me "
"call htmx.trigger(me, 'intersect') "
"end "
"def backoff() "
"add .hidden to .js-loading in me "
"remove .hidden from .js-neterr in me "
"set myMs to Number(me.dataset.retryMs) "
"if myMs < 10000 then set me.dataset.retryMs to myMs * 2 end "
"js setTimeout(() => htmx.trigger(me, 'sentinel:retry'), myMs) "
"end "
"on htmx:beforeRequest "
"set me.style.pointerEvents to 'none' "
"set me.style.opacity to '0' "
"end "
"on htmx:afterSwap set me.dataset.retryMs to 1000 end "
"on htmx:sendError call backoff() "
"on htmx:responseError call backoff() "
"on htmx:timeout call backoff()"
"\""
" role=\"status\" aria-live=\"polite\" aria-hidden=\"true\">"
"<td colspan=\"" colspan "\" class=\"px-3 py-4\">"
"<div class=\"block md:hidden h-[60vh] js-mobile-sentinel\">"
"<div class=\"js-loading text-center text-xs text-stone-400\">loading\u2026 " page " / " total-pages "</div>"
"<div class=\"js-neterr hidden flex h-full items-center justify-center\"></div>"
"</div>"
"<div class=\"hidden md:block h-[30vh] js-desktop-sentinel\">"
"<div class=\"js-loading text-center text-xs text-stone-400\">loading\u2026 " page " / " total-pages "</div>"
"<div class=\"js-neterr hidden inset-0 grid place-items-center p-4\"></div>"
"</div>"
"</td></tr>"))
(raw! (str
"<tr><td colspan=\"" colspan "\" class=\"px-3 py-4 text-center text-xs text-stone-400\">End of results</td></tr>"))))
(tr :id (str id-prefix "-sentinel-" page)
:sx-get url
:sx-trigger "intersect once delay:250ms"
:sx-swap "outerHTML"
:sx-retry "exponential:1000:30000"
:role "status" :aria-live "polite" :aria-hidden "true"
(td :colspan colspan :class "px-3 py-4"
(div :class "block md:hidden h-[60vh] js-mobile-sentinel"
(div :class "sx-indicator js-loading text-center text-xs text-stone-400"
(str "loading\u2026 " page " / " total-pages))
(div :class "js-neterr hidden flex h-full items-center justify-center"))
(div :class "hidden md:block h-[30vh] js-desktop-sentinel"
(div :class "sx-indicator js-loading text-center text-xs text-stone-400"
(str "loading\u2026 " page " / " total-pages))
(div :class "js-neterr hidden inset-0 grid place-items-center p-4"))))
(tr (td :colspan colspan :class "px-3 py-4 text-center text-xs text-stone-400"
"End of results"))))
(defcomp ~status-pill (&key status size)
(let* ((s (or status "pending"))

View File

@@ -3,7 +3,7 @@
:class "block rounded border border-stone-200 bg-white hover:bg-stone-50 transition-colors no-underline"
:data-fragment "link-card"
:data-app data-app
:data-hx-disable true
:sx-disable true
(div :class "flex flex-row items-start gap-3 p-3"
(if image
(img :src image :alt "" :class "flex-shrink-0 w-16 h-16 rounded object-cover")
@@ -18,7 +18,7 @@
(defcomp ~cart-mini (&key cart-count blog-url cart-url oob)
(div :id "cart-mini"
:hx-swap-oob oob
:sx-swap-oob oob
(if (= cart-count 0)
(div :class "h-12 w-12 rounded-full overflow-hidden border border-stone-300 flex-shrink-0"
(a :href blog-url
@@ -58,5 +58,5 @@
(div :class "relative nav-group"
(a :href href
:class "justify-center cursor-pointer flex flex-row items-center gap-2 rounded bg-stone-200 text-black p-3"
:data-hx-disable true
:sx-disable true
label)))

View File

@@ -10,9 +10,7 @@
(link :rel "stylesheet" :type "text/css" :href (str asset-url "/styles/basics.css"))
(link :rel "stylesheet" :type "text/css" :href (str asset-url "/styles/cards.css"))
(link :rel "stylesheet" :type "text/css" :href (str asset-url "/styles/blog-content.css"))
(script :src "https://unpkg.com/htmx.org@2.0.8")
(meta :name "htmx-config" :content "{\"selfRequestsOnly\":false}")
(script :src "https://unpkg.com/hyperscript.org@0.9.12")
(meta :name "csrf-token" :content "")
(script :src "https://cdn.tailwindcss.com")
(link :rel "stylesheet" :href (str asset-url "/fontawesome/css/all.min.css"))
(link :rel "stylesheet" :href (str asset-url "/fontawesome/css/v4-shims.min.css"))
@@ -33,7 +31,8 @@
".clamp-3{display:-webkit-box;-webkit-line-clamp:3;-webkit-box-orient:vertical;overflow:hidden}"
".no-scrollbar::-webkit-scrollbar{display:none}.no-scrollbar{-ms-overflow-style:none;scrollbar-width:none}"
"details.group{overflow:hidden}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}"
".js-wrap.open .js-pop{display:block}.js-wrap.open .js-backdrop{display:block}")))
(defcomp ~app-layout (&key title asset-url meta-html menu-colour
@@ -55,7 +54,7 @@
:class (str "flex items-start gap-2 p-1 bg-" colour "-500")
(div :class "flex flex-col w-full items-center"
(when header-rows-html (raw! header-rows-html))))))
(div :id "root-menu" :hx-swap-oob "outerHTML" :class "md:hidden"
(div :id "root-menu" :sx-swap-oob "outerHTML" :class "md:hidden"
(when menu-html (raw! menu-html)))))
(div :id "filter"
(when filter-html (raw! filter-html)))
@@ -73,20 +72,60 @@
(script :src (str asset-url "/scripts/sexp.js"))
(script :src (str asset-url "/scripts/body.js")))))))
(defcomp ~app-body (&key header-rows filter aside menu content)
(div :class "max-w-screen-2xl mx-auto py-1 px-1"
(div :class "w-full"
(details :class "group/root p-2" :data-toggle-group "mobile-panels"
(summary
(header :class "z-50"
(div :id "root-header-summary"
:class "flex items-start gap-2 p-1 bg-sky-500"
(div :class "flex flex-col w-full items-center"
(when header-rows header-rows)))))
(div :id "root-menu" :sx-swap-oob "outerHTML" :class "md:hidden"
(when menu menu))))
(div :id "filter"
(when filter filter))
(main :id "root-panel" :class "max-w-full"
(div :class "md:min-h-0"
(div :class "flex flex-row md:h-full md:min-h-0"
(aside :id "aside"
:class "hidden md:flex md:flex-col max-w-xs md:h-full md:min-h-0 mr-3"
(when aside aside))
(section :id "main-panel"
:class "flex-1 md:h-full md:min-h-0 overflow-y-auto overscroll-contain js-grid-viewport"
(when content content)
(div :class "pb-8")))))))
(defcomp ~oob-response (&key oobs-html filter-html aside-html menu-html content-html)
(<>
(when oobs-html (raw! oobs-html))
(div :id "filter" :hx-swap-oob "outerHTML"
(div :id "filter" :sx-swap-oob "outerHTML"
(when filter-html (raw! filter-html)))
(aside :id "aside" :hx-swap-oob "outerHTML"
(aside :id "aside" :sx-swap-oob "outerHTML"
:class "hidden md:flex md:flex-col max-w-xs md:h-full md:min-h-0 mr-3"
(when aside-html (raw! aside-html)))
(div :id "root-menu" :hx-swap-oob "outerHTML" :class "md:hidden"
(div :id "root-menu" :sx-swap-oob "outerHTML" :class "md:hidden"
(when menu-html (raw! menu-html)))
(section :id "main-panel"
:class "flex-1 md:h-full md:min-h-0 overflow-y-auto overscroll-contain js-grid-viewport"
(when content-html (raw! content-html)))))
;; Sexp-native OOB response — accepts nested sexp expressions, no raw!
(defcomp ~oob-sexp (&key oobs filter aside menu content)
(<>
(when oobs oobs)
(div :id "filter" :sx-swap-oob "outerHTML"
(when filter filter))
(aside :id "aside" :sx-swap-oob "outerHTML"
:class "hidden md:flex md:flex-col max-w-xs md:h-full md:min-h-0 mr-3"
(when aside aside))
(div :id "root-menu" :sx-swap-oob "outerHTML" :class "md:hidden"
(when menu menu))
(section :id "main-panel"
:class "flex-1 md:h-full md:min-h-0 overflow-y-auto overscroll-contain js-grid-viewport"
(when content content))))
(defcomp ~hamburger ()
(div :class "md:hidden bg-stone-200 rounded"
(svg :class "h-12 w-12 transition-transform group-open/root:hidden block self-start"
@@ -102,7 +141,7 @@
settings-url is-admin oob)
(<>
(div :id "root-row"
:hx-swap-oob (if oob "outerHTML" nil)
:sx-swap-oob (if oob "outerHTML" nil)
:class "flex flex-col items-center md:flex-row justify-center md:justify-between w-full p-1 bg-sky-500"
(div :class "w-full flex flex-row items-top"
(when cart-mini-html (raw! cart-mini-html))
@@ -127,15 +166,15 @@
(shade (str (- 500 (* lv 100)))))
(<>
(div :id id
:hx-swap-oob (if oob "outerHTML" nil)
:sx-swap-oob (if oob "outerHTML" nil)
:class (str "flex flex-col items-center md:flex-row justify-center md:justify-between w-full p-1 bg-" c "-" shade)
(div :class "relative nav-group"
(a :href link-href
:hx-get (if external nil link-href)
:hx-target (if external nil "#main-panel")
:hx-select (if external nil (or hx-select "#main-panel"))
:hx-swap (if external nil "outerHTML")
:hx-push-url (if external nil "true")
:sx-get (if external nil link-href)
:sx-target (if external nil "#main-panel")
:sx-select (if external nil (or hx-select "#main-panel"))
:sx-swap (if external nil "outerHTML")
:sx-push-url (if external nil "true")
:class "w-full whitespace-normal flex items-center gap-2 font-bold text-2xl px-3 py-2"
(when icon (i :class icon :aria-hidden "true"))
(if link-label-html (raw! link-label-html)
@@ -158,13 +197,75 @@
(span count)))
(defcomp ~oob-header (&key parent-id child-id row-html)
(div :id parent-id :hx-swap-oob "outerHTML" :class "w-full"
(div :id parent-id :sx-swap-oob "outerHTML" :class "w-full"
(div :class "w-full" (raw! row-html)
(div :id child-id))))
(defcomp ~header-child (&key id inner-html)
(div :id (or id "root-header-child") :class "w-full" (raw! inner-html)))
;; Sexp-native header-row — accepts nested sexp expressions, no raw!
(defcomp ~header-row-sx (&key cart-mini blog-url site-title app-label
nav-tree auth-menu nav-panel
settings-url is-admin oob)
(<>
(div :id "root-row"
:sx-swap-oob (if oob "outerHTML" nil)
:class "flex flex-col items-center md:flex-row justify-center md:justify-between w-full p-1 bg-sky-500"
(div :class "w-full flex flex-row items-top"
(when cart-mini cart-mini)
(div :class "font-bold text-5xl flex-1"
(a :href (or blog-url "/") :class "flex justify-center md:justify-start items-baseline gap-2"
(h1 (or site-title ""))))
(nav :class "hidden md:flex gap-4 text-sm ml-2 justify-end items-center flex-0"
(when nav-tree nav-tree)
(when auth-menu auth-menu)
(when nav-panel nav-panel)
(when (and is-admin settings-url)
(a :href settings-url :class "justify-center cursor-pointer flex flex-row items-center gap-2 rounded bg-stone-200 text-black p-3"
(i :class "fa fa-cog" :aria-hidden "true"))))
(~hamburger)))
(div :class "block md:hidden text-md font-bold"
(when auth-menu auth-menu))))
;; Sexp-native menu-row — accepts nested sexp expressions, no raw!
(defcomp ~menu-row-sx (&key id level colour link-href link-label link-label-content icon
hx-select nav child-id child oob external)
(let* ((c (or colour "sky"))
(lv (or level 1))
(shade (str (- 500 (* lv 100)))))
(<>
(div :id id
:sx-swap-oob (if oob "outerHTML" nil)
:class (str "flex flex-col items-center md:flex-row justify-center md:justify-between w-full p-1 bg-" c "-" shade)
(div :class "relative nav-group"
(a :href link-href
:sx-get (if external nil link-href)
:sx-target (if external nil "#main-panel")
:sx-select (if external nil (or hx-select "#main-panel"))
:sx-swap (if external nil "outerHTML")
:sx-push-url (if external nil "true")
:class "w-full whitespace-normal flex items-center gap-2 font-bold text-2xl px-3 py-2"
(when icon (i :class icon :aria-hidden "true"))
(if link-label-content link-label-content
(when link-label (div link-label)))))
(when nav
(nav :class "hidden md:flex gap-4 text-sm ml-2 justify-end items-center flex-0"
nav)))
(when (and child-id (not oob))
(div :id child-id :class "flex flex-col w-full items-center"
(when child child))))))
;; Sexp-native oob-header — accepts nested sexp expression, no raw!
(defcomp ~oob-header-sx (&key parent-id child-id row)
(div :id parent-id :sx-swap-oob "outerHTML" :class "w-full"
(div :class "w-full" row
(div :id child-id))))
;; Sexp-native header-child — accepts nested sexp expression, no raw!
(defcomp ~header-child-sx (&key id inner)
(div :id (or id "root-header-child") :class "w-full" inner))
(defcomp ~error-content (&key errnum message image)
(div :class "text-center p-8 max-w-lg mx-auto"
(div :class "font-bold text-2xl md:text-4xl text-red-500 mb-4" errnum)
@@ -176,11 +277,11 @@
(defcomp ~nav-link (&key href hx-select label icon aclass select-colours is-selected)
(div :class "relative nav-group"
(a :href href
:hx-get href
:hx-target "#main-panel"
:hx-select (or hx-select "#main-panel")
:hx-swap "outerHTML"
:hx-push-url "true"
:sx-get href
:sx-target "#main-panel"
:sx-select (or hx-select "#main-panel")
:sx-swap "outerHTML"
:sx-push-url "true"
:aria-selected (when is-selected "true")
:class (or aclass
(str "justify-center cursor-pointer flex flex-row items-center gap-2 rounded bg-stone-200 text-black p-3 "

View File

@@ -1,30 +1,35 @@
;; Miscellaneous shared components for Phase 3 conversion
;; The single place where raw! lives — for CMS content (Ghost post body,
;; product descriptions, etc.) that arrives as pre-rendered HTML.
(defcomp ~rich-text (&key html)
(raw! html))
(defcomp ~error-inline (&key message)
(div :class "text-red-600 text-sm" (raw! message)))
(div :class "text-red-600 text-sm" message))
(defcomp ~notification-badge (&key count)
(span :class "bg-red-500 text-white text-xs rounded-full px-1.5 py-0.5" (raw! count)))
(span :class "bg-red-500 text-white text-xs rounded-full px-1.5 py-0.5" count))
(defcomp ~cache-cleared (&key time-str)
(span :class "text-green-600 font-bold" "Cache cleared at " (raw! time-str)))
(span :class "text-green-600 font-bold" "Cache cleared at " time-str))
(defcomp ~error-list (&key items-html)
(defcomp ~error-list (&key items)
(ul :class "list-disc pl-5 space-y-1 text-sm text-red-600"
(raw! items-html)))
(when items items)))
(defcomp ~error-list-item (&key message)
(li (raw! message)))
(li message))
(defcomp ~fragment-error (&key service)
(p :class "text-sm text-red-600" "Service " (b (raw! service)) " is unavailable."))
(p :class "text-sm text-red-600" "Service " (b service) " is unavailable."))
(defcomp ~htmx-sentinel (&key id hx-get hx-trigger hx-swap class extra-attrs)
(div :id id :hx-get hx-get :hx-trigger hx-trigger :hx-swap hx-swap :class class))
(div :id id :sx-get hx-get :sx-trigger hx-trigger :sx-swap hx-swap :class class))
(defcomp ~nav-group-link (&key href hx-select nav-class label)
(div :class "relative nav-group"
(a :href href :hx-get href :hx-target "#main-panel"
:hx-select hx-select :hx-swap "outerHTML"
:hx-push-url "true" :class nav-class
(raw! label))))
(a :href href :sx-get href :sx-target "#main-panel"
:sx-select hx-select :sx-swap "outerHTML"
:sx-push-url "true" :class nav-class
label)))

View File

@@ -1,15 +1,15 @@
(defcomp ~relation-attach (&key create-url label icon)
(a :href create-url
:hx-get create-url
:hx-target "#main-panel"
:hx-swap "outerHTML"
:hx-push-url "true"
:sx-get create-url
:sx-target "#main-panel"
:sx-swap "outerHTML"
:sx-push-url "true"
:class "flex items-center gap-2 text-sm p-2 rounded hover:bg-stone-100 text-stone-500 hover:text-stone-700 transition-colors"
(when icon (i :class icon))
(span (or label "Add"))))
(defcomp ~relation-detach (&key detach-url name)
(button :hx-delete detach-url
:hx-confirm (str "Remove " (or name "this item") "?")
(button :sx-delete detach-url
:sx-confirm (str "Remove " (or name "this item") "?")
:class "text-red-500 hover:text-red-700 text-sm p-1 rounded hover:bg-red-50 transition-colors"
(i :class "fa fa-times" :aria-hidden "true")))

View File

@@ -203,3 +203,31 @@ class TestPythonParity:
py_html = py_render(parse(call_text), env)
js_html = _js_render(call_text, comp_text)
assert js_html == py_html
MAP_CASES = [
# map with lambda returning HTML element
(
"",
'(ul (map (lambda (x) (li x)) ("a" "b" "c")))',
),
# map with lambda returning component
(
'(defcomp ~item (&key name) (span :class "item" name))',
'(div (map (lambda (t) (~item :name (get t "name"))) ({"name" "Alice"} {"name" "Bob"})))',
),
# map-indexed with lambda
(
"",
'(ul (map-indexed (lambda (i x) (li (str i ". " x))) ("foo" "bar")))',
),
]
@pytest.mark.parametrize("comp_text,call_text", MAP_CASES)
def test_map_lambda_render(self, comp_text, call_text):
env = {}
if comp_text:
for expr in parse_all(comp_text):
evaluate(expr, env)
py_html = py_render(parse(call_text), env)
js_html = _js_render(call_text, comp_text)
assert js_html == py_html, f"Mismatch:\n PY: {py_html!r}\n JS: {js_html!r}"

View File

@@ -36,7 +36,7 @@
// 2. Image gallery
// - Supports multiple galleries via [data-gallery-root]
// - Thumbnail navigation, prev/next arrows, keyboard arrows, touch swipe
// - HTMX-aware: runs on initial load and after HTMX swaps
// - Runs on initial load and after SxEngine swaps
// ============================================================================
(() => {
@@ -62,11 +62,11 @@
/**
* Initialize a single gallery instance.
* This attaches handlers only once, even if HTMX re-inserts the fragment.
* This attaches handlers only once, even if SxEngine re-inserts the fragment.
* @param {Element} root - Element with [data-gallery-root].
*/
function initOneGallery(root) {
// Prevent double-initialization (HTMX may re-insert the same fragment)
// Prevent double-initialization (SxEngine may re-insert the same fragment)
if (root.dataset.galleryInitialized === 'true') return;
root.dataset.galleryInitialized = 'true';
@@ -189,18 +189,10 @@
initGallery(document);
});
// Re-initialize galleries inside new fragments from HTMX
if (window.htmx) {
// htmx.onLoad runs on initial load and after each swap
htmx.onLoad((content) => {
initGallery(content);
});
// Alternative:
// htmx.on('htmx:afterSwap', (evt) => {
// initGallery(evt.detail.target);
// });
}
// Re-initialize galleries after SxEngine swaps
document.addEventListener('sx:afterSettle', (evt) => {
initGallery(evt.detail?.target || document);
});
})();
@@ -319,7 +311,7 @@
initPeek();
}
// Expose for dynamic inserts (e.g., from HTMX or other JS)
// Expose for dynamic inserts (e.g., from SxEngine or other JS)
window.initPeekScroll = initPeek;
})();
@@ -327,7 +319,7 @@
// ============================================================================
// 4. Exclusive <details> behavior
// - Only one <details> with the same [data-toggle-group] is open at a time
// - Respects HTMX swaps by re-attaching afterSwap
// - Respects SxEngine swaps by re-attaching afterSettle
// - Scrolls to top when opening a panel
// ============================================================================
@@ -369,25 +361,22 @@ function attachExclusiveDetailsBehavior(root = document) {
// Initial binding on page load
attachExclusiveDetailsBehavior();
// Re-bind for new content after HTMX swaps
document.body.addEventListener('htmx:afterSwap', function (evt) {
attachExclusiveDetailsBehavior(evt.target);
// Re-bind for new content after SxEngine swaps
document.body.addEventListener('sx:afterSettle', function (evt) {
attachExclusiveDetailsBehavior(evt.detail?.target || document);
});
// ============================================================================
// 5. Close <details> panels before HTMX requests
// - When a link/button inside a <details[data-toggle-group]> triggers HTMX,
// 5. Close <details> panels before SxEngine requests
// - When a link/button inside a <details[data-toggle-group]> triggers SxEngine,
// we close that panel and scroll to top.
// ============================================================================
document.body.addEventListener('htmx:beforeRequest', function (evt) {
document.body.addEventListener('sx:beforeRequest', function (evt) {
const triggerEl = evt.target;
// Find the closest <details> panel (e.g., mobile panel, filters, etc.)
const panel = triggerEl.closest('details[data-toggle-group]');
if (!panel) return;
panel.open = false;
window.scrollTo(0, 0);
});
@@ -397,7 +386,7 @@ document.body.addEventListener('htmx:beforeRequest', function (evt) {
// 6. Ghost / Koenig video card fix
// - Ghost/Koenig editors may output <figure class="kg-video-card"><video>...</video></figure>
// - This replaces the <figure> with just the <video>, and enforces some defaults.
// - Works on initial load, HTMX swaps, and general DOM inserts.
// - Works on initial load, SxEngine swaps, and general DOM inserts.
// ============================================================================
(function () {
@@ -433,9 +422,9 @@ document.body.addEventListener('htmx:beforeRequest', function (evt) {
replaceKgFigures();
}
// After HTMX swaps, fix any new figures
document.addEventListener('htmx:afterSwap', e =>
replaceKgFigures(e.target || document)
// After SxEngine swaps, fix any new figures
document.addEventListener('sx:afterSettle', e =>
replaceKgFigures(e.detail?.target || document)
);
// Fallback: MutationObserver for other dynamic content inserts
@@ -487,7 +476,7 @@ document.addEventListener('DOMContentLoaded', () => {
// - Targets iframes from youtube.com / youtube-nocookie.com
// - Removes width/height attributes so CSS can take over
// - Applies aspect-ratio if supported, else JS resize fallback
// - Works on initial load + after HTMX swaps
// - Works on initial load + after SxEngine swaps
// ============================================================================
(function () {
@@ -535,150 +524,100 @@ document.addEventListener('DOMContentLoaded', () => {
fixYouTubeIframes();
}
// Run after HTMX swaps
document.addEventListener('htmx:afterSwap', e =>
fixYouTubeIframes(e.target || document)
// Run after SxEngine swaps
document.addEventListener('sx:afterSettle', e =>
fixYouTubeIframes(e.detail?.target || document)
);
})();
// ============================================================================
// 9. HTMX global error handler (SweetAlert2)
// - Listens for htmx:responseError events
// 9. SxEngine global error handler (SweetAlert2)
// - Listens for sx:responseError events
// - Extracts error info from JSON or HTML responses
// - Shows a SweetAlert error modal with details
// ============================================================================
document.body.addEventListener("htmx:responseError", function (event) {
const xhr = event.detail.xhr;
const status = xhr.status;
const contentType = xhr.getResponseHeader("Content-Type") || "";
const triggerEl = event.detail.elt; // element that fired the request
const form = triggerEl ? triggerEl.closest("form") : null;
// SxEngine global error handler
document.body.addEventListener("sx:responseError", function (event) {
var resp = event.detail.response;
if (!resp) return;
var status = resp.status || 0;
var triggerEl = event.target;
var form = triggerEl ? triggerEl.closest("form") : null;
let title = "Something went wrong";
if (status >= 500) {
title = "Server error";
} else if (status >= 400) {
title = "There was a problem with your request";
}
var title = "Something went wrong";
if (status >= 500) title = "Server error";
else if (status >= 400) title = "There was a problem with your request";
let message = "";
let fieldErrors = null;
let html = "";
resp.text().then(function (text) {
var contentType = resp.headers.get("Content-Type") || "";
var message = "";
var html = "";
var fieldErrors = null;
if (contentType.includes("application/json")) {
try {
const data = JSON.parse(xhr.responseText || "{}");
message = data.message || "";
// We expect errors as an object: { field: [msg, ...], ... }
if (data.errors && typeof data.errors === "object" && !Array.isArray(data.errors)) {
fieldErrors = data.errors;
// Build a bullet list for SweetAlert
const allMessages = [];
for (const [field, msgs] of Object.entries(data.errors)) {
const arr = Array.isArray(msgs) ? msgs : [msgs];
allMessages.push(...arr);
}
if (allMessages.length) {
html =
"<ul style='text-align:left;margin:0;padding-left:1.25rem;'>" +
allMessages.map((e) => `<li>${e}</li>`).join("") +
"</ul>";
}
} else if (Array.isArray(data.errors)) {
// Legacy shape: errors: ["msg1", "msg2"]
html =
"<ul style='text-align:left;margin:0;padding-left:1.25rem;'>" +
data.errors.map((e) => `<li>${e}</li>`).join("") +
"</ul>";
} else if (data.error) {
html = data.error;
}
} catch (e) {
html = xhr.responseText;
}
} else {
// HTML or plain text
html = xhr.responseText;
}
// Apply field-level highlighting + scroll
if (form && fieldErrors) {
// Clear previous error state
form.querySelectorAll(".field-error").forEach((el) => {
el.classList.remove(
"field-error",
"border-red-500",
"ring-1",
"ring-red-500"
);
el.removeAttribute("aria-invalid");
});
let firstErrorInput = null;
for (const [field, msgs] of Object.entries(fieldErrors)) {
if (field === "__all__" || field === "_global") continue;
// Special case: days group
if (field === "days") {
const group = form.querySelector("[data-days-group]");
if (group) {
group.classList.add(
"field-error",
"border",
"border-red-500",
"rounded",
"ring-1",
"ring-red-500"
);
// Focus the first checkbox in the group
if (!firstErrorInput) {
const cb = group.querySelector('input[type="checkbox"]');
if (cb) {
firstErrorInput = cb;
}
if (contentType.includes("application/json")) {
try {
var data = JSON.parse(text || "{}");
message = data.message || "";
if (data.errors && typeof data.errors === "object" && !Array.isArray(data.errors)) {
fieldErrors = data.errors;
var allMessages = [];
for (var field in data.errors) {
var arr = Array.isArray(data.errors[field]) ? data.errors[field] : [data.errors[field]];
allMessages.push.apply(allMessages, arr);
}
if (allMessages.length) {
html = "<ul style='text-align:left;margin:0;padding-left:1.25rem;'>" +
allMessages.map(function (e) { return "<li>" + e + "</li>"; }).join("") + "</ul>";
}
} else if (Array.isArray(data.errors)) {
html = "<ul style='text-align:left;margin:0;padding-left:1.25rem;'>" +
data.errors.map(function (e) { return "<li>" + e + "</li>"; }).join("") + "</ul>";
} else if (data.error) {
html = data.error;
}
continue;
} catch (e) { html = text; }
} else {
html = text;
}
if (form && fieldErrors) {
form.querySelectorAll(".field-error").forEach(function (el) {
el.classList.remove("field-error", "border-red-500", "ring-1", "ring-red-500");
el.removeAttribute("aria-invalid");
});
var firstErrorInput = null;
for (var fld in fieldErrors) {
if (fld === "__all__" || fld === "_global") continue;
if (fld === "days") {
var group = form.querySelector("[data-days-group]");
if (group) {
group.classList.add("field-error", "border", "border-red-500", "rounded", "ring-1", "ring-red-500");
if (!firstErrorInput) { var cb = group.querySelector('input[type="checkbox"]'); if (cb) firstErrorInput = cb; }
}
continue;
}
var input = form.querySelector('[name="' + fld + '"]');
if (!input) continue;
input.classList.add("field-error", "border-red-500", "ring-1", "ring-red-500");
input.setAttribute("aria-invalid", "true");
if (!firstErrorInput) firstErrorInput = input;
}
// Normal fields: find by name
const input = form.querySelector(`[name="${field}"]`);
if (!input) continue;
input.classList.add(
"field-error",
"border-red-500",
"ring-1",
"ring-red-500"
);
input.setAttribute("aria-invalid", "true");
if (!firstErrorInput) {
firstErrorInput = input;
if (firstErrorInput) {
firstErrorInput.scrollIntoView({ behavior: "smooth", block: "center" });
firstErrorInput.focus({ preventScroll: true });
}
}
if (firstErrorInput) {
firstErrorInput.scrollIntoView({ behavior: "smooth", block: "center" });
firstErrorInput.focus({ preventScroll: true });
if (typeof Swal !== "undefined") {
Swal.fire({ icon: "error", title: message || title, html: html || "Please correct the highlighted fields and try again." });
}
}
Swal.fire({
icon: "error",
title: message || title,
html: html || "Please correct the highlighted fields and try again.",
});
}).catch(function () {});
});
document.addEventListener('toggle', function (event) {
const details = event.target;
// Only act on <details> elements that were just opened
@@ -737,7 +676,7 @@ document.body.addEventListener('click', function (e) {
const eventName = btn.getAttribute('data-confirm-event');
if (eventName) {
// HTMX-style: fire a custom event (e.g. "confirmed") for hx-trigger
// Fire a custom event (e.g. "confirmed") for sx-trigger
btn.dispatchEvent(new CustomEvent(eventName, { bubbles: true }));
return;
}
@@ -746,7 +685,7 @@ document.body.addEventListener('click', function (e) {
const form = btn.closest('form');
if (form) {
if (typeof form.requestSubmit === 'function') {
form.requestSubmit(btn); // proper HTMX-visible submit
form.requestSubmit(btn); // proper submit
} else {
const ev = new Event('submit', { bubbles: true, cancelable: true });
form.dispatchEvent(ev);
@@ -795,28 +734,38 @@ document.body.addEventListener('click', function (e) {
document.body.addEventListener('htmx:beforeSwap', function (event) {
const xhr = event.detail.xhr;
if (!xhr) return;
// Server can send: HX-Preserve-Search: keep | replace
const mode = xhr.getResponseHeader('HX-Preserve-Search');
// Only remove if no preserve header AND incoming response contains the element
if (!mode) {
const parser = new DOMParser();
const doc = parser.parseFromString(event.detail.serverResponse, 'text/html');
const el = document.getElementById('search-desktop');
if (el && doc.getElementById('search-desktop')) {
el.parentElement.removeChild(el);
}
const el2 = document.getElementById('search-mobile');
if (el2 && doc.getElementById('search-mobile')) {
el2.parentElement.removeChild(el2);
// ============================================================================
// Scrolling menu arrow visibility (replaces hyperscript scroll/load handlers)
// Elements with data-scroll-arrows="arrow-class" show/hide arrows on overflow.
// ============================================================================
(function () {
function updateArrows(container) {
var arrowClass = container.getAttribute('data-scroll-arrows');
if (!arrowClass) return;
var arrows = document.querySelectorAll('.' + arrowClass);
if (window.innerWidth >= 640 && container.scrollWidth > container.clientWidth) {
arrows.forEach(function (a) { a.classList.remove('hidden'); a.classList.add('flex'); });
} else {
arrows.forEach(function (a) { a.classList.add('hidden'); a.classList.remove('flex'); });
}
}
});
function initScrollArrows(root) {
(root || document).querySelectorAll('[data-scroll-arrows]').forEach(function (el) {
if (el._scrollArrowsBound) return;
el._scrollArrowsBound = true;
el.addEventListener('scroll', function () { updateArrows(el); }, { passive: true });
updateArrows(el);
});
}
document.addEventListener('DOMContentLoaded', function () { initScrollArrows(); });
window.addEventListener('load', function () {
document.querySelectorAll('[data-scroll-arrows]').forEach(updateArrows);
});
document.addEventListener('sx:afterSettle', function (e) { initScrollArrows(e.detail?.target); });
})();

View File

@@ -812,7 +812,13 @@
var i = 0;
while (i < args.length) {
if (isKw(args[i]) && i + 1 < args.length) {
kwargs[args[i].name] = sexpEval(args[i + 1], env);
// Keep kwarg values as AST — renderDOM will handle them when the
// component body references the param symbol. Simple literals are
// eval'd so strings/numbers resolve immediately.
var v = args[i + 1];
kwargs[args[i].name] = (typeof v === "string" || typeof v === "number" ||
typeof v === "boolean" || isNil(v) || isKw(v))
? v : (isSym(v) ? sexpEval(v, env) : v);
i += 2;
} else {
children.push(args[i]);
@@ -875,6 +881,14 @@
if (name.charAt(0) === "~") {
var comp = env[name];
if (isComponent(comp)) return renderComponentDOM(comp, expr.slice(1), env);
// Unknown component — render a visible warning, don't crash
console.warn("sexp.js: unknown component " + name);
var warn = document.createElement("div");
warn.setAttribute("style",
"background:#fef2f2;border:1px solid #fca5a5;color:#991b1b;" +
"padding:4px 8px;margin:2px;border-radius:4px;font-size:12px;font-family:monospace");
warn.textContent = "Unknown component: " + name;
return warn;
}
// Fallback: evaluate then render
@@ -1000,11 +1014,48 @@
}
if (name === "define" || name === "defcomp") { sexpEval(expr, env); return ""; }
// Higher-order forms — render-aware (lambda bodies may contain HTML/components)
if (name === "map") {
var mapFn = sexpEval(expr[1], env), mapColl = sexpEval(expr[2], env);
if (!Array.isArray(mapColl)) return "";
var mapParts = [];
for (var mi = 0; mi < mapColl.length; mi++) {
if (isLambda(mapFn)) mapParts.push(renderLambdaStr(mapFn, [mapColl[mi]], env));
else mapParts.push(renderStr(mapFn(mapColl[mi]), env));
}
return mapParts.join("");
}
if (name === "map-indexed") {
var mixFn = sexpEval(expr[1], env), mixColl = sexpEval(expr[2], env);
if (!Array.isArray(mixColl)) return "";
var mixParts = [];
for (var mxi = 0; mxi < mixColl.length; mxi++) {
if (isLambda(mixFn)) mixParts.push(renderLambdaStr(mixFn, [mxi, mixColl[mxi]], env));
else mixParts.push(renderStr(mixFn(mxi, mixColl[mxi]), env));
}
return mixParts.join("");
}
if (name === "filter") {
var filtFn = sexpEval(expr[1], env), filtColl = sexpEval(expr[2], env);
if (!Array.isArray(filtColl)) return "";
var filtParts = [];
for (var fli = 0; fli < filtColl.length; fli++) {
var keep = isLambda(filtFn) ? callLambda(filtFn, [filtColl[fli]], env) : filtFn(filtColl[fli]);
if (isSexpTruthy(keep)) filtParts.push(renderStr(filtColl[fli], env));
}
return filtParts.join("");
}
if (HTML_TAGS[name]) return renderStrElement(name, expr.slice(1), env);
if (name.charAt(0) === "~") {
var comp = env[name];
if (isComponent(comp)) return renderStrComponent(comp, expr.slice(1), env);
// Unknown component — return visible warning
console.warn("sexp.js: unknown component " + name);
return '<div style="background:#fef2f2;border:1px solid #fca5a5;color:#991b1b;' +
'padding:4px 8px;margin:2px;border-radius:4px;font-size:12px;font-family:monospace">' +
'Unknown component: ' + escapeText(name) + '</div>';
}
return renderStr(sexpEval(expr, env), env);
@@ -1033,12 +1084,21 @@
return open + inner.join("") + "</" + tag + ">";
}
function renderLambdaStr(fn, args, env) {
var local = merge({}, fn.closure, env);
for (var i = 0; i < fn.params.length; i++) local[fn.params[i]] = args[i];
return renderStr(fn.body, local);
}
function renderStrComponent(comp, args, env) {
var kwargs = {}, children = [];
var i = 0;
while (i < args.length) {
if (isKw(args[i]) && i + 1 < args.length) {
kwargs[args[i].name] = sexpEval(args[i + 1], env);
var v = args[i + 1];
kwargs[args[i].name] = (typeof v === "string" || typeof v === "number" ||
typeof v === "boolean" || isNil(v) || isKw(v))
? v : (isSym(v) ? sexpEval(v, env) : v);
i += 2;
} else { children.push(args[i]); i++; }
}
@@ -1082,6 +1142,50 @@
var _componentEnv = {};
// =========================================================================
// Head auto-hoist: move meta/title/link/script[ld+json] from body to <head>
// =========================================================================
var HEAD_HOIST_SELECTOR =
"meta, title, link[rel='canonical'], script[type='application/ld+json']";
function _hoistHeadElements(root) {
var els = root.querySelectorAll(HEAD_HOIST_SELECTOR);
if (!els.length) return;
var head = document.head;
for (var i = 0; i < els.length; i++) {
var el = els[i];
var tag = el.tagName.toLowerCase();
// For <title>, replace existing
if (tag === "title") {
document.title = el.textContent || "";
el.parentNode.removeChild(el);
continue;
}
// For <meta>, remove existing with same name/property to avoid duplicates
if (tag === "meta") {
var name = el.getAttribute("name");
var prop = el.getAttribute("property");
if (name) {
var old = head.querySelector('meta[name="' + name + '"]');
if (old) old.parentNode.removeChild(old);
}
if (prop) {
var old2 = head.querySelector('meta[property="' + prop + '"]');
if (old2) old2.parentNode.removeChild(old2);
}
}
// For <link rel=canonical>, remove existing
if (tag === "link" && el.getAttribute("rel") === "canonical") {
var oldLink = head.querySelector('link[rel="canonical"]');
if (oldLink) oldLink.parentNode.removeChild(oldLink);
}
// Move from body to head
el.parentNode.removeChild(el);
head.appendChild(el);
}
}
var Sexp = {
// Types
NIL: NIL,
@@ -1153,6 +1257,11 @@
var node = Sexp.render(exprOrText, extraEnv);
el.textContent = "";
el.appendChild(node);
// Auto-hoist head elements (meta, title, link, script[ld+json]) to <head>
_hoistHeadElements(el);
// Process sx- attributes and hydrate the newly mounted content
if (typeof SxEngine !== "undefined") SxEngine.process(el);
Sexp.hydrate(el);
},
/**
@@ -1241,6 +1350,601 @@
global.Sexp = Sexp;
// =========================================================================
// SxEngine — native fetch/swap/history engine (replaces HTMX)
// =========================================================================
var SxEngine = (function () {
if (typeof document === "undefined") return {};
// ---- helpers ----------------------------------------------------------
var PROCESSED = "_sxBound";
var VERBS = ["get", "post", "put", "delete", "patch"];
var DEFAULT_SWAP = "outerHTML";
var HISTORY_MAX = 20;
function dispatch(el, name, detail) {
var evt = new CustomEvent(name, { bubbles: true, cancelable: true, detail: detail || {} });
return el.dispatchEvent(evt);
}
function csrfToken() {
var m = document.querySelector('meta[name="csrf-token"]');
return m ? m.getAttribute("content") : null;
}
function sameOrigin(url) {
try { return new URL(url, location.href).origin === location.origin; } catch (e) { return true; }
}
function resolveTarget(el, attr) {
var sel = el.getAttribute("sx-target") || attr;
if (!sel || sel === "this") return el;
if (sel === "closest") return el.parentElement;
return document.querySelector(sel);
}
function getVerb(el) {
for (var i = 0; i < VERBS.length; i++) {
var v = VERBS[i];
if (el.hasAttribute("sx-" + v)) return { method: v.toUpperCase(), url: el.getAttribute("sx-" + v) };
}
return null;
}
// ---- Sync manager -----------------------------------------------------
var _controllers = new WeakMap();
function abortPrevious(el) {
var prev = _controllers.get(el);
if (prev) prev.abort();
}
function trackController(el, ctrl) {
_controllers.set(el, ctrl);
}
// ---- Request executor -------------------------------------------------
function executeRequest(el, verbInfo, extraParams) {
var method = verbInfo.method;
var url = verbInfo.url;
// sx-media: skip if media query doesn't match
var media = el.getAttribute("sx-media");
if (media && !window.matchMedia(media).matches) return Promise.resolve();
// sx-confirm: show dialog first
var confirmMsg = el.getAttribute("sx-confirm");
if (confirmMsg) {
if (typeof Swal !== "undefined") {
return Swal.fire({
title: confirmMsg,
icon: "warning",
showCancelButton: true,
confirmButtonText: "Yes",
cancelButtonText: "Cancel"
}).then(function (result) {
if (!result.isConfirmed) return;
return _doFetch(el, method, url, extraParams);
});
}
if (!window.confirm(confirmMsg)) return Promise.resolve();
}
return _doFetch(el, method, url, extraParams);
}
function _doFetch(el, method, url, extraParams) {
// sx-sync: abort previous
var sync = el.getAttribute("sx-sync");
if (sync && sync.indexOf("replace") >= 0) abortPrevious(el);
var ctrl = new AbortController();
trackController(el, ctrl);
// Build headers
var headers = {
"SX-Request": "true",
"SX-Current-URL": location.href
};
var targetSel = el.getAttribute("sx-target");
if (targetSel) headers["SX-Target"] = targetSel;
// Extra headers from sx-headers
var extraH = el.getAttribute("sx-headers");
if (extraH) {
try {
var parsed = JSON.parse(extraH);
for (var k in parsed) headers[k] = parsed[k];
} catch (e) { /* ignore */ }
}
// CSRF for same-origin mutating requests
if (method !== "GET" && sameOrigin(url)) {
var csrf = csrfToken();
if (csrf) headers["X-CSRFToken"] = csrf;
}
// Build body
var body = null;
var isJson = el.getAttribute("sx-encoding") === "json";
if (method !== "GET") {
var form = el.closest("form") || (el.tagName === "FORM" ? el : null);
if (form) {
if (isJson) {
var fd = new FormData(form);
var obj = {};
fd.forEach(function (v, k) {
if (obj[k] !== undefined) {
if (!Array.isArray(obj[k])) obj[k] = [obj[k]];
obj[k].push(v);
} else {
obj[k] = v;
}
});
body = JSON.stringify(obj);
headers["Content-Type"] = "application/json";
} else {
body = new URLSearchParams(new FormData(form));
headers["Content-Type"] = "application/x-www-form-urlencoded";
}
}
}
// Include extra inputs
var includeSel = el.getAttribute("sx-include");
if (includeSel && method !== "GET") {
var extras = document.querySelectorAll(includeSel);
if (!body) body = new URLSearchParams();
extras.forEach(function (inp) {
if (inp.name) body.append(inp.name, inp.value);
});
}
// sx-vals: merge extra key-value pairs
var valsAttr = el.getAttribute("sx-vals");
if (valsAttr) {
try {
var vals = JSON.parse(valsAttr);
if (method === "GET") {
for (var vk in vals) {
url += (url.indexOf("?") >= 0 ? "&" : "?") + encodeURIComponent(vk) + "=" + encodeURIComponent(vals[vk]);
}
} else if (body instanceof URLSearchParams) {
for (var vk2 in vals) body.append(vk2, vals[vk2]);
} else if (!body) {
body = new URLSearchParams();
for (var vk3 in vals) body.append(vk3, vals[vk3]);
headers["Content-Type"] = "application/x-www-form-urlencoded";
}
} catch (e) { /* ignore */ }
}
// For GET with form data, append to URL
if (method === "GET") {
var form2 = el.closest("form") || (el.tagName === "FORM" ? el : null);
if (form2) {
var qs = new URLSearchParams(new FormData(form2)).toString();
if (qs) url += (url.indexOf("?") >= 0 ? "&" : "?") + qs;
}
// Also handle search inputs with name attr
if (el.tagName === "INPUT" && el.name) {
var param = encodeURIComponent(el.name) + "=" + encodeURIComponent(el.value);
url += (url.indexOf("?") >= 0 ? "&" : "?") + param;
}
}
// Lifecycle: beforeRequest
if (!dispatch(el, "sx:beforeRequest", { method: method, url: url })) return Promise.resolve();
// Loading state
el.classList.add("sx-request");
el.setAttribute("aria-busy", "true");
var fetchOpts = { method: method, headers: headers, signal: ctrl.signal };
if (body && method !== "GET") fetchOpts.body = body;
return fetch(url, fetchOpts).then(function (resp) {
el.classList.remove("sx-request");
el.removeAttribute("aria-busy");
if (!resp.ok) {
dispatch(el, "sx:responseError", { response: resp, status: resp.status });
return _handleRetry(el, verbInfo, extraParams);
}
return resp.text().then(function (text) {
dispatch(el, "sx:afterRequest", { response: resp });
// Check for text/sexp content type
var ct = resp.headers.get("Content-Type") || "";
if (ct.indexOf("text/sexp") >= 0) {
try { text = Sexp.renderToString(text); }
catch (err) {
console.error("sexp.js render error:", err);
return;
}
}
// Process the response
var swapStyle = el.getAttribute("sx-swap") || DEFAULT_SWAP;
var target = resolveTarget(el, null);
// sx-select: extract subset from response
var selectSel = el.getAttribute("sx-select");
// Parse response into DOM for OOB + select processing
var parser = new DOMParser();
var doc = parser.parseFromString(text, "text/html");
// OOB processing: extract elements with sx-swap-oob
var oobs = doc.querySelectorAll("[sx-swap-oob]");
oobs.forEach(function (oob) {
var oobSwap = oob.getAttribute("sx-swap-oob") || "outerHTML";
var oobTarget = document.getElementById(oob.id);
oob.removeAttribute("sx-swap-oob");
if (oobTarget) {
_swapContent(oobTarget, oob.outerHTML, oobSwap);
}
oob.parentNode.removeChild(oob);
});
// Also support hx-swap-oob during migration
var hxOobs = doc.querySelectorAll("[hx-swap-oob]");
hxOobs.forEach(function (oob) {
var oobSwap = oob.getAttribute("hx-swap-oob") || "outerHTML";
var oobTarget = document.getElementById(oob.id);
oob.removeAttribute("hx-swap-oob");
if (oobTarget) {
_swapContent(oobTarget, oob.outerHTML, oobSwap);
}
oob.parentNode.removeChild(oob);
});
// Build final content
var content;
if (selectSel) {
// sx-select may be comma-separated
var parts = selectSel.split(",").map(function (s) { return s.trim(); });
var frags = [];
parts.forEach(function (sel) {
var matches = doc.querySelectorAll(sel);
matches.forEach(function (m) { frags.push(m.outerHTML); });
});
content = frags.join("");
} else {
content = doc.body ? doc.body.innerHTML : text;
}
// Main swap
if (swapStyle !== "none" && target) {
_swapContent(target, content, swapStyle);
// Auto-hoist any head elements that ended up in body
_hoistHeadElements(target);
}
// History
var pushUrl = el.getAttribute("sx-push-url");
if (pushUrl === "true") {
history.pushState({ sxUrl: url }, "", url);
} else if (pushUrl && pushUrl !== "false") {
history.pushState({ sxUrl: pushUrl }, "", pushUrl);
}
dispatch(el, "sx:afterSwap", { target: target });
// Settle tick
requestAnimationFrame(function () {
dispatch(el, "sx:afterSettle", { target: target });
});
});
}).catch(function (err) {
el.classList.remove("sx-request");
el.removeAttribute("aria-busy");
if (err.name === "AbortError") return;
dispatch(el, "sx:sendError", { error: err });
return _handleRetry(el, verbInfo, extraParams);
});
}
// ---- Swap engine ------------------------------------------------------
function _swapContent(target, html, strategy) {
switch (strategy) {
case "innerHTML":
target.innerHTML = html;
break;
case "outerHTML":
var tgt = target;
var parent = tgt.parentNode;
tgt.insertAdjacentHTML("afterend", html);
parent.removeChild(tgt);
// Process parent to catch all newly inserted siblings
Sexp.processScripts(parent);
Sexp.hydrate(parent);
SxEngine.process(parent);
return; // early return — afterSwap handling done inline
case "afterend":
target.insertAdjacentHTML("afterend", html);
break;
case "beforeend":
target.insertAdjacentHTML("beforeend", html);
break;
case "afterbegin":
target.insertAdjacentHTML("afterbegin", html);
break;
case "beforebegin":
target.insertAdjacentHTML("beforebegin", html);
break;
case "delete":
target.parentNode.removeChild(target);
return;
default:
target.innerHTML = html;
}
Sexp.processScripts(target);
Sexp.hydrate(target);
SxEngine.process(target);
}
// ---- Retry system -----------------------------------------------------
function _handleRetry(el, verbInfo, extraParams) {
var retry = el.getAttribute("sx-retry");
if (!retry) return;
var parts = retry.split(":");
var strategy = parts[0]; // "exponential"
var startMs = parseInt(parts[1], 10) || 1000;
var capMs = parseInt(parts[2], 10) || 30000;
var currentMs = parseInt(el.getAttribute("data-sx-retry-ms"), 10) || startMs;
el.classList.add("sx-error");
el.classList.remove("sx-loading");
setTimeout(function () {
el.classList.remove("sx-error");
el.classList.add("sx-loading");
el.setAttribute("data-sx-retry-ms", Math.min(currentMs * 2, capMs));
executeRequest(el, verbInfo, extraParams);
}, currentMs);
}
// ---- Trigger system ---------------------------------------------------
function parseTrigger(spec) {
if (!spec) return null;
var triggers = [];
var parts = spec.split(",");
for (var i = 0; i < parts.length; i++) {
var p = parts[i].trim();
if (!p) continue;
var tokens = p.split(/\s+/);
var trigger = { event: tokens[0], modifiers: {} };
for (var j = 1; j < tokens.length; j++) {
var tok = tokens[j];
if (tok === "once") trigger.modifiers.once = true;
else if (tok === "changed") trigger.modifiers.changed = true;
else if (tok.indexOf("delay:") === 0) trigger.modifiers.delay = parseInt(tok.substring(6), 10);
else if (tok.indexOf("from:") === 0) trigger.modifiers.from = tok.substring(5);
}
triggers.push(trigger);
}
return triggers;
}
function bindTriggers(el, verbInfo) {
var triggerSpec = el.getAttribute("sx-trigger");
var triggers;
if (triggerSpec) {
triggers = parseTrigger(triggerSpec);
} else {
// Defaults
if (el.tagName === "FORM") {
triggers = [{ event: "submit", modifiers: {} }];
} else if (el.tagName === "INPUT" || el.tagName === "SELECT" || el.tagName === "TEXTAREA") {
triggers = [{ event: "change", modifiers: {} }];
} else {
triggers = [{ event: "click", modifiers: {} }];
}
}
triggers.forEach(function (trig) {
if (trig.event === "intersect") {
_bindIntersect(el, verbInfo, trig.modifiers);
} else if (trig.event === "load") {
setTimeout(function () { executeRequest(el, verbInfo); }, 0);
} else if (trig.event === "revealed") {
_bindIntersect(el, verbInfo, { once: true });
} else {
_bindEvent(el, verbInfo, trig);
}
});
}
function _bindEvent(el, verbInfo, trig) {
var eventName = trig.event;
var mods = trig.modifiers;
var listenTarget = mods.from ? document.querySelector(mods.from) || el : el;
var timer = null;
var lastVal = undefined;
var handler = function (e) {
// For form submissions, prevent default
if (eventName === "submit") e.preventDefault();
// For links, prevent navigation
if (eventName === "click" && el.tagName === "A") e.preventDefault();
// changed modifier: only fire if value changed
if (mods.changed && el.value !== undefined) {
if (el.value === lastVal) return;
lastVal = el.value;
}
if (mods.delay) {
clearTimeout(timer);
timer = setTimeout(function () { executeRequest(el, verbInfo); }, mods.delay);
} else {
executeRequest(el, verbInfo);
}
};
listenTarget.addEventListener(eventName, handler, { once: !!mods.once });
}
function _bindIntersect(el, verbInfo, mods) {
if (!("IntersectionObserver" in window)) {
executeRequest(el, verbInfo);
return;
}
var fired = false;
var delay = mods.delay || 0;
var obs = new IntersectionObserver(function (entries) {
entries.forEach(function (entry) {
if (!entry.isIntersecting) return;
if (mods.once && fired) return;
fired = true;
if (mods.once) obs.unobserve(el);
if (delay) {
setTimeout(function () { executeRequest(el, verbInfo); }, delay);
} else {
executeRequest(el, verbInfo);
}
});
});
obs.observe(el);
}
// ---- History manager --------------------------------------------------
var _historyCache = {};
var _historyCacheKeys = [];
function _cacheCurrentPage() {
var key = location.href;
var main = document.getElementById("main-panel");
if (!main) return;
_historyCache[key] = main.innerHTML;
// LRU eviction
var idx = _historyCacheKeys.indexOf(key);
if (idx >= 0) _historyCacheKeys.splice(idx, 1);
_historyCacheKeys.push(key);
while (_historyCacheKeys.length > HISTORY_MAX) {
delete _historyCache[_historyCacheKeys.shift()];
}
}
if (typeof window !== "undefined") {
window.addEventListener("popstate", function (e) {
var url = location.href;
// Try cache first
if (_historyCache[url]) {
var main = document.getElementById("main-panel");
if (main) {
main.innerHTML = _historyCache[url];
Sexp.processScripts(main);
Sexp.hydrate(main);
SxEngine.process(main);
dispatch(document.body, "sx:afterSettle", { target: main });
return;
}
}
// Fetch fresh
fetch(url, {
headers: { "SX-Request": "true", "SX-History-Restore": "true" }
}).then(function (resp) {
return resp.text();
}).then(function (text) {
var ct = "";
// Response content-type is lost here, check for sexp
if (text.charAt(0) === "(") {
try { text = Sexp.renderToString(text); } catch (e) { /* not sexp */ }
}
var parser = new DOMParser();
var doc = parser.parseFromString(text, "text/html");
var newMain = doc.getElementById("main-panel");
var main = document.getElementById("main-panel");
if (main && newMain) {
main.innerHTML = newMain.innerHTML;
Sexp.processScripts(main);
Sexp.hydrate(main);
SxEngine.process(main);
dispatch(document.body, "sx:afterSettle", { target: main });
}
}).catch(function () {
location.reload();
});
});
}
// ---- sx-on:* inline event handlers ------------------------------------
function _bindInlineHandlers(el) {
var attrs = el.attributes;
for (var i = 0; i < attrs.length; i++) {
var name = attrs[i].name;
if (name.indexOf("sx-on:") === 0) {
var evtName = name.substring(6);
el.addEventListener(evtName, new Function("event", attrs[i].value));
}
}
}
// ---- Process function -------------------------------------------------
function process(root) {
root = root || document.body;
if (!root || !root.querySelectorAll) return;
var selector = "[sx-get],[sx-post],[sx-put],[sx-delete],[sx-patch]";
var elements = root.querySelectorAll(selector);
// Also check root itself
if (root.matches && root.matches(selector)) {
_processOne(root);
}
for (var i = 0; i < elements.length; i++) {
_processOne(elements[i]);
}
// Bind sx-on:* handlers on all elements
var allOnEls = root.querySelectorAll("[sx-on\\:beforeRequest],[sx-on\\:afterRequest],[sx-on\\:afterSwap],[sx-on\\:afterSettle],[sx-on\\:responseError]");
allOnEls.forEach(function (el) {
if (el[PROCESSED + "on"]) return;
el[PROCESSED + "on"] = true;
_bindInlineHandlers(el);
});
}
function _processOne(el) {
if (el[PROCESSED]) return;
// sx-disable: skip processing
if (el.hasAttribute("sx-disable") || el.closest("[sx-disable]")) return;
el[PROCESSED] = true;
var verbInfo = getVerb(el);
if (!verbInfo) return;
bindTriggers(el, verbInfo);
}
// ---- Public API -------------------------------------------------------
var engine = {
process: process,
executeRequest: executeRequest,
version: "1.0.0"
};
return engine;
})();
global.SxEngine = SxEngine;
// =========================================================================
// Auto-init in browser
// =========================================================================
@@ -1249,6 +1953,7 @@
var init = function () {
Sexp.processScripts();
Sexp.hydrate();
SxEngine.process();
};
if (document.readyState === "loading") {
document.addEventListener("DOMContentLoaded", init);
@@ -1256,23 +1961,11 @@
init();
}
// Re-process after HTMX swaps
document.addEventListener("htmx:afterSwap", function (e) {
Sexp.processScripts(e.detail.target);
Sexp.hydrate(e.detail.target);
// Cache current page before navigation
document.addEventListener("sx:beforeRequest", function () {
if (typeof SxEngine._cacheCurrentPage === "function") SxEngine._cacheCurrentPage();
});
// S-expression wire format: intercept text/sexp responses and render to HTML
// before HTMX swaps them in. Server sends Content-Type: text/sexp with
// s-expression body; sexp.js renders to HTML string for HTMX to swap.
document.addEventListener("htmx:beforeSwap", function (e) {
var xhr = e.detail.xhr;
var ct = xhr.getResponseHeader("Content-Type") || "";
if (ct.indexOf("text/sexp") === -1) return;
// Render s-expression response to HTML string
var html = Sexp.renderToString(xhr.responseText);
e.detail.serverResponse = html;
});
}
})(typeof window !== "undefined" ? window : this);

View File

@@ -59,7 +59,7 @@ def _join_url_parts(parts: List[str]) -> str:
return url
def hx_fragment_request() -> bool:
return request.headers.get("HX-Request", "").lower() == "true"
return request.headers.get("SX-Request", "").lower() == "true" or request.headers.get("HX-Request", "").lower() == "true"
def route_prefix():
return f"{request.scheme}://{request.host}/{request.headers.get('x-forwarded-prefix', '/')}"