host: composition editor for and/or/each + relative-addressed refs (resolve-in-context)

The block editor now edits the object's ONE root composition (:body) as three block kinds —
CARD (a ref leaf, the "and"/content), CONDITIONAL (alt+when, the "or": render the first
branch whose live-context condition holds), and REPEATER (each: render a template per graph
query). The render-fold already interprets seq/alt/when/each/ref, so authored compositions
render for free; this adds the editing model + UI.

ADDRESSING (per the design discussion — refs are IPNS-like, not frozen CIDs): refs are
RELATIVE-STORED + RESOLVE-IN-CONTEXT. A :body stores (ref "body__b0") (field-relative); the
render context carries the CONTAINER (the object being rendered) and the resolver combines
them -> the card's storage slug <container>__<field>__<name>. So a body is portable (doesn't
pin the container's name), and editing a card updates everything that refs it for free (no
cascade). A cross-domain ref is absolute with an authority ("market:…"); the resolver
dispatches on the prefix (local today, fetch_data/AP later). A compat shim resolves an older
absolute ref directly. (Snapshot-to-absolute-CID stays a future on-demand op; the CID —
hash(record incl :body) — is the immutable layer over this naming layer.)

MODEL: host/blog--{card-slug,resolve-ref,slug->ref,new-card!,node-kind,node-refs,node-pred,
node-each-type,cond->pred,pred->ckey}; block-add!/add-cond!/add-each!; index-addressed
block-move-idx!/remove-idx!/set-cond! (alt/each aren't single refs). UI: host/blog--block-row
renders by kind (card / "if <cond> → … else → …" / "for each <type> → …") with a condition
<select> + ✎ links to each card's own /<cslug>/edit (external object, CID-neutral). Routes:
POST /:slug/blocks/{add, add-cond, add-each, :idx/{move,remove,cond}}.

Types-define-structure is the next layer (a type declares its composition field(s) + block
grammar). Full host conformance 399/399 (blog 170, incl. 5 new and/or/each tests: add-cond/
add-each/set-cond, a conditional rendering the context-chosen branch, the 3-form editor).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-07-01 10:08:12 +00:00
parent 01e0b5db41
commit 39c3def2e7
2 changed files with 390 additions and 87 deletions

View File

@@ -545,24 +545,53 @@
(fn (req)
(let ((al (str (or (dream-header req "accept-language") ""))))
(if (>= (len al) 2) (substr al 0 2) "en"))))
;; the `ref` transclude resolver (compose.sx asks the context for "ref"): render the
;; referenced object. A decomposed card object is-a a card-type with field-values + the
;; card-type carries a :template, so it renders via the SAME typed-block path articles
;; use; render-page turns that SX tree into HTML. Empty for an absent / template-less ref.
;; ── ref addressing: relative-stored, resolve-in-context (IPNS-like) ─────────────────
;; A ref in a :body is RELATIVE by default — a field-path like "body__b0" (logical: body/b0),
;; resolved against the object being rendered (the "container" in the context). So the same
;; body is portable: it doesn't pin the container's name. A card's storage slug is
;; <container>__<field>__<name> (routing-safe — a single URL segment). A cross-domain ref is
;; ABSOLUTE with an authority: "market:obj__field__card" — the resolver dispatches on the
;; prefix (local today; fetch_data / ActivityPub for a remote authority later). A snapshot/
;; publish op (future) freezes all refs to absolute CIDs. This is the naming layer; the CID
;; (content hash of the record, incl :body) is the immutable-identity layer on top.
(define host/blog--card-slug
(fn (container field name) (str container "__" field "__" name)))
;; resolve a ref string (relative field-path, or authority:slug) to a LOCAL storage slug,
;; or "" if it's a remote authority we can't fetch yet.
(define host/blog--resolve-ref
(fn (refstr ctx)
(let ((container (str (or (get ctx "container") ""))))
(if (contains? refstr ":")
(let ((p (index-of refstr ":")))
(let ((auth (substr refstr 0 p)) (rest-slug (substr refstr (+ p 1))))
(if (or (= auth "blog") (= auth container)) rest-slug ""))) ;; local authority -> the slug; remote -> unresolved (seam)
(if (= container "") refstr
;; relative resolution: <container>__<ref>. COMPAT: an older body may store an
;; ABSOLUTE ref (the full card slug) — if the relative form is absent but the ref
;; already names an existing object, use it directly.
(let ((rel (str container "__" refstr)))
(if (host/blog-exists? rel) rel (if (host/blog-exists? refstr) refstr rel))))))))
;; the `ref` transclude resolver (compose.sx asks the context for "ref"): RESOLVE the ref in
;; context, then render the resolved card object. A card is-a a card-type with field-values +
;; the card-type carries a :template, so it renders via the SAME typed-block path articles
;; use; render-page turns that SX tree into HTML. Empty for an absent / remote / bare ref.
(define host/blog--comp-ref
(fn (slug ctx)
(let ((tb (host/blog--typed-block slug)))
(if (= tb "") "" (render-page tb)))))
(fn (refstr ctx)
(let ((slug (host/blog--resolve-ref refstr ctx)))
(if (= slug "") ""
(let ((tb (host/blog--typed-block slug)))
(if (= tb "") "" (render-page tb)))))))
;; the render context for a :body: auth from the principal + live device/locale from the
;; request + the graph-query resolver + the transclude resolver. The context is the
;; EXECUTION environment — the object (its when-variants) is the definition; this picks
;; which path renders.
;; request + the graph-query resolver + the transclude resolver + the CONTAINER (the object
;; being rendered, so relative refs resolve). The context is the EXECUTION environment — the
;; object (its when-variants) is the definition; this picks which path renders.
(define host/blog--comp-ctx
(fn (principal req)
(fn (principal req container)
(merge
(merge (if (nil? principal) {} {"auth" "yes"})
(if (nil? req) {} {"device" (host/blog--device-of req) "locale" (host/blog--locale-of req)}))
{"query" host/blog--comp-query "ref" host/blog--comp-ref})))
{"query" host/blog--comp-query "ref" host/blog--comp-ref
"container" (or container "")})))
;; ── cards-as-objects: decompose content into card OBJECTS + a `contains` body ────────
;; A post body is not one opaque sx_content string but a `contains` DAG of separate,
@@ -627,7 +656,7 @@
(let ((refs
(map-indexed
(fn (i block)
(let ((orig-tag (str (first block))) (cslug (str post-slug "__b" i)))
(let ((orig-tag (str (first block))) (cslug (host/blog--card-slug post-slug "body" (str "b" i))))
(let ((ctype (host/blog--tag->card-type orig-tag)))
(begin
;; status "block" hides the card object from listings; it still
@@ -636,7 +665,7 @@
(host/blog-relate! cslug ctype "is-a")
(host/blog--set-field-values! cslug (host/blog--block-fields orig-tag ctype block))
(host/blog-relate! post-slug cslug "contains")
(list (quote ref) cslug)))))
(list (quote ref) (host/blog--slug->ref post-slug cslug))))))
blocks)))
(host/blog--set-body! post-slug (cons (quote seq) refs)))))))
@@ -661,16 +690,8 @@
(fn (slug)
(let loop ((i 0))
(if (host/blog-exists? (str slug "__b" i)) (loop (+ i 1)) i))))
(define host/blog-block-add!
(fn (slug ctype fields)
(let ((cslug (str slug "__b" (host/blog--next-block-idx slug))))
(begin
(host/blog-seed! cslug ctype "(article (h1 \"card\"))" "block")
(host/blog-relate! cslug ctype "is-a")
(host/blog--set-field-values! cslug fields)
(host/blog-relate! slug cslug "contains")
(host/blog--set-body-refs! slug (concat (host/blog-body-refs slug) (list cslug)))
cslug))))
;; legacy card-only remove (by ref slug) — kept for card-only callers/tests; the node-based
;; editor uses host/blog-block-remove-idx! (index-addressed, preserves alt/each nodes).
(define host/blog-block-remove!
(fn (slug cslug)
(begin
@@ -703,6 +724,177 @@
(fn (vals)
(let ((t (str (or (get vals "text") (get vals "src") (get vals "code") (get vals "url") ""))))
(if (> (len t) 60) (str (substr t 0 60) "…") t))))
;; ── and/or/each authoring: the :body's top-level nodes are BLOCKS of three kinds ─────
;; The :body IS the object's one root composition (inline, part of its CID). Its top-level
;; nodes are blocks: a CARD (ref -> an external card object via a `contains` edge), a
;; CONDITIONAL (alt+when — the "or": show the first branch whose condition holds), or a
;; REPEATER (each — the loop: render a template per graph-query result). seq is the "and".
;; The editor edits this inline tree; leaves stay external refs. (composition-objects.md.)
(define host/blog-body-nodes
(fn (slug)
(let ((body (host/blog-body-of slug)))
(if (and (= (type-of body) "list") (= (str (first body)) "seq"))
(rest body) (list)))))
(define host/blog--set-body-nodes!
(fn (slug nodes) (host/blog--set-body! slug (cons (quote seq) nodes))))
;; the value at index k of a list (any element type).
(define host/blog--nth
(fn (xs k) (let loop ((i 0) (ys xs))
(cond ((empty? ys) nil) ((= i k) (first ys)) (else (loop (+ i 1) (rest ys)))))))
;; a copy of xs without index i.
(define host/blog--remove-at
(fn (xs i) (let loop ((k 0) (ys xs) (acc (list)))
(if (empty? ys) acc
(loop (+ k 1) (rest ys) (if (= k i) acc (concat acc (list (first ys)))))))))
;; a fresh card object (is-a ctype + fields), contains-linked to `slug`; returns its slug.
;; Every block kind's leaves are card objects made this way.
;; a fresh, uniquely-named card in <container>/<field>. Returns its STORAGE SLUG
;; (<container>__<field>__b<i>); callers store the RELATIVE ref via host/blog--slug->ref.
(define host/blog--next-card-name
(fn (container field)
(let loop ((i 0))
(if (host/blog-exists? (host/blog--card-slug container field (str "b" i))) (loop (+ i 1)) (str "b" i)))))
(define host/blog--new-card!
(fn (container field ctype fields)
(let ((cslug (host/blog--card-slug container field (host/blog--next-card-name container field))))
(begin
(host/blog-seed! cslug ctype "(article (h1 \"card\"))" "block")
(host/blog-relate! cslug ctype "is-a")
(host/blog--set-field-values! cslug fields)
(host/blog-relate! container cslug "contains")
cslug))))
;; a card's RELATIVE ref (field-path) from its storage slug: <container>__<field>__<name>
;; -> <field>__<name>. What's stored in a :body (resolve-in-context re-prepends container).
(define host/blog--slug->ref
(fn (container slug)
(if (starts-with? slug (str container "__")) (substr slug (+ (len container) 2)) slug)))
(define host/blog--append-node!
(fn (slug node) (host/blog--set-body-nodes! slug (concat (host/blog-body-nodes slug) (list node)))))
;; the kind of a body node, for the editor: "card" | "cond" | "each" | "other".
(define host/blog--node-kind
(fn (node)
(if (= (type-of node) "list")
(let ((h (str (first node))))
(cond ((= h "ref") "card") ((= h "alt") "cond") ((= h "each") "each") (else "other")))
"other")))
;; every ref slug a node (transitively) contains — for `contains`-edge cleanup on remove.
(define host/blog--node-refs
(fn (node)
(if (= (type-of node) "list")
(if (= (str (first node)) "ref")
(list (str (first (rest node))))
(reduce (fn (acc n) (concat acc (host/blog--node-refs n))) (list) (rest node)))
(list))))
;; a `when` condition key <-> its predicate. A small decidable set over the live context
;; (auth/device/locale) — this is where responsive/personalised authoring surfaces.
(define host/blog--cond->pred
(fn (ckey)
(cond
((= ckey "auth") (list (quote has) "auth"))
((= ckey "device:mobile") (list (quote eq) "device" "mobile"))
((= ckey "device:desktop") (list (quote eq) "device" "desktop"))
((= ckey "locale:fr") (list (quote eq) "locale" "fr"))
(else (list (quote has) "auth")))))
(define host/blog--pred->label
(fn (pred)
(if (= (type-of pred) "list")
(let ((op (str (first pred))))
(cond
((= op "has") (str "has " (str (first (rest pred)))))
((= op "eq") (str (str (first (rest pred))) " = " (str (first (rest (rest pred))))))
(else "?")))
"?")))
;; the when-predicate of a conditional node (alt (when P …) …), or nil.
(define host/blog--node-pred
(fn (node)
(if (and (= (host/blog--node-kind node) "cond") (>= (len (rest node)) 1))
(let ((wb (first (rest node)))) (if (= (str (first wb)) "when") (first (rest wb)) nil))
nil)))
;; the query TYPE of a repeater node (each (query is-a T) …), or "".
(define host/blog--node-each-type
(fn (node)
(if (and (= (host/blog--node-kind node) "each") (>= (len (rest node)) 1))
(let ((src (first (rest node))))
(if (and (= (type-of src) "list") (= (str (first src)) "query")) (str (first (rest (rest src)))) ""))
"")))
;; the ref inside a branch — its last element (ref …); "" if none. Used to read the then/
;; else refs of a conditional and the template ref of a repeater.
(define host/blog--branch-ref
(fn (branch)
(let ((n (host/blog--nth branch (- (len branch) 1))))
(if (and (= (type-of n) "list") (= (str (first n)) "ref")) (str (first (rest n))) ""))))
(define host/blog--cond-then (fn (node) (host/blog--branch-ref (first (rest node)))))
(define host/blog--cond-else (fn (node) (host/blog--branch-ref (first (rest (rest node))))))
(define host/blog--each-tmpl (fn (node) (host/blog--branch-ref node)))
;; a ckey (for the cond <select>) from a predicate — the inverse of host/blog--cond->pred.
(define host/blog--pred->ckey
(fn (pred)
(if (= (type-of pred) "list")
(let ((op (str (first pred))))
(cond
((= op "has") "auth")
((and (= op "eq") (= (str (first (rest pred))) "device") (= (str (first (rest (rest pred)))) "mobile")) "device:mobile")
((and (= op "eq") (= (str (first (rest pred))) "device")) "device:desktop")
((and (= op "eq") (= (str (first (rest pred))) "locale")) "locale:fr")
(else "auth")))
"auth")))
;; add a CARD block to `field`: (ref <field-relative>). Returns the new card's storage slug.
(define host/blog-block-add!
(fn (slug ctype fields)
(let ((cslug (host/blog--new-card! slug "body" ctype fields)))
(begin (host/blog--append-node! slug (list (quote ref) (host/blog--slug->ref slug cslug))) cslug))))
;; add a CONDITIONAL (or) block: (alt (when <pred> (ref A)) (else (ref B))) — A/B relative refs.
(define host/blog-block-add-cond!
(fn (slug ckey)
(let ((a (host/blog--slug->ref slug (host/blog--new-card! slug "body" "card-text" {"text" "shown when the condition holds"})))
(b (host/blog--slug->ref slug (host/blog--new-card! slug "body" "card-text" {"text" "shown otherwise"}))))
(host/blog--append-node! slug
(list (quote alt)
(list (quote when) (host/blog--cond->pred ckey) (list (quote ref) a))
(list (quote else) (list (quote ref) b)))))))
;; add a REPEATER (each) block: (each (query is-a TYPE) (ref <template>)) — template relative.
(define host/blog-block-add-each!
(fn (slug type)
(let ((t (host/blog--slug->ref slug (host/blog--new-card! slug "body" "card-text" {"text" "rendered once per item"}))))
(host/blog--append-node! slug
(list (quote each)
(list (quote query) (string->symbol "is-a") (string->symbol type))
(list (quote ref) t))))))
;; move / remove a block by its INDEX (blocks aren't all single refs, so index-addressed).
(define host/blog-block-move-idx!
(fn (slug i dir)
(let ((nodes (host/blog-body-nodes slug)))
(let ((j (if (= dir "up") (- i 1) (+ i 1))))
(when (and (>= i 0) (< i (len nodes)) (>= j 0) (< j (len nodes)))
(host/blog--set-body-nodes! slug
(map-indexed (fn (k n) (cond ((= k i) (host/blog--nth nodes j))
((= k j) (host/blog--nth nodes i))
(else n))) nodes)))))))
(define host/blog-block-remove-idx!
(fn (slug i)
(let ((nodes (host/blog-body-nodes slug)))
(when (and (>= i 0) (< i (len nodes)))
(begin
;; refs are field-relative; contains edges are keyed by SLUG — resolve before dropping.
(for-each (fn (r) (host/blog-unrelate! slug (host/blog--resolve-ref r {"container" slug}) "contains"))
(host/blog--node-refs (host/blog--nth nodes i)))
(host/blog--set-body-nodes! slug (host/blog--remove-at nodes i)))))))
;; change a conditional block's `when` condition (its then/else branches are kept).
(define host/blog-block-set-cond!
(fn (slug i ckey)
(let ((nodes (host/blog-body-nodes slug)))
(when (and (>= i 0) (< i (len nodes)) (= (host/blog--node-kind (host/blog--nth nodes i)) "cond"))
(let ((node (host/blog--nth nodes i)))
(let ((wb (first (rest node))) (eb (first (rest (rest node)))))
(host/blog--set-body-nodes! slug
(map-indexed
(fn (k n) (if (= k i)
(list (quote alt)
(list (quote when) (host/blog--cond->pred ckey) (first (rest (rest wb)))) eb)
n))
nodes))))))))
;; Seed a live demo of the composition fold: one object, rendered by host/comp-render, that
;; shows seq + alt(when auth) + row(par) + each — and renders DIFFERENTLY logged-in vs out.
(define host/blog-seed-compose-demo!
@@ -1348,51 +1540,105 @@
;; an add-block form. Each control sx-posts its route, sx-swap="outerHTML" replacing
;; #block-editor with the re-render (live reorder/add/remove). Wrapped for the swap target.
;; one sx-post button-form targeting #block-editor (dir passed as a hidden field).
;; index-addressed control button (move up/down, remove, set-cond) -> re-renders #block-editor.
(define host/blog--block-btn
(fn (slug cslug action dir label)
(let ((url (str "/" slug "/blocks/" cslug "/" action)))
(fn (slug idx action dir label)
(let ((url (str "/" slug "/blocks/" idx "/" action)))
(quasiquote
;; :sx-post (NOT sx-disable) so the click is a text/sx form round-trip through the
;; engine — the handler returns the re-rendered #block-editor and sx-swap="outerHTML"
;; replaces it live (no reload). The explicit :sx-post overrides any boost target.
(form :method "post" :action (unquote url) :style "display:inline;margin:0"
;; :sx-post (NOT sx-disable) so the click is a text/sx round-trip through the engine —
;; the handler returns the re-rendered #block-editor and sx-swap="outerHTML" replaces it.
(form :method "post" :action (unquote url) :style "display:inline;margin:0 0.1em"
:sx-post (unquote url) :sx-target "#block-editor" :sx-swap "outerHTML"
(unquote (if (= dir "") "" (quasiquote (input :type "hidden" :name "dir" :value (unquote dir)))))
(button :type "submit" (unquote label)))))))
(define host/blog--block-row
(fn (slug cslug)
(let ((ctype (host/blog--primary-card-type cslug))
(vals (host/blog-field-values-of cslug)))
(define host/blog--block-ctrls
(fn (slug idx)
(quasiquote (span :style "white-space:nowrap"
(unquote (host/blog--block-btn slug idx "move" "up" "↑"))
(unquote (host/blog--block-btn slug idx "move" "down" "↓"))
(unquote (host/blog--block-btn slug idx "remove" "" "remove"))))))
;; a ✎ edit-fields link + preview for a card REF — refs are field-relative, resolved to the
;; card's own /<cslug>/edit page (external object; editing it is CID-neutral to the container).
(define host/blog--ref-chip
(fn (slug ref)
(let ((cslug (host/blog--resolve-ref ref {"container" slug})))
(quasiquote (span
(a :href (unquote (str "/" cslug "/edit")) "✎")
" " (span :style "color:#555" (unquote (host/blog--block-preview (host/blog-field-values-of cslug)))))))))
;; the condition <select> for a conditional block (submit re-renders the editor).
(define host/blog--cond-form
(fn (slug idx cur)
(let ((url (str "/" slug "/blocks/" idx "/cond"))
(opt (fn (v l cur) (if (= v cur)
(quasiquote (option :value (unquote v) :selected "selected" (unquote l)))
(quasiquote (option :value (unquote v) (unquote l)))))))
(quasiquote
(li :style "display:flex;gap:0.5em;align-items:center;border:1px solid #ddd;padding:0.4em;margin:0.2em 0"
(span :style "font-weight:bold;min-width:7em" (unquote ctype))
(span :style "flex:1;color:#555" (unquote (host/blog--block-preview vals)))
(a :href (unquote (str "/" cslug "/edit")) "fields")
(unquote (host/blog--block-btn slug cslug "move" "up" "↑"))
(unquote (host/blog--block-btn slug cslug "move" "down" "↓"))
(unquote (host/blog--block-btn slug cslug "remove" "" "remove")))))))
(form :method "post" :action (unquote url) :style "display:inline"
:sx-post (unquote url) :sx-target "#block-editor" :sx-swap "outerHTML"
(select :name "cond"
(unquote (opt "auth" "logged in" cur))
(unquote (opt "device:mobile" "on mobile" cur))
(unquote (opt "device:desktop" "on desktop" cur))
(unquote (opt "locale:fr" "locale = fr" cur)))
(button :type "submit" "set"))))))
;; a block row rendered by KIND — card / conditional (or) / repeater (each).
(define host/blog--block-row
(fn (slug idx node)
(let ((kind (host/blog--node-kind node))
(rs "display:flex;gap:0.5em;align-items:center;border:1px solid #ddd;padding:0.4em;margin:0.2em 0"))
(cond
((= kind "card")
(quasiquote (li :style (unquote rs)
(b :style "min-width:5em" "card")
(span :style "flex:1" (unquote (host/blog--ref-chip slug (str (first (rest node))))))
(unquote (host/blog--block-ctrls slug idx)))))
((= kind "cond")
(quasiquote (li :style (unquote rs)
(b :style "min-width:5em" "if")
(span :style "flex:1"
(unquote (host/blog--cond-form slug idx (host/blog--pred->ckey (host/blog--node-pred node))))
" → " (unquote (host/blog--ref-chip slug (host/blog--cond-then node)))
" · else → " (unquote (host/blog--ref-chip slug (host/blog--cond-else node))))
(unquote (host/blog--block-ctrls slug idx)))))
((= kind "each")
(quasiquote (li :style (unquote rs)
(b :style "min-width:5em" "for each")
(span :style "flex:1"
(code (unquote (host/blog--node-each-type node)))
" → " (unquote (host/blog--ref-chip slug (host/blog--each-tmpl node))))
(unquote (host/blog--block-ctrls slug idx)))))
(else (quasiquote (li :style (unquote rs) "(unknown block)")))))))
(define host/blog--block-editor
(fn (slug)
(let ((refs (host/blog-body-refs slug)))
(let ((rows (map (fn (c) (host/blog--block-row slug c)) refs)))
(let ((nodes (host/blog-body-nodes slug)))
(let ((rows (map-indexed (fn (i n) (host/blog--block-row slug i n)) nodes)))
(quasiquote
(div :id "block-editor" :style "margin-top:1.5em;border-top:1px solid #ccc;padding-top:1em"
(h3 :style "font-size:1em;margin:0 0 0.3em" "Blocks")
(unquote (if (> (len refs) 0) (cons (quote ul) rows) (quote (p :style "color:#999" "No blocks yet."))))
(h3 :style "font-size:1em;margin:0 0 0.3em" "Blocks (composition)")
(unquote (if (> (len nodes) 0) (cons (quote ul) rows) (quote (p :style "color:#999" "No blocks yet."))))
;; add a CARD block (the "and"/content leaf). Options are DIRECT <select> children.
(form :method "post" :action (unquote (str "/" slug "/blocks/add"))
:sx-post (unquote (str "/" slug "/blocks/add")) :sx-target "#block-editor" :sx-swap "outerHTML"
;; options MUST be DIRECT children of <select> — a wrapper (e.g. a span to splice
;; a dynamic list) leaves the dropdown empty when the DOM is built programmatically
;; on a boosted swap (a full-page HTML parse would hoist them out, masking it). The
;; card types are a fixed set, so inline them.
(select :name "ctype"
(option :value "card-heading" "heading")
(option :value "card-text" "text")
(option :value "card-quote" "quote")
(option :value "card-code" "code")
(option :value "card-heading" "heading") (option :value "card-text" "text")
(option :value "card-quote" "quote") (option :value "card-code" "code")
(option :value "card-callout" "callout"))
" " (input :name "text" :placeholder "text…" :style "width:50%")
" " (button :type "submit" "+ add block"))))))))
" " (input :name "text" :placeholder "text…" :style "width:40%")
" " (button :type "submit" "+ card"))
;; add a CONDITIONAL (or) block — alt+when over the live context.
(form :method "post" :action (unquote (str "/" slug "/blocks/add-cond"))
:sx-post (unquote (str "/" slug "/blocks/add-cond")) :sx-target "#block-editor" :sx-swap "outerHTML"
:style "margin-top:0.3em"
(select :name "cond"
(option :value "auth" "logged in") (option :value "device:mobile" "on mobile")
(option :value "device:desktop" "on desktop") (option :value "locale:fr" "locale = fr"))
" " (button :type "submit" "+ conditional (or)"))
;; add a REPEATER (each) block — iterate a graph query.
(form :method "post" :action (unquote (str "/" slug "/blocks/add-each"))
:sx-post (unquote (str "/" slug "/blocks/add-each")) :sx-target "#block-editor" :sx-swap "outerHTML"
:style "margin-top:0.3em"
(input :name "type" :placeholder "type name (e.g. compose-item)" :style "width:40%")
" " (button :type "submit" "+ repeater (each)"))))))))
;; ── read handlers ───────────────────────────────────────────────────
;; Post body is rendered per-block (a guarded HTML string) then injected raw.
@@ -1410,7 +1656,7 @@
;; (host/comp-render) against a context (auth from the principal); else the
;; legacy sx_content path. The SAME object renders differently per context.
(body-html (if (get r :body)
(host/comp-render (get r :body) (host/blog--comp-ctx principal req))
(host/comp-render (get r :body) (host/blog--comp-ctx principal req slug))
(host/blog-render r)))
;; all relation blocks (Related, Tags, Types, Tagged-with-this …)
;; come from iterating the registry — one section, registry-driven.
@@ -1785,6 +2031,7 @@
(if (host/blog--editor-swap-req? req)
(dream-html (render-page (host/blog--block-editor slug)))
(dream-redirect (str "/" slug "/edit")))))
(define host/blog--block-idx (fn (req) (parse-int (or (dream-param req "idx") "0") 0)))
(define host/blog-block-add-submit
(fn (req)
(let ((slug (dream-param req "slug"))
@@ -1798,18 +2045,30 @@
(host/blog-block-add! slug ctype
(if (= ctype "card-heading") {"level" "2" "text" text} {"text" text})))
(host/blog--block-resp req slug)))))
(define host/blog-block-add-cond-submit
(fn (req)
(let ((slug (dream-param req "slug")) (ckey (or (host/field req "cond") "auth")))
(begin (when (host/blog-exists? slug) (host/blog-block-add-cond! slug ckey))
(host/blog--block-resp req slug)))))
(define host/blog-block-add-each-submit
(fn (req)
(let ((slug (dream-param req "slug")) (type (host/blog-slugify (or (host/field req "type") ""))))
(begin (when (and (host/blog-exists? slug) (not (= type ""))) (host/blog-block-add-each! slug type))
(host/blog--block-resp req slug)))))
(define host/blog-block-remove-submit
(fn (req)
(let ((slug (dream-param req "slug")) (cslug (dream-param req "cslug")))
(begin
(when (host/blog-exists? slug) (host/blog-block-remove! slug cslug))
(let ((slug (dream-param req "slug")))
(begin (when (host/blog-exists? slug) (host/blog-block-remove-idx! slug (host/blog--block-idx req)))
(host/blog--block-resp req slug)))))
(define host/blog-block-move-submit
(fn (req)
(let ((slug (dream-param req "slug")) (cslug (dream-param req "cslug"))
(dir (or (host/field req "dir") "up")))
(begin
(when (host/blog-exists? slug) (host/blog-block-move! slug cslug dir))
(let ((slug (dream-param req "slug")) (dir (or (host/field req "dir") "up")))
(begin (when (host/blog-exists? slug) (host/blog-block-move-idx! slug (host/blog--block-idx req) dir))
(host/blog--block-resp req slug)))))
(define host/blog-block-cond-submit
(fn (req)
(let ((slug (dream-param req "slug")) (ckey (or (host/field req "cond") "auth")))
(begin (when (host/blog-exists? slug) (host/blog-block-set-cond! slug (host/blog--block-idx req) ckey))
(host/blog--block-resp req slug)))))
;; GET /<slug>/edit — edit form pre-filled with the post's current title, raw
@@ -1940,8 +2199,11 @@
(dream-get "/:slug/edit" (host/blog--protect-html resolve host/blog-edit-form))
(dream-post "/:slug/edit" (host/blog--protect-html resolve host/blog-edit-submit))
(dream-post "/:slug/blocks/add" (host/blog--protect-html resolve host/blog-block-add-submit))
(dream-post "/:slug/blocks/:cslug/remove" (host/blog--protect-html resolve host/blog-block-remove-submit))
(dream-post "/:slug/blocks/:cslug/move" (host/blog--protect-html resolve host/blog-block-move-submit))
(dream-post "/:slug/blocks/add-cond" (host/blog--protect-html resolve host/blog-block-add-cond-submit))
(dream-post "/:slug/blocks/add-each" (host/blog--protect-html resolve host/blog-block-add-each-submit))
(dream-post "/:slug/blocks/:idx/remove" (host/blog--protect-html resolve host/blog-block-remove-submit))
(dream-post "/:slug/blocks/:idx/move" (host/blog--protect-html resolve host/blog-block-move-submit))
(dream-post "/:slug/blocks/:idx/cond" (host/blog--protect-html resolve host/blog-block-cond-submit))
(dream-post "/:slug/relate" (host/blog--protect-html resolve host/blog-relate-submit))
(dream-post "/:slug/unrelate" (host/blog--protect-html resolve host/blog-unrelate-submit))
(dream-post "/meta/new-type" (host/blog--protect-html resolve host/blog-meta-new-type))