host: layer 2 — types declare composition fields (a block editor per field)

:body was hardwired; now a TYPE declares which of its fields are compositions
({:name "body" :type "Composition"}), and an object may carry several (:body, :aside, :body-1).
The edit page renders ONE block editor per declared field (host/blog--block-editors →
host/blog--composition-fields → the type's Composition fields, default ["body"]); each editor
is independent, targets #comp-<field>, and its cards get field-qualified slugs
(<container>__<field>__<name>). Every block op takes a `field` (threaded via a hidden "field"
input, so routes are unchanged); the response re-renders just that field's editor.

STORAGE: compositions moved into a STRING-KEYED sub-dict :comps (like :field-values) —
string keys round-trip through persist cleanly, whereas a mix of a keyword :body and a string
"body" top-level key does NOT survive serialization as one key (it splits the data). body-of/
set-body! delegate to comp-of/set-comp! with "body" + a legacy top-level :body read fallback,
so existing bodies still render (the demos reseed into :comps on boot).

blog 174/174, full host conformance 403/403 (+ tests: a Landing type with two Composition
fields → two independent #comp-body/#comp-aside editors; block-add! to a named field; default
[body]). Editor still renders any node kind (no "unknown block"); #block-editor wrapper kept
so the Playwright selectors hold.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-07-01 11:07:21 +00:00
parent b056469be1
commit 616c3cf966
2 changed files with 177 additions and 104 deletions

View File

@@ -828,22 +828,22 @@
(begin
(host/blog-put! "bdoc" "BDoc" "(article)" "published")
(host/blog--set-body! "bdoc" (quote (seq)))
(let ((c0 (host/blog-block-add! "bdoc" "card-text" {"text" "first"}))
(c1 (host/blog-block-add! "bdoc" "card-heading" {"level" "2" "text" "a head"})))
(let ((c0 (host/blog-block-add! "bdoc" "body" "card-text" {"text" "first"}))
(c1 (host/blog-block-add! "bdoc" "body" "card-heading" {"level" "2" "text" "a head"})))
(list (host/blog-body-refs "bdoc")
(host/blog-is-a? c0 "card-text")
(contains? (host/blog-out "bdoc" "contains") c1))))
(list (list "body__b0" "body__b1") true true))
(host-bl-test "block-move-idx! reorders the body by index (no-op at the ends)"
(begin
(host/blog-block-move-idx! "bdoc" 1 "up") ;; node 1 before node 0
(host/blog-block-move-idx! "bdoc" "body" 1 "up") ;; node 1 before node 0
(let ((after-up (host/blog-body-refs "bdoc")))
(host/blog-block-move-idx! "bdoc" 0 "up") ;; index 0 up -> no-op
(host/blog-block-move-idx! "bdoc" "body" 0 "up") ;; index 0 up -> no-op
(list after-up (host/blog-body-refs "bdoc"))))
(list (list "body__b1" "body__b0") (list "body__b1" "body__b0")))
(host-bl-test "block-remove-idx! drops the node + its contained card's contains edge"
(begin
(host/blog-block-remove-idx! "bdoc" 0) ;; node 0 is now body__b1
(host/blog-block-remove-idx! "bdoc" "body" 0) ;; node 0 is now body__b1
(list (host/blog-body-refs "bdoc") (contains? (host/blog-out "bdoc" "contains") "bdoc__body__b1")))
(list (list "body__b0") false))
(host-bl-test "the edit page shows the block editor (#block-editor + an add-block form)"
@@ -862,7 +862,7 @@
(begin
(host/blog-put! "cdoc2" "C2" "(article)" "published")
(host/blog--set-body! "cdoc2" (quote (seq)))
(host/blog-block-add-cond! "cdoc2" "device:mobile")
(host/blog-block-add-cond! "cdoc2" "body" "device:mobile")
(let ((n (host/blog--nth (host/blog-body-nodes "cdoc2") 0)))
(list (host/blog--node-kind n)
(host/blog--pred->ckey (host/blog--node-pred n))
@@ -870,13 +870,13 @@
(list "cond" "device:mobile" 2))
(host-bl-test "block-set-cond! changes the condition (branches kept)"
(begin
(host/blog-block-set-cond! "cdoc2" 0 "locale:fr")
(host/blog-block-set-cond! "cdoc2" "body" 0 "locale:fr")
(let ((n (host/blog--nth (host/blog-body-nodes "cdoc2") 0)))
(list (host/blog--pred->ckey (host/blog--node-pred n)) (host/blog--node-kind n))))
(list "locale:fr" "cond"))
(host-bl-test "block-add-each! appends an (each (query is-a TYPE) (ref …)) repeater"
(begin
(host/blog-block-add-each! "cdoc2" "compose-item")
(host/blog-block-add-each! "cdoc2" "body" "compose-item")
(let ((n (host/blog--nth (host/blog-body-nodes "cdoc2") 1)))
(list (host/blog--node-kind n) (host/blog--node-each-type n))))
(list "each" "compose-item"))
@@ -886,17 +886,42 @@
(begin
(host/blog-put! "cdoc3" "C3" "(article)" "published")
(host/blog--set-body! "cdoc3" (quote (seq)))
(host/blog-block-add-cond! "cdoc3" "auth")
(host/blog-block-add-cond! "cdoc3" "body" "auth")
(let ((body (host/blog-body-of "cdoc3")))
(list (contains? (host/comp-render body (host/blog--comp-ctx "u" nil "cdoc3")) "shown when the condition holds")
(contains? (host/comp-render body (host/blog--comp-ctx nil nil "cdoc3")) "shown otherwise"))))
(list true true))
;; the editor offers all three block kinds.
(host-bl-test "the block editor offers card + conditional + repeater add forms"
(let ((html (render-page (host/blog--block-editor "cdoc2"))))
(let ((html (render-page (host/blog--block-editor "cdoc2" "body"))))
(list (contains? html "+ card") (contains? html "+ conditional")
(contains? html "+ repeater") (contains? html "for each")))
(list true true true true))
;; -- LAYER 2: a TYPE declares which fields are compositions; the editor renders one block
;; editor per field, each an independent composition (its own cards, field-qualified slugs). --
(host-bl-test "a type declaring TWO composition fields yields two independent block editors"
(begin
(host/blog-seed! "landing-type" "Landing" "(p)" "published")
(host/blog-relate! "landing-type" "type" "subtype-of")
(host/blog--set-fields! "landing-type"
(list {:name "body" :type "Composition"} {:name "aside" :type "Composition"}))
(host/blog-put! "land1" "L1" "(article)" "published")
(host/blog-relate! "land1" "landing-type" "is-a")
(let ((cf (host/blog--composition-fields "land1"))
(html (render-page (host/blog--block-editors "land1"))))
(list cf (contains? html "id=\"comp-body\"") (contains? html "id=\"comp-aside\"")
(contains? html ":aside (composition)"))))
(list (list "body" "aside") true true true))
(host-bl-test "block-add! writes to the NAMED field; fields are independent (slug carries it)"
(begin
(host/blog-block-add! "land1" "aside" "card-text" {"text" "sidebar"})
(host/blog-block-add! "land1" "body" "card-text" {"text" "main"})
(list (len (host/blog--comp-nodes "land1" "aside")) (len (host/blog--comp-nodes "land1" "body"))
(host/blog-is-a? "land1__aside__b0" "card-text") (host/blog-is-a? "land1__body__b0" "card-text")))
(list 1 1 true true))
(host-bl-test "composition-fields defaults to [body] when the type declares none"
(begin (host/blog-put! "plain1" "P" "(p)" "published") (host/blog--composition-fields "plain1"))
(list "body"))
;; the editor renders a HAND-AUTHORED composition (text/row/alt-with-text) WITHOUT falling
;; through to "(unknown block)" — every node kind gets a labelled row (the compose-demo case).
(host-bl-test "the block editor renders text/layout/inline-alt nodes (no unknown block)"
@@ -908,7 +933,7 @@
(alt (when (has "auth") (text "member")) (else (text "guest")))
(row (text "A") (text "B"))
(each (query is-a compose-item) (seq (text "x"))))))
(let ((html (render-page (host/blog--block-editor "mixdoc"))))
(let ((html (render-page (host/blog--block-editor "mixdoc" "body"))))
(list (contains? html "unknown block")
(contains? html "text") (contains? html "layout") (contains? html "for each"))))
(list false true true true))