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:
2026-03-01 09:45:07 +00:00
parent 0d48fd22ee
commit 22802bd36b
270 changed files with 7153 additions and 5382 deletions

View File

@@ -1,9 +1,9 @@
;; Blog card components
;; Blog card components — pure data, no HTML injection
(defcomp ~blog-like-button (&key like-url hx-headers heart)
(div :class "absolute top-20 right-2 z-10 text-6xl md:text-4xl"
(button :hx-post like-url :hx-swap "outerHTML"
:hx-headers hx-headers :class "cursor-pointer" heart)))
(button :sx-post like-url :sx-swap "outerHTML"
:sx-headers hx-headers :class "cursor-pointer" heart)))
(defcomp ~blog-draft-status (&key publish-requested timestamp)
(<> (div :class "flex justify-center gap-2 mt-1"
@@ -14,61 +14,98 @@
(defcomp ~blog-published-status (&key timestamp)
(p :class "text-sm text-stone-500" (str "Published: " timestamp)))
(defcomp ~blog-card (&key like-html href hx-select title status-html feature-image excerpt widget-html at-bar-html)
;; Tag components — accept data, not HTML
(defcomp ~blog-tag-icon (&key src name initial)
(if src
(img :src src :alt name :class "h-4 w-4 rounded-full object-cover border border-stone-300 flex-shrink-0")
(div :class "h-4 w-4 rounded-full text-[8px] font-semibold flex items-center justify-center border border-stone-300 flex-shrink-0 bg-stone-200 text-stone-600" initial)))
(defcomp ~blog-tag-item (&key src name initial)
(li (a :class "flex items-center gap-1"
(~blog-tag-icon :src src :name name :initial initial)
(span :class "inline-block rounded-full bg-stone-100 text-stone-600 px-2 py-1 text-sm font-medium border border-stone-200" name))))
;; At-bar — tags + authors row for detail pages
(defcomp ~blog-at-bar (&key tags authors)
(when (or tags authors)
(div :class "flex flex-row justify-center gap-3"
(when tags
(div :class "mt-4 flex items-center gap-2" (div "in")
(ul :class "flex flex-wrap gap-2 text-sm"
(map (lambda (t) (~blog-tag-item :src (get t "src") :name (get t "name") :initial (get t "initial"))) tags))))
(div)
(when authors
(div :class "mt-4 flex items-center gap-2" (div "by")
(ul :class "flex flex-wrap gap-2 text-sm"
(map (lambda (a) (~blog-author-item :image (get a "image") :name (get a "name"))) authors)))))))
;; Author components
(defcomp ~blog-author-item (&key image name)
(li :class "flex items-center gap-1"
(when image (img :src image :alt name :class "h-5 w-5 rounded-full object-cover"))
(span :class "text-stone-700" name)))
;; Card — accepts pure data
(defcomp ~blog-card (&key slug href hx-select title
feature-image excerpt
status is-draft publish-requested status-timestamp
liked like-url csrf-token
has-like
tags authors widget)
(article :class "border-b pb-6 last:border-b-0 relative"
(raw! like-html)
(a :href href :hx-get href :hx-target "#main-panel"
:hx-select hx-select :hx-swap "outerHTML" :hx-push-url "true"
(when has-like
(~blog-like-button
:like-url like-url
:sx-headers (str "{\"X-CSRFToken\": \"" csrf-token "\"}")
:heart (if liked "❤️" "🤍")))
(a :href href :sx-get href :sx-target "#main-panel"
:sx-select (or hx-select "#main-panel") :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)
(raw! status-html))
(if is-draft
(~blog-draft-status :publish-requested publish-requested :timestamp status-timestamp)
(when status-timestamp (~blog-published-status :timestamp status-timestamp))))
(when feature-image (div :class "mb-4" (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 widget-html (raw! widget-html))
(raw! at-bar-html)))
widget
(when (or tags authors)
(div :class "flex flex-row justify-center gap-3"
(when tags
(div :class "mt-4 flex items-center gap-2" (div "in")
(ul :class "flex flex-wrap gap-2 text-sm"
(map (lambda (t) (~blog-tag-item :src (get t "src") :name (get t "name") :initial (get t "initial"))) tags))))
(div)
(when authors
(div :class "mt-4 flex items-center gap-2" (div "by")
(ul :class "flex flex-wrap gap-2 text-sm"
(map (lambda (a) (~blog-author-item :image (get a "image") :name (get a "name"))) authors))))))))
(defcomp ~blog-card-tile (&key href hx-select feature-image title status-html excerpt at-bar-html)
(defcomp ~blog-card-tile (&key href hx-select feature-image title
is-draft publish-requested status-timestamp
excerpt tags authors)
(article :class "relative"
(a :href href :hx-get href :hx-target "#main-panel"
:hx-select hx-select :hx-swap "outerHTML" :hx-push-url "true"
(a :href href :sx-get href :sx-target "#main-panel"
:sx-select (or hx-select "#main-panel") :sx-swap "outerHTML" :sx-push-url "true"
:class "block rounded-xl bg-white shadow hover:shadow-md transition overflow-hidden"
(when feature-image (div (img :src feature-image :alt "" :class "w-full aspect-video object-cover")))
(div :class "p-3 text-center"
(h2 :class "text-lg font-bold text-stone-900" title)
(raw! status-html)
(if is-draft
(~blog-draft-status :publish-requested publish-requested :timestamp status-timestamp)
(when status-timestamp (~blog-published-status :timestamp status-timestamp)))
(when excerpt (p :class "text-stone-700 text-sm leading-relaxed line-clamp-3 mt-1" excerpt))))
(raw! at-bar-html)))
(defcomp ~blog-tag-icon-image (&key src name)
(img :src src :alt name :class "h-4 w-4 rounded-full object-cover border border-stone-300 flex-shrink-0"))
(defcomp ~blog-tag-icon-initial (&key initial)
(div :class "h-4 w-4 rounded-full text-[8px] font-semibold flex items-center justify-center border border-stone-300 flex-shrink-0 bg-stone-200 text-stone-600" initial))
(defcomp ~blog-tag-li (&key icon-html name)
(li (a :class "flex items-center gap-1" (raw! icon-html)
(span :class "inline-block rounded-full bg-stone-100 text-stone-600 px-2 py-1 text-sm font-medium border border-stone-200" name))))
(defcomp ~blog-tag-bar (&key items-html)
(div :class "mt-4 flex items-center gap-2" (div "in")
(ul :class "flex flex-wrap gap-2 text-sm" (raw! items-html))))
(defcomp ~blog-author-with-image (&key image name)
(li :class "flex items-center gap-1"
(img :src image :alt name :class "h-5 w-5 rounded-full object-cover")
(span :class "text-stone-700" name)))
(defcomp ~blog-author-text (&key name)
(li :class "text-stone-700" name))
(defcomp ~blog-author-bar (&key items-html)
(div :class "mt-4 flex items-center gap-2" (div "by")
(ul :class "flex flex-wrap gap-2 text-sm" (raw! items-html))))
(defcomp ~blog-at-bar (&key tag-items-html author-items-html)
(div :class "flex flex-row justify-center gap-3"
(raw! tag-items-html) (div) (raw! author-items-html)))
(when (or tags authors)
(div :class "flex flex-row justify-center gap-3"
(when tags
(div :class "mt-4 flex items-center gap-2" (div "in")
(ul :class "flex flex-wrap gap-2 text-sm"
(map (lambda (t) (~blog-tag-item :src (get t "src") :name (get t "name") :initial (get t "initial"))) tags))))
(div)
(when authors
(div :class "mt-4 flex items-center gap-2" (div "by")
(ul :class "flex flex-wrap gap-2 text-sm"
(map (lambda (a) (~blog-author-item :image (get a "image") :name (get a "name"))) authors))))))))
(defcomp ~blog-page-badges (&key has-calendar has-market)
(div :class "flex justify-center gap-2 mt-2"
@@ -77,13 +114,14 @@
(when has-market (span :class "inline-block px-2 py-0.5 rounded-full text-xs font-semibold bg-green-100 text-green-800"
(i :class "fa fa-shopping-bag mr-1") "Market"))))
(defcomp ~blog-page-card (&key href hx-select title badges-html pub-html feature-image excerpt)
(defcomp ~blog-page-card (&key href hx-select title has-calendar has-market pub-timestamp feature-image excerpt)
(article :class "border-b pb-6 last:border-b-0 relative"
(a :href href :hx-get href :hx-target "#main-panel"
:hx-select hx-select :hx-swap "outerHTML" :hx-push-url "true"
(a :href href :sx-get href :sx-target "#main-panel"
:sx-select (or hx-select "#main-panel") :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)
(raw! badges-html) (raw! pub-html))
(~blog-page-badges :has-calendar has-calendar :has-market has-market)
(when pub-timestamp (~blog-published-status :timestamp pub-timestamp)))
(when feature-image (div :class "mb-4" (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)))))