host: blog SPA scaffolding (WASM kernel) — server side complete, boost blocked on bundle rebuild

Turn the blog into a SPA using the SX-htmx engine (web/engine.sx) booting the
WASM OCaml kernel (same evaluator as the server) in-browser, with sx-boost
fragment-swapping every link into #content.

Server side DONE + verified:
- lib/host/static.sx: GET /static/** serves shared/static via the file-read
  primitive (ctype by ext, traversal-guarded, 404 on missing). Wired into
  serve.sh (module + route group). Tested: kernel JS + .wasm binary-exact.
- host/blog--page is now the SPA shell: full page = WASM boot scripts +
  sx-boost=#content wrapper + #content; on SX-Request:true returns ONLY the
  inner content fragment for the engine to swap. All 13 handlers thread req.
- docker-compose mounts ./shared/static.
- lib/host/playwright/spa-check.{spec.js,run-spa-check.sh}: boot/boost/swap/back.

Client side: the WASM kernel BOOTS (SxKernel object, data-sx-ready=true, web
stack loads). BLOCKER: the bundled .sxbc throw 'VM: unknown opcode 0' vs this
worktree's kernel -> .sx source fallback -> boot.sx source fails 'Expected
list, got string' -> process-boosted never binds links (boosted 0/N). Fix =
rebuild a consistent WASM bundle (recompile .sxbc against the kernel via
scripts/sx-build-all.sh); the browser wasm target isn't built here yet. See
plans/host-spa.md. Live NOT redeployed (stays on pre-SPA process).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-06-28 20:53:06 +00:00
parent d8d7663565
commit dbcbc39ebe
7 changed files with 298 additions and 21 deletions

View File

@@ -404,14 +404,35 @@
;; builds the tree (running any dynamic logic in the full evaluator, e.g. a posts
;; loop) and render-page renders the static result — no embedded HTML strings,
;; only the doctype prefix render-to-html doesn't emit. `body` is an SX node.
;; SPA shell. The blog is a single-page app: the page boots the WASM OCaml kernel
;; (the SAME evaluator as the server) + the SX-htmx engine (web/engine.sx), and
;; `sx-boost="#content"` turns every in-page link/form into a fragment swap into
;; #content — no full reloads, history handled. A boosted request carries the
;; SX-Request:true header; we then return ONLY the inner content (so the engine
;; swaps it straight into #content). A direct / no-JS request gets the full shell,
;; so the blog degrades gracefully to plain server-rendered pages.
(define host/blog--spa-req? (fn (req) (= (dream-header req "sx-request") "true")))
(define host/blog--page
(fn (title body)
(str "<!doctype html>"
(render-page
(quasiquote
(html
(head (meta :charset "utf-8") (title (unquote title)))
(body (unquote body))))))))
(fn (req title body)
(if (host/blog--spa-req? req)
;; fragment: inner content only — engine swaps it into #content
(render-page body)
;; full SPA shell: WASM kernel + platform + boosted #content
(str "<!doctype html>"
(render-page
(quasiquote
(html
(head (meta :charset "utf-8") (title (unquote title))
(script :src "/static/wasm/sx_browser.bc.wasm.js")
(script :src "/static/wasm/sx-platform.js"))
(body
;; sx-boost must be on a DESCENDANT of <body> (process-boosted
;; queries [sx-boost] WITHIN the body, so it can't sit on body
;; itself). The wrapper boosts every link/form inside, targeting
;; #content; #content is the swap target.
(div :sx-boost "#content"
(div :id "content" (unquote body)))))))))))
;; ── registry-driven relation rendering (post page) ──────────────────
;; One labelled block of links from records ({:slug :title}), or "" when empty.
@@ -576,7 +597,7 @@
(relations (host/blog--relations-or-hint slug (not (nil? principal))))
(auth-foot (host/auth-footer req)))
(dream-html
(host/blog--page (get r :title)
(host/blog--page req (get r :title)
(quasiquote
(div
(article (raw! (unquote body-html)))
@@ -590,7 +611,7 @@
" · "
(unquote auth-foot))))))))
(dream-html-status 404
(host/blog--page "Not found"
(host/blog--page req "Not found"
(quasiquote
(div (h1 "404")
(p (unquote (str "No published post: " slug))))))))))))
@@ -612,7 +633,7 @@
;; quasiquote (a perform during tree-build raises VmSuspended).
(auth-foot (host/auth-footer req)))
(dream-html
(host/blog--page "Blog"
(host/blog--page req "Blog"
(quasiquote
(div (h1 "Posts")
(unquote listing)
@@ -637,7 +658,7 @@
(unquote (get p :title))))))
recs)))
(dream-html
(host/blog--page "Tags"
(host/blog--page req "Tags"
(quasiquote
(div (h1 "Tags")
(unquote (if (> (len recs) 0)
@@ -657,7 +678,7 @@
(dream-response 200 {:content-type "text/plain; charset=utf-8"}
(or (get r :sx-content) ""))
(dream-html-status 404
(host/blog--page "Not found"
(host/blog--page req "Not found"
(quasiquote (div (h1 "404") (p (unquote (str "No post: " slug))))))))))))
;; ── create page (GET /new) — clean minimal form as an SX tree ───────
@@ -666,7 +687,7 @@
(define host/blog-new-form
(fn (req)
(dream-html
(host/blog--page "New post"
(host/blog--page req "New post"
(quasiquote
(div
(h1 "New post")
@@ -706,12 +727,12 @@
(cond
((or (nil? title) (= title ""))
(dream-html-status 400
(host/blog--page "Error"
(host/blog--page req "Error"
(quasiquote (div (h1 "Error") (p "Title is required.")
(p (a :href "/new" "Back")))))))
((not (host/blog-content-ok? sx-content))
(dream-html-status 400
(host/blog--page "Error"
(host/blog--page req "Error"
(quasiquote (div (h1 "Error") (p "Post body is not valid SX markup.")
(p (a :href "/new" "Back")))))))
(else
@@ -793,7 +814,7 @@
(kind (or (dream-form-field req "kind") "related")))
(if (nil? (host/blog-get slug))
(dream-html-status 404
(host/blog--page "Not found"
(host/blog--page req "Not found"
(quasiquote (div (h1 "404") (p (unquote (str "No post: " slug)))))))
(begin
(when (and other (not (= other "")) (not (= other slug))
@@ -823,7 +844,7 @@
(let ((r (host/blog-get slug)))
(if (nil? r)
(dream-html-status 404
(host/blog--page "Not found"
(host/blog--page req "Not found"
(quasiquote (div (h1 "404") (p (unquote (str "No post: " slug)))))))
(let ((status (get r :status)))
;; the relation editors + tag toggle do durable reads — compute them
@@ -836,7 +857,7 @@
(quasiquote (option :value (unquote val) :selected "selected" (unquote label)))
(quasiquote (option :value (unquote val) (unquote label)))))))
(dream-html
(host/blog--page (str "Edit: " (get r :title))
(host/blog--page req (str "Edit: " (get r :title))
(quasiquote
(div
(h1 (unquote (str "Edit: " (get r :title))))
@@ -868,7 +889,7 @@
(let ((slug (dream-param req "slug")) (r (host/blog-get (dream-param req "slug"))))
(if (nil? r)
(dream-html-status 404
(host/blog--page "Not found"
(host/blog--page req "Not found"
(quasiquote (div (h1 "404") (p (unquote (str "No post: " slug)))))))
(let ((title (or (dream-form-field req "title") (get r :title)))
(sx-content (or (dream-form-field req "sx_content") ""))
@@ -884,7 +905,7 @@
(dream-redirect (str "/" slug "/")))
(let ((issue-items (map (fn (i) (quasiquote (li (unquote i)))) issues)))
(dream-html-status 400
(host/blog--page "Cannot save"
(host/blog--page req "Cannot save"
(quasiquote
(div (h1 "Cannot save")
(p "This post can't be saved yet:")