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
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:
@@ -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,
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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 %}
|
||||
|
||||
@@ -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; }
|
||||
|
||||
@@ -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() }}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 %}
|
||||
|
||||
@@ -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}}"
|
||||
>
|
||||
|
||||
@@ -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 %}
|
||||
|
||||
@@ -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 %}
|
||||
|
||||
@@ -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"
|
||||
>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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 %}
|
||||
|
||||
@@ -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 %}
|
||||
|
||||
@@ -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 %}
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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"))
|
||||
|
||||
|
||||
@@ -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 == ""
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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("&", "&")
|
||||
.replace("<", "<")
|
||||
.replace(">", ">")
|
||||
.replace('"', """))
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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).
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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"))
|
||||
|
||||
@@ -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)))
|
||||
|
||||
@@ -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 "
|
||||
|
||||
@@ -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)))
|
||||
|
||||
@@ -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")))
|
||||
|
||||
@@ -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}"
|
||||
|
||||
@@ -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); });
|
||||
})();
|
||||
|
||||
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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', '/')}"
|
||||
|
||||
|
||||
Reference in New Issue
Block a user