Send all responses as sexp wire format with client-side rendering
- Server sends sexp source text, client (sexp.js) renders everything - SexpExpr marker class for nested sexp composition in serialize() - sexp_page() HTML shell with data-mount="body" for full page loads - sexp_response() returns text/sexp for OOB/partial responses - ~app-body layout component replaces ~app-layout (no raw!) - ~rich-text is the only component using raw! (for CMS HTML content) - Fragment endpoints return text/sexp, auto-wrapped in SexpExpr - All _*_html() helpers converted to _*_sexp() returning sexp source - Head auto-hoist: sexp.js moves meta/title/link/script[ld+json] from rendered body to document.head automatically - Unknown components render warning box instead of crashing page - Component kwargs preserve AST for lazy rendering (fixes <> in kwargs) - Fix unterminated paren in events/sexp/tickets.sexpr Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -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"))
|
||||
|
||||
Reference in New Issue
Block a user