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:
@@ -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"
|
||||
>
|
||||
|
||||
Reference in New Issue
Block a user