diff --git a/lib/host/blog.sx b/lib/host/blog.sx
index bb0fb268..1875e544 100644
--- a/lib/host/blog.sx
+++ b/lib/host/blog.sx
@@ -536,6 +536,28 @@
(concat acc (list (str "‘" k "’ blocks are not allowed in :" field)))))
(else acc))))
(list) (host/blog--comp-nodes slug field))))
+;; ── Part C: the TYPE DEFINITION is itself editable — a type's :fields (each with, for a
+;; Composition field, its block grammar) are displayed + edited on the type's own edit page.
+;; is this post a TYPE? (declares fields, or is subtype-of "type" transitively). --
+(define host/blog--is-type?
+ (fn (slug)
+ (or (> (len (host/blog-fields-of slug)) 0)
+ (contains? (host/blog--subtype-closure (host/blog-out slug "subtype-of") :out) "type"))))
+;; set a Composition field's grammar (:blocks + :allow) on a type, preserving its other fields.
+(define host/blog--set-field-grammar!
+ (fn (slug fname blocks allow)
+ (host/blog--set-fields! slug
+ (map (fn (f) (if (= (str (get f :name)) fname) (merge f {:blocks blocks :allow allow}) f))
+ (host/blog-fields-of slug)))))
+;; a labelled checkbox (the attr must be OMITTED when unchecked — an empty :checked still
+;; checks the box).
+(define host/blog--checkbox
+ (fn (name label checked)
+ (if checked
+ (quasiquote (label :style "margin-right:0.9em;white-space:nowrap"
+ (input :type "checkbox" :name (unquote name) :checked "checked") " " (unquote label)))
+ (quasiquote (label :style "margin-right:0.9em;white-space:nowrap"
+ (input :type "checkbox" :name (unquote name)) " " (unquote label))))))
;; render one labelled input per field, pre-filled from `values`. Widget per value-type
;; (textarea for Text, else a typed ). Pure — takes pre-fetched fields + values.
(define host/blog--field-inputs
@@ -1792,6 +1814,56 @@
(unquote (cons (quote div)
(map (fn (f) (host/blog--block-editor slug f)) (host/blog--composition-fields slug))))))))
+;; ── Part C: the TYPE-DEFINITION editor (shown when editing a TYPE post). "It's just more
+;; composition" — a type's :fields are displayed, and each Composition field's grammar (which
+;; card kinds + control blocks it permits) is edited with a checklist. The type governs what
+;; its instances' compositions can contain; here you edit that governance. --
+(define host/blog--own-field
+ (fn (slug fname)
+ (let loop ((fs (host/blog-fields-of slug)))
+ (cond ((empty? fs) nil) ((= (str (get (first fs) :name)) fname) (first fs)) (else (loop (rest fs)))))))
+;; the grammar edit form for ONE Composition field (id #grammar-): a checkbox per card
+;; type (checked = permitted) + conditional/repeater toggles, POSTing to //grammar.
+(define host/blog--grammar-form
+ (fn (slug fdecl)
+ (let ((fname (str (get fdecl :name)))
+ (cur (or (get fdecl :blocks) (host/blog--subtype-closure (list "card") :in)))
+ (ctrl (or (get fdecl :allow) (list "cond" "each")))
+ (all-cards (host/blog--subtype-closure (list "card") :in))
+ (url (str "/" slug "/grammar")))
+ (let ((tgt (str "#grammar-" fname))
+ (card-checks (map (fn (ct) (host/blog--checkbox (str "blk-" ct) (host/blog--card-label ct) (contains? cur ct))) all-cards)))
+ (cons (quote form)
+ (append
+ (quasiquote (:method "post" :action (unquote url) :sx-post (unquote url)
+ :sx-target (unquote tgt) :sx-swap "outerHTML"
+ :id (unquote (str "grammar-" fname))
+ :style "margin:0.3em 0;padding:0.5em;border:1px dashed #bbb"
+ (input :type "hidden" :name "field" :value (unquote fname))
+ (div :style "font-weight:bold;font-size:0.9em;margin-bottom:0.3em"
+ (unquote (str ":" fname " may contain these blocks —")))))
+ (append card-checks
+ (quasiquote ((br)
+ (unquote (host/blog--checkbox "allow-cond" "conditional (or)" (contains? ctrl "cond")))
+ (unquote (host/blog--checkbox "allow-each" "repeater (each)" (contains? ctrl "each")))
+ (br) (button :type "submit" :style "margin-top:0.4em" "save grammar"))))))))))
+;; the whole type-definition editor: a row per field (scalar shown as name:type; Composition
+;; shown with its grammar form).
+(define host/blog--type-def-editor
+ (fn (slug)
+ (let ((fields (host/blog-fields-of slug)))
+ (let ((rows (map (fn (f)
+ (if (= (get f :type) "Composition")
+ (host/blog--grammar-form slug f)
+ (quasiquote (div :style "padding:0.15em 0;color:#555"
+ (b (unquote (str (get f :name)))) (unquote (str " : " (get f :type)))))))
+ fields)))
+ (cons (quote div)
+ (append
+ (quasiquote (:id "type-def" :style "margin-top:1.5em;border-top:2px solid #999;padding-top:1em"
+ (h3 :style "font-size:1em;margin:0 0 0.4em" "Type definition — what this type's instances may contain")))
+ rows))))))
+
;; ── read handlers ───────────────────────────────────────────────────
;; Post body is rendered per-block (a guarded HTML string) then injected raw.
(define host/blog-post
@@ -2226,6 +2298,20 @@
(let ((slug (dream-param req "slug")) (field (host/blog--block-field req)) (ckey (or (host/field req "cond") "auth")))
(begin (when (host/blog-exists? slug) (host/blog-block-set-cond! slug field (host/blog--block-idx req) ckey))
(host/blog--block-resp req slug field)))))
+;; POST //grammar — set a Composition field's block grammar from the checklist. Only on a
+;; TYPE post (it defines what its instances' compositions may contain).
+(define host/blog-grammar-submit
+ (fn (req)
+ (let ((slug (dream-param req "slug")) (fname (or (host/field req "field") "body")))
+ (begin
+ (when (and (host/blog-exists? slug) (host/blog--is-type? slug))
+ (let ((all-cards (host/blog--subtype-closure (list "card") :in)))
+ (host/blog--set-field-grammar! slug fname
+ (filter (fn (ct) (not (nil? (host/field req (str "blk-" ct))))) all-cards)
+ (filter (fn (c) (not (nil? (host/field req (str "allow-" c))))) (list "cond" "each")))))
+ (if (host/blog--editor-swap-req? req)
+ (dream-html (render-page (host/blog--grammar-form slug (host/blog--own-field slug fname))))
+ (dream-redirect (str "/" slug "/edit")))))))
;; GET //edit — edit form pre-filled with the post's current title, raw
;; sx_content (in a textarea — render-to-html escapes the text child, so the
@@ -2244,6 +2330,8 @@
;; here, not in the quasiquote, so IO stays in the handler body.
(let ((relation-editors (host/blog--relation-editors slug))
(block-editor (host/blog--block-editors slug))
+ ;; if this post is a TYPE, its definition (fields + grammar) is editable here.
+ (type-def (if (host/blog--is-type? slug) (host/blog--type-def-editor slug) ""))
(tag-toggle (host/blog--is-tag-toggle slug))
(post-fields (host/blog--scalar-fields slug))
(field-values (host/blog-field-values-of slug))
@@ -2275,6 +2363,7 @@
(button :type "submit" "Save")))
(div :style "margin-top:2em;border-top:1px solid #ccc;padding-top:1em"
(unquote tag-toggle))
+ (unquote type-def)
(unquote block-editor)
(unquote relation-editors)
(p :style "margin-top:1.5em"
@@ -2360,6 +2449,7 @@
(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/grammar" (host/blog--protect-html resolve host/blog-grammar-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))
diff --git a/lib/host/tests/blog.sx b/lib/host/tests/blog.sx
index e90d0df7..f9ce95d6 100644
--- a/lib/host/tests/blog.sx
+++ b/lib/host/tests/blog.sx
@@ -971,6 +971,41 @@
(host/blog-block-add! "grm1" "body" "card-image" {"src" "x.jpg"}) ;; model add bypasses the handler guard
(> (len (host/blog--comp-violations "grm1" "body")) 0))
true)
+;; -- Part C: the TYPE DEFINITION is itself displayed + edited (as composition) on the type's
+;; own edit page. is-type? gates it; the grammar checklist edits what instances may contain. --
+(host-bl-test "is-type? recognises type posts (article, card-image) but not a plain instance"
+ (begin
+ (host/blog-put! "plainpost" "PP" "(p \"x\")" "published")
+ (list (host/blog--is-type? "article") (host/blog--is-type? "card-image") (host/blog--is-type? "plainpost")))
+ (list true true false))
+(host-bl-test "set-field-grammar! updates a Composition field's :blocks + :allow"
+ (begin
+ (host/blog-seed! "dt" "DT" "(p)" "published") (host/blog-relate! "dt" "type" "subtype-of")
+ (host/blog--set-fields! "dt" (list {:name "body" :type "Composition"}))
+ (host/blog--set-field-grammar! "dt" "body" (list "card-text") (list "each"))
+ (let ((f (host/blog--own-field "dt" "body")))
+ (list (get f :blocks) (get f :allow))))
+ (list (list "card-text") (list "each")))
+(host-bl-test "the type-def editor renders a grammar checklist for a Composition field"
+ (let ((html (render-page (host/blog--type-def-editor "article"))))
+ (list (contains? html "Type definition") (contains? html "may contain")
+ (contains? html "blk-card-image") (contains? html "allow-cond")))
+ (list true true true true))
+(host-bl-test "POST //grammar sets the grammar from the checklist"
+ (begin
+ (host/blog-seed! "dt2" "DT2" "(p)" "published") (host/blog-relate! "dt2" "type" "subtype-of")
+ (host/blog--set-fields! "dt2" (list {:name "body" :type "Composition"}))
+ (host-bl-wapp (host-bl-send "POST" "/dt2/grammar" "Bearer good"
+ "application/x-www-form-urlencoded" "field=body&blk-card-text=on&blk-card-heading=on&allow-cond=on"))
+ (let ((f (host/blog--own-field "dt2" "body")))
+ (list (contains? (get f :blocks) "card-text") (contains? (get f :blocks) "card-heading")
+ (contains? (get f :blocks) "card-image") (get f :allow))))
+ (list true true false (list "cond")))
+;; a type's edit page SHOWS the type-def editor; an instance's does not.
+(host-bl-test "the type-def editor appears on a type's edit page, not an instance's"
+ (list (contains? (dream-resp-body (host-bl-wapp (host-bl-send "GET" "/article/edit" "Bearer good" "" ""))) "Type definition")
+ (contains? (dream-resp-body (host-bl-wapp (host-bl-send "GET" "/my-first-post/edit" "Bearer good" "" ""))) "Type definition"))
+ (list true false))
;; 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)"