From a88ceda9d67f5293c6d5040da7b359a0dec73ce6 Mon Sep 17 00:00:00 2001 From: giles Date: Tue, 30 Jun 2026 14:18:29 +0000 Subject: [PATCH] =?UTF-8?q?host:=20cards-as-types=20=E2=80=94=20the=20blog?= =?UTF-8?q?=20content=20block=20vocabulary=20as=20metamodel=20types?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Seed the kg-card / content-on-sx block kinds as types: a 'card' root (subtype-of type) + card-heading/text/image/quote/code/embed/callout as subtypes, each with its own fields (host/blog--seed-card-type!). They appear in /meta (Types 11) and define (a) the editor's future card palette and (b) the radar migrator's target vocabulary. Instances-as-blocks vs instances-as-posts is a later decision — this is the vocabulary. plans/NOTE-blog-types-for-radar.md: the TYPE CONTRACT for the loops/radar migration — a blog post -> is-a article + typed field-values; body Ghost/Koenig cards -> these card-types. Two paths mapped onto radar's duplicate->cutover->diverge (type-at-import vs type-in-diverge), plus the open cards-as-blocks-vs-posts question for them to inform from the Ghost corpus. Verified live-path (/meta Types 11, card-types with fields) + focused eval (type-defs has card-image; fields src/alt/caption, heading level/text). Full blog conformance still blocked by box contention; test added for a quiet re-run. Co-Authored-By: Claude Opus 4.8 --- lib/host/blog.sx | 37 ++++++++++++++++++ lib/host/tests/blog.sx | 13 +++++++ plans/NOTE-blog-types-for-radar.md | 61 ++++++++++++++++++++++++++++++ 3 files changed, 111 insertions(+) create mode 100644 plans/NOTE-blog-types-for-radar.md diff --git a/lib/host/blog.sx b/lib/host/blog.sx index bc27f745..579a228a 100644 --- a/lib/host/blog.sx +++ b/lib/host/blog.sx @@ -584,6 +584,20 @@ :rel {:symmetric symmetric :label label :inverse-label inverse-label}})) (host/blog-relate! slug "relation" "is-a")))) +;; ── cards-as-types (the blog content vocabulary) ──────────────────── +;; Seed a card-type: a type-post subtype-of "card" with its own fields. The kg-card / +;; content-on-sx block vocabulary becomes the metamodel's card types, so the editor's +;; card palette + a post's body blocks are driven by type definitions, and the radar +;; migrator (plans/NOTE-blog-types-for-radar.md) maps old Ghost cards onto these. +(define host/blog--seed-card-type! + (fn (slug title fields) + (begin + (host/blog-seed! slug title + (str "(article (h1 \"" title "\") (p \"A " title " card — a kind of content block. Its fields define what the editor collects and the template renders.\"))") + "published") + (host/blog-relate! slug "card" "subtype-of") + (host/blog--set-fields! slug fields)))) + ;; Seed the root type-posts: "type" (the root) and "tag" (a kind of type). Types ;; ARE posts, so these are real posts that document themselves; tag subtype-of ;; type means anything that is-a tag is, transitively, a type. Idempotent — safe @@ -624,6 +638,29 @@ ;; above the body. (field "subtitle") resolves to the instance's value at render. (host/blog--set-template! "article" "(p :style \"font-style:italic;color:#555;margin:0 0 1em;font-size:1.1em\" (field \"subtitle\"))") + ;; ── cards-as-types: the blog content block vocabulary (kg-cards / content-on-sx + ;; block kinds) as metamodel types. "card" is the root; each card kind is a subtype + ;; with its own fields. These define the editor's card palette + the radar migrator's + ;; target vocabulary (plans/NOTE-blog-types-for-radar.md). Instances-as-blocks vs + ;; instances-as-posts is a later decision; this is the vocabulary. + (host/blog-seed! "card" "Card" + "(article (h1 \"Card\") (p \"A content block — the building unit of a post body. Each card kind is a type with its own fields; the editor collects them and the template renders them.\"))" + "published") + (host/blog-relate! "card" "type" "subtype-of") + (host/blog--seed-card-type! "card-heading" "Heading" + (list {:name "level" :type "Int"} {:name "text" :type "String"})) + (host/blog--seed-card-type! "card-text" "Text" + (list {:name "text" :type "Text"})) + (host/blog--seed-card-type! "card-image" "Image" + (list {:name "src" :type "URL"} {:name "alt" :type "String"} {:name "caption" :type "String"})) + (host/blog--seed-card-type! "card-quote" "Quote" + (list {:name "text" :type "Text"} {:name "cite" :type "String"})) + (host/blog--seed-card-type! "card-code" "Code" + (list {:name "language" :type "String"} {:name "code" :type "Text"})) + (host/blog--seed-card-type! "card-embed" "Embed" + (list {:name "url" :type "URL"} {:name "caption" :type "String"})) + (host/blog--seed-card-type! "card-callout" "Callout" + (list {:name "style" :type "String"} {:name "text" :type "Text"})) ;; relation DECLARATIONS (see plans/relations-as-posts.md). A type-post declares ;; which relation it anchors at its OBJECT end ("you may point at me with R"); the ;; picker's candidate set is the down-closure of a relation's anchors through the diff --git a/lib/host/tests/blog.sx b/lib/host/tests/blog.sx index 26851445..dd5a6bfd 100644 --- a/lib/host/tests/blog.sx +++ b/lib/host/tests/blog.sx @@ -703,6 +703,19 @@ "application/x-www-form-urlencoded" "title=Sneaky Rel")) (host/blog-exists? "sneaky-rel")) false) + +;; -- cards-as-types: the blog content block vocabulary -- +(host-bl-test "card-types are seeded as subtypes of card (in type-defs)" + (let ((defs (host/blog-type-defs))) + (list (contains? defs "card") (contains? defs "card-image") (contains? defs "card-heading"))) + (list true true true)) +(host-bl-test "a card-type carries its fields" + (map (fn (f) (get f :name)) (host/blog-fields-of "card-image")) + (list "src" "alt" "caption")) +(host-bl-test "/meta lists the card vocabulary with fields" + (let ((body (dream-resp-body (host-bl-app (host-bl-req "/meta"))))) + (list (contains? body ">Image") (contains? body "src:URL, alt:String"))) + (list true true)) (host-bl-test "a post with no schema'd type is vacuously valid" (host/blog-type-valid? "ppost" "(p \"anything\")") true) (host-bl-test "edit-submit rejects content violating the type schema (not saved)" diff --git a/plans/NOTE-blog-types-for-radar.md b/plans/NOTE-blog-types-for-radar.md new file mode 100644 index 00000000..be56fb74 --- /dev/null +++ b/plans/NOTE-blog-types-for-radar.md @@ -0,0 +1,61 @@ +# NOTE → the `loops/radar` migration: the blog TYPE CONTRACT for genesis-import + +**From:** the host-on-sx loop (`loops/host`). **Date:** 2026-06-30. +**Re:** `plans/rose-ash-on-sx-migration.md`, slice-01-blog. + +## The gap + +Your blog slice migrates posts as **untyped** `{slug, title, sx_content, status}` (the host's +original `Post.sx_content` shape). Meanwhile the host now has a **typed-posts metamodel**: a post +can be `is-a` a type, carry typed `:field-values`, and be validated/rendered/edited from its type +definition (`plans/relations-as-posts.md`). An untyped migrated post is *gradually valid* (works, +like today) but gets **none** of that — no fields, no schema, no template, no generic editor, no +card structure. So: **migrated blogs should be typed.** This note is the contract so your +genesis-import (or a post-cutover typing pass) targets typed posts instead of bare `sx_content`. + +## The contract (all defined in `host/blog-seed-types!`, visible at `/meta`) + +**Post-level type:** a blog post → **`is-a "article"`**. Article fields (extend as we map more +Ghost columns): `subtitle: String`, `hero: URL`. Article also has a `:schema` (requires an `h1`) +and a render `:template`. So: `relate(post, "article", "is-a")` + `:field-values {subtitle, hero}`. + +**Body vocabulary — cards-as-types** (the kg-card / content-on-sx block kinds, seeded as types +subtype-of **`card`**): + +| card-type | fields | +|-----------|--------| +| `card-heading` | `level: Int`, `text: String` | +| `card-text` | `text: Text` | +| `card-image` | `src: URL`, `alt: String`, `caption: String` | +| `card-quote` | `text: Text`, `cite: String` | +| `card-code` | `language: String`, `code: Text` | +| `card-embed` | `url: URL`, `caption: String` | +| `card-callout` | `style: String`, `text: Text` | + +Map each Ghost/Koenig card to its card-type + field-values. (More card kinds = more `seed-card-type!` +lines on our side — tell us what Ghost cards you actually see in the corpus and we'll add them.) + +## How it fits `duplicate → cutover → diverge` + +Two clean options, your call: +1. **Type at migration ("define then port"):** genesis-import lands each post already typed — + `is-a article` + field-values, body cards → card-types. Richer import; needs this vocabulary + frozen first (it now exists). +2. **Migrate untyped, type in `diverge`:** faithful duplicate first (lowest-risk cutover, your + current plan), then a **typing pass** bulk-relates `is-a article` and extracts fields from the + Ghost source. Typing becomes part of "diverge". Fits your strategy best. + +Either way the END STATE is typed posts against this vocabulary. The host **defines** it; your +migrator **consumes** it. + +## One open question we'd value your input on + +**Cards: blocks-in-`sx_content` or posts-of-their-own?** Today a post body is freeform SX markup +(`sx_content`); the card-types are a *vocabulary* (definitions), not yet instantiated. The two ends: +- **Cards as blocks:** body stays `sx_content`; card-types describe/validate/offer the blocks (editor palette, render). Simple, matches today. +- **Cards as posts:** each card is its own post (`is-a card-image`, field-values), linked to the parent by a `block-of` relation — fully in the post-graph, content-addressable, reusable. Powerful, bigger. + +Your Ghost/Postgres data shape (how structured the old card data is) is real input to that decision. +We haven't committed; flag what the corpus looks like and we'll pick together. + +— host-on-sx