Send all responses as sexp wire format with client-side rendering
All checks were successful
Build and Deploy / build-and-deploy (push) Successful in 5m35s

- Server sends sexp source text, client (sexp.js) renders everything
- SexpExpr marker class for nested sexp composition in serialize()
- sexp_page() HTML shell with data-mount="body" for full page loads
- sexp_response() returns text/sexp for OOB/partial responses
- ~app-body layout component replaces ~app-layout (no raw!)
- ~rich-text is the only component using raw! (for CMS HTML content)
- Fragment endpoints return text/sexp, auto-wrapped in SexpExpr
- All _*_html() helpers converted to _*_sexp() returning sexp source
- Head auto-hoist: sexp.js moves meta/title/link/script[ld+json]
  from rendered body to document.head automatically
- Unknown components render warning box instead of crashing page
- Component kwargs preserve AST for lazy rendering (fixes <> in kwargs)
- Fix unterminated paren in events/sexp/tickets.sexpr

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-03-01 09:45:07 +00:00
parent 0d48fd22ee
commit 22802bd36b
270 changed files with 7153 additions and 5382 deletions

View File

@@ -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"))