;; lib/host/blog.sx — Blog domain on the host, on the EDITOR's content model. ;; The SX post editor (blog/sx/editor.sx) emits `sx_content`: SX element markup ;; (e.g. "(article (h1 \"T\") (p \"body\" (strong \"x\")))"), NOT content-on-sx ;; CtDoc blocks. So a post here is a record {slug,title,sx_content,status} stored ;; in the durable persist KV, and a post page is `render-to-html (parse sx_content)` ;; — server-side, static, no client runtime needed to view a published post. ;; ;; GET / HTML index of posts (public) ;; GET // rendered post (public) -> HTML / 404 ;; GET /posts SX list (public) -> {:ok true :data ({:slug …} …)} ;; GET /new HTML create form (public chrome) ;; POST /new form ingest from the editor (guarded) ;; POST //edit form ingest, edit an existing post (guarded) ;; Reads anonymous; writes behind the auth+ACL pipeline ("edit" on "blog"). The ;; JSON CRUD /posts (POST/PUT/DELETE) was deleted in the SX-native pivot — the wire ;; is SX/SXTP (host/ok emits text/sx), writes go through the form ingest. ;; Depends on spec/render + web/adapter-html (render-to-html), lib/persist/* ;; (durable KV), lib/dream/* (+ form), lib/host/{handler,middleware}.sx. ;; ── store (durable persist KV, injectable) ────────────────────────── (define host/blog-store (persist/open)) (define host/blog-use-store! (fn (b) (set! host/blog-store b))) (define host/blog--key (fn (slug) (str "blog:" slug))) ;; ── content-addressing: a universal CID over the canonical form ───── ;; Every object (content/type/relation/constraint post) carries a stable :cid = ;; hash of its CONTENT. The runtime has no hash primitive, so the canon serializer ;; and a tail-recursive double-hash are built here. Canon SORTS keys, so the CID is ;; identical across processes regardless of dict insertion / hash-seed order. The ;; :slug (a mutable name) and any prior :cid are excluded — the CID hashes content ;; only. git-shaped: slug = mutable name -> CID = immutable content identity. (define host/blog--canon (fn (v) (let ((t (type-of v))) (cond ((= t "dict") (str "{" (join "|" (map (fn (k) (str k "=" (host/blog--canon (get v k)))) (filter (fn (k) (and (not (= k "slug")) (not (= k "cid")))) (sort (keys v))))) "}")) ((= t "list") (str "[" (join "|" (map host/blog--canon v)) "]")) ((= t "nil") "~") (else (str v)))))) (define host/blog--hash-go (fn (s i n h1 h2) (if (>= i n) (str h1 "-" h2) (let ((c (char-code (substr s i 1)))) (host/blog--hash-go s (+ i 1) n (mod (+ (* h1 131) c) 1000000007) (mod (+ (* h2 137) c) 998244353)))))) (define host/blog--cid-of (fn (rec) (let ((s (host/blog--canon rec))) (str "z" (host/blog--hash-go s 0 (len s) 7 11))))) ;; the single choke point for every record write: stamps the content CID, then puts. (define host/blog--write! (fn (slug rec) (persist/backend-kv-put host/blog-store (host/blog--key slug) (merge rec {:cid (host/blog--cid-of rec)})))) ;; slug from a title: lowercase, words joined by '-'. (Punctuation kept simple.) (define host/blog-slugify (fn (title) (join "-" (filter (fn (w) (not (= w ""))) (split (lower title) " "))))) ;; ── records ───────────────────────────────────────────────────────── (define host/blog-get (fn (slug) (persist/backend-kv-get host/blog-store (host/blog--key slug)))) (define host/blog-exists? (fn (slug) (persist/backend-kv-has? host/blog-store (host/blog--key slug)))) ;; A write preserves any extra slots already on the record (:rel for relation-posts, ;; :schema for refinement types) — merging over the previous record — so editing a ;; post's title/content/status doesn't nuke the metadata that lives alongside it. (define host/blog-put! (fn (slug title sx-content status) (let ((prev (host/blog-get slug))) (host/blog--write! slug (merge (if prev prev {}) {:slug slug :title title :sx-content sx-content :status status}))))) (define host/blog-delete! (fn (slug) (persist/backend-kv-delete host/blog-store (host/blog--key slug)))) (define host/blog-seed! (fn (slug title sx-content status) (when (not (host/blog-exists? slug)) (host/blog-put! slug title sx-content status)))) ;; all blog slugs (kv keys are "blog:") (define host/blog-slugs (fn () (reduce (fn (acc k) (if (starts-with? k "blog:") (append acc (list (substr k 5))) acc)) (list) (persist/backend-kv-keys host/blog-store)))) (define host/blog-list (fn () (map (fn (slug) (let ((r (host/blog-get slug))) {:slug slug :title (get r :title) :status (get r :status)})) (host/blog-slugs)))) ;; a post's content CID — its global, location-independent identity (nil if unknown). (define host/blog-cid (fn (slug) (get (host/blog-get slug) :cid))) ;; reverse lookup: a slug whose record has this CID (nil if none). Scan; not for renders. (define host/blog-by-cid (fn (cid) (reduce (fn (acc slug) (if acc acc (if (= (host/blog-cid slug) cid) slug acc))) nil (host/blog-slugs)))) ;; ── render ────────────────────────────────────────────────────────── ;; A post's sx_content is SX element markup -> HTML via render-page (which supplies ;; the server env so components resolve + keyword attrs are kept). ;; ;; Rendered PER BLOCK and guarded: the editor wraps content in a (<> ...) fragment ;; of blocks, some of which the host can't render (the legacy editor emits bare ;; ~kg-md cards while the components are ~kg_cards/kg-md — drift we don't paper over ;; with aliases). Rendering each block under its own guard means the real prose ;; (p/h1/ul/...) shows and only the unsupported block degrades to a placeholder — ;; and a bad block never crashes the handler (-> 502). (define host/blog--render-node (fn (node) (guard (e (true "
(unsupported block)
")) (render-page node)))) (define host/blog-render (fn (record) (let ((sx (get record :sx-content))) (if (and sx (not (= sx ""))) (let ((tree (parse-safe sx))) (cond ((nil? tree) "

(unparseable content)

") ((and (= (type-of tree) "list") (> (len tree) 0) (= (str (first tree)) "<>")) (join "" (map host/blog--render-node (rest tree)))) (else (host/blog--render-node tree)))) (str "

(empty post)

"))))) ;; ── related posts (blog × relations) ──────────────────────────────── ;; Every link between posts is a typed edge in the relations graph (lib/relations): ;; node = "blog:", kind = a relation kind. "related" is symmetric; directed ;; kinds (is-a, tagged) carry meaning by direction. The registry below is the one ;; place that knows each kind's direction, label, and candidate set — relate, the ;; picker, and rendering all read from it (see plans/typed-posts-and-relations.md). ;; "Typing is just relating to a type": classification is an is-a/tagged edge to a ;; type-post, and types ARE posts (same blog: namespace). (define host/blog--node (fn (slug) (string->symbol (str "blog:" slug)))) ;; Relations are POSTS (plans/relations-as-posts.md). Each relation-post is-a ;; `relation` and owns its metadata in a :rel slot {:symmetric :label ;; :inverse-label}. To keep RENDER paths perform-free — a durable read inside the ;; http-listen render VM raises VmSuspended — the relation specs are loaded into an ;; in-memory cache at boot, exactly like edges (host/blog-load-edges!). kind-spec / ;; rel-kinds / kind-symmetric? then read the cache (pure); the relation-posts stay ;; the durable source of truth. host/blog-load-rel-kinds! re-reads them. (define host/blog--rel-cache (dict)) ;; cache one relation-post's :rel metadata (+ :kind), keyed by slug. (define host/blog--cache-rel! (fn (kind) (let ((m (get (host/blog-get kind) :rel))) (when m (dict-set! host/blog--rel-cache kind (merge {:kind kind} m)))))) ;; host/blog-rel-kinds is a VALUE (the list of relation specs), populated at boot by ;; load-rel-kinds! — like slice 1's static registry, which mapped fine on the live ;; serving JIT. (Computing it as a function that map/for-each-es a function-produced ;; list silently lost 3 of 4 relations on the live JIT — see plans/relations-as-posts.md ;; / plans/jit-bytecode-correctness.md. Both the cache loads and the list build are ;; therefore UNROLLED — no iteration over the relation list.) Metadata still lives on ;; the relation-posts; add a relation = a seed-rel! + a line in each unrolled list. (define host/blog-rel-kinds (list)) ;; UNROLLED, and it must STAY unrolled: load-rel-kinds! runs at BOOT, where it is ;; JIT-compiled but the http-listen IO resolver is NOT yet installed (that happens when ;; serving starts). The serving-JIT HO-callback-perform fix (81177d0e) only engages WITH ;; the resolver, so a dynamic loader (map/for-each/reduce over instances-of "relation" ;; with a durable read per item) silently returns [] at boot — verified 2026-06-30: ;; dynamic loader -> /meta Relations(0). So the cache loads + the list are UNROLLED (no ;; HO over a function-produced list). A new relation is a seed-rel! + a line here; or ;; appended at RUNTIME (where the resolver IS installed) — see host/blog-meta-new-relation. (define host/blog-load-rel-kinds! (fn () (begin (host/blog--cache-rel! "related") (host/blog--cache-rel! "is-a") (host/blog--cache-rel! "subtype-of") (host/blog--cache-rel! "tagged") (set! host/blog-rel-kinds (list (get host/blog--rel-cache "related") (get host/blog--rel-cache "is-a") (get host/blog--rel-cache "subtype-of") (get host/blog--rel-cache "tagged")))))) ;; spec = the cached :rel metadata + :kind; nil for a non-relation (relate validates). (define host/blog--kind-spec (fn (kind) (get host/blog--rel-cache kind))) (define host/blog--kind-symmetric? (fn (kind) (let ((s (host/blog--kind-spec kind))) (and s (get s :symmetric))))) ;; ── edges (parameterised by kind, DURABLE) ────────────────────────── ;; lib/relations holds the graph in memory (a Datalog cache that re-saturates per ;; query); it does NOT survive a restart. So the host owns the durable source of ;; truth: every physical edge is also a KV row "edge:||" in the ;; blog store, replayed into the in-memory graph on boot (host/blog-load-edges!). ;; '|' is a safe delimiter — slugs are [a-z0-9-], kinds are registry names. (define host/blog--edge-key (fn (src kind dst) (str "edge:" src "|" kind "|" dst))) (define host/blog--add-edge! (fn (src dst kind) (begin (relations/relate (host/blog--node src) (host/blog--node dst) (string->symbol kind)) (persist/backend-kv-put host/blog-store (host/blog--edge-key src kind dst) 1)))) (define host/blog--del-edge! (fn (src dst kind) (begin (relations/unrelate (host/blog--node src) (host/blog--node dst) (string->symbol kind)) (persist/backend-kv-delete host/blog-store (host/blog--edge-key src kind dst))))) ;; A symmetric kind writes both directions, so children alone read it from either ;; side; a directed kind writes one edge (the inverse is host/blog-in). (define host/blog-relate! (fn (a b kind) (begin (host/blog--add-edge! a b kind) (when (host/blog--kind-symmetric? kind) (host/blog--add-edge! b a kind))))) (define host/blog-unrelate! (fn (a b kind) (begin (host/blog--del-edge! a b kind) (when (host/blog--kind-symmetric? kind) (host/blog--del-edge! b a kind))))) ;; rebuild the in-memory graph from the durable edge store — called on boot, after ;; the store is pointed at the durable backend. Each "edge:||" key ;; is re-applied directly (both directions of a symmetric kind are stored, so no ;; symmetry re-derivation is needed here). (define host/blog-load-edges! (fn () (for-each (fn (key) (let ((body (substr key 5))) ;; drop "edge:" (let ((p1 (index-of body "|"))) (when (>= p1 0) (let ((src (substr body 0 p1)) (tail (substr body (+ p1 1)))) (let ((p2 (index-of tail "|"))) (when (>= p2 0) (let ((ek (substr tail 0 p2))) ;; conj/disj are structural (type-algebra operands) — KV-only, ;; never replayed into the Datalog graph (it re-saturates per query). (when (not (or (= ek "conj") (= ek "disj"))) (relations/relate (host/blog--node src) (host/blog--node (substr tail (+ p2 1))) (string->symbol ek))))))))))) (filter (fn (k) (starts-with? k "edge:")) (persist/backend-kv-keys host/blog-store))))) ;; nodes -> existing blog slugs: strip "blog:", drop non-blog and deleted targets. ;; Existence is one kv-keys read (host/blog-slugs), NOT a perform per candidate — ;; keeping IO out of the inner filter (and out of the page-render quasiquote). (define host/blog--edge-slugs (fn (nodes) (let ((existing (host/blog-slugs))) (filter (fn (s) (contains? existing s)) (map (fn (n) (substr (symbol->string n) 5)) (filter (fn (n) (starts-with? (symbol->string n) "blog:")) nodes)))))) ;; DIRECT edges come from the durable edge store, NOT lib/relations: each relations ;; query re-runs the (CEK-interpreted) ruleset — ~seconds even on a tiny graph — ;; whereas the edge:|| KV rows are a cheap string scan. lib/relations ;; is reserved for TRANSITIVE queries (descendants/ancestors). The two are always ;; in sync: host/blog-relate! writes both, and a plain blog edge has no derived ;; effective edges, so KV == relations/children for direct lookups. (define host/blog--parse-edge-key (fn (k) (if (starts-with? k "edge:") (let ((body (substr k 5))) (let ((p1 (index-of body "|"))) (if (< p1 0) nil (let ((rest (substr body (+ p1 1)))) (let ((p2 (index-of rest "|"))) (if (< p2 0) nil {:src (substr body 0 p1) :kind (substr rest 0 p2) :dst (substr rest (+ p2 1))})))))) nil))) (define host/blog--all-edges (fn () (filter (fn (e) (not (nil? e))) (map host/blog--parse-edge-key (persist/backend-kv-keys host/blog-store))))) ;; outgoing targets / incoming sources of `slug` under `kind`, as existing slugs. (define host/blog-out (fn (slug kind) (let ((existing (host/blog-slugs))) (filter (fn (s) (contains? existing s)) (reduce (fn (acc e) (if (and (= (get e :src) slug) (= (get e :kind) kind)) (concat acc (list (get e :dst))) acc)) (list) (host/blog--all-edges)))))) (define host/blog-in (fn (slug kind) (let ((existing (host/blog-slugs))) (filter (fn (s) (contains? existing s)) (reduce (fn (acc e) (if (and (= (get e :dst) slug) (= (get e :kind) kind)) (concat acc (list (get e :src))) acc)) (list) (host/blog--all-edges)))))) ;; back-compat: "related posts" is just the symmetric "related" kind. (define host/blog-related (fn (slug) (host/blog-out slug "related"))) ;; ── typing: is-a + subtype-of with subsumption ────────────────────── ;; Typing is just relating to a type, and types ARE posts. A post DECLARES its ;; types with is-a edges; types form a hierarchy with subtype-of edges. is-a ;; (instance-of) is NOT transitive on its own, but subsumption is: an instance of ;; a subtype is an instance of the supertype. So a post's full type set is its ;; declared types PLUS every subtype-of-ancestor of each (relations/descendants ;; follows subtype-of transitively). Keeps the Datalog ruleset minimal — the ;; closure is composed host-side. (define host/blog--uniq (fn (xs) (reduce (fn (acc x) (if (contains? acc x) acc (concat acc (list x)))) (list) xs))) (define host/blog-types-of (fn (slug) (host/blog--uniq (reduce (fn (acc t) (concat (concat acc (list t)) (host/blog--edge-slugs (relations/descendants (host/blog--node t) (string->symbol "subtype-of"))))) (list) (host/blog-out slug "is-a"))))) ;; is this post (transitively) of the given type-slug? (define host/blog-is-a? (fn (slug type) (contains? (host/blog-types-of slug) type))) ;; all posts that are (transitively) instances of `type`: instances of the type ;; itself plus instances of any of its subtypes. Computed in O(#subtypes) relation ;; queries, NOT one type-resolution per post — the efficient way to enumerate a ;; type's members (e.g. "all tags") for the picker. (define host/blog-instances-of (fn (type) (let ((subtypes (concat (list type) (host/blog--edge-slugs (relations/ancestors (host/blog--node type) (string->symbol "subtype-of")))))) (host/blog--uniq (reduce (fn (acc t) (concat acc (host/blog-in t "is-a"))) (list) subtypes))))) ;; All type-posts: the subtype-of hierarchy rooted at "type" (type + its transitive ;; subtypes). This is "the types you've DEFINED" — distinct from host/blog-instances-of ;; "type" (which is the is-a INSTANCES of the type, i.e. typed content, not the type ;; definitions; the definitions are linked by subtype-of, the same set instances-of ;; computes internally as `subtypes`). Used by the metamodel overview + editor. (define host/blog-type-defs (fn () (host/blog--uniq (concat (list "type") (host/blog--edge-slugs (relations/ancestors (host/blog--node "type") (string->symbol "subtype-of"))))))) ;; ── Slice 4: type ALGEBRA — intersection (∧) and union (∨) types ───── ;; An algebraic type is a post with operand edges: a `conj` edge per intersection ;; member, a `disj` edge per union member. Its EXTENT is its operands' extents combined ;; by set intersection / union, recursively — so types compose into an algebra in the ;; same graph (meta-circular: an algebraic type is just another post). Binary today ;; (nth 0/1, no fold over operands — robust on the serving JIT); n-ary is a follow-up. ;; is-a-expr? generalises is-a? to type expressions. (define host/blog--set-intersect (fn (xs ys) (filter (fn (x) (contains? ys x)) xs))) ;; operand edges live in the KV ONLY (read back via host/blog-out), NOT in lib/relations: ;; conj/disj are structural, and feeding extra kinds into the Datalog graph blows up its ;; per-query re-saturation. host/blog-load-edges! skips them on replay for the same reason. (define host/blog--add-edge-kv! (fn (src dst kind) (persist/backend-kv-put host/blog-store (host/blog--edge-key src kind dst) 1))) (define host/blog-make-and! (fn (t a b) (begin (host/blog-seed! t t (str "(article (h1 \"" t "\") (p \"An intersection type (" a " ∧ " b ") — its instances are exactly those that are instances of BOTH.\"))") "published") (host/blog--add-edge-kv! t a "conj") (host/blog--add-edge-kv! t b "conj")))) (define host/blog-make-or! (fn (t a b) (begin (host/blog-seed! t t (str "(article (h1 \"" t "\") (p \"A union type (" a " ∨ " b ") — its instances are those that are instances of EITHER.\"))") "published") (host/blog--add-edge-kv! t a "disj") (host/blog--add-edge-kv! t b "disj")))) ;; the EXTENT of a type expression: operands' extents combined by set ops (recursive). ;; A plain type (no operands) falls through to its instances. (define host/blog-instances-of-expr (fn (t) (let ((conj (host/blog-out t "conj")) (disj (host/blog-out t "disj"))) (cond ((>= (len conj) 2) (host/blog--set-intersect (host/blog-instances-of-expr (nth conj 0)) (host/blog-instances-of-expr (nth conj 1)))) ((>= (len disj) 2) (host/blog--uniq (concat (host/blog-instances-of-expr (nth disj 0)) (host/blog-instances-of-expr (nth disj 1))))) (else (host/blog-instances-of t)))))) ;; is `slug` a member of the type expression `t`? Generalises is-a? to the algebra. (define host/blog-is-a-expr? (fn (slug t) (contains? (host/blog-instances-of-expr t) slug))) ;; ── tags (a tag is a post that is-a tag) ──────────────────────────── (define host/blog-is-tag? (fn (slug) (host/blog-is-a? slug "tag"))) (define host/blog-tags (fn (slug) (host/blog-out slug "tagged"))) ;; a post's tags (define host/blog-tagged-with (fn (tag) (host/blog-in tag "tagged"))) ;; posts with a tag ;; ── gradual validation: refinement types (schemas ON the type-post) ── ;; A type-post may carry a SCHEMA in a :schema slot: a list of rules ;; {:block :msg }, each requiring the content to contain (anywhere) an ;; element of that tag — i.e. a refinement type {x : T | x has these blocks}. A post ;; is checked against the schema of every type it is-a; a type with no schema imposes ;; nothing (gradual). Schemas are declarative data (not opaque predicates), so they ;; yield a specific, human error AND live on the type-post (Slice 5) — make a new ;; refinement type by giving its post a :schema (host/blog--set-schema!). ;; schema-of reads the type-post; only the SAVE path calls it (a write request, where ;; a durable read is fine — never in a render, which would VmSuspend). (define host/blog-schema-of (fn (type-slug) (get (host/blog-get type-slug) :schema))) ;; attach/replace a type-post's :schema (idempotent; preserves the rest of the record). ;; Used at boot to install schemas on type-posts — incl. migrating ones seeded before ;; schemas lived on the post (a single read+write, not a loop, so boot-JIT-safe). (define host/blog--set-schema! (fn (slug schema) (let ((r (host/blog-get slug))) (when r (host/blog--write! slug (merge r {:schema schema})))))) ;; ── Slice 8: typed scalar FIELDS on a type (the keystone for the UI) ─ ;; A type declares :fields — a list of {:name :type [:widget] [:required]} specs. A ;; field holds a typed VALUE on an instance (vs a relation, which is an edge to a post). ;; value-type names map to a default input widget; fields drive BOTH the generic edit ;; form (one input per field) AND the render template. Direct fields for now; inheritance ;; through subtype-of is a follow-up. See plans/relations-as-posts.md ("Types define the UI"). (define host/blog-value-types {"String" {:widget "text"} "Text" {:widget "textarea"} "URL" {:widget "url"} "Int" {:widget "number"} "Date" {:widget "date"} "Bool" {:widget "checkbox"}}) ;; the input widget for a field: its explicit :widget, else its value-type's default, ;; else "text" (an unknown value-type degrades to a plain text input). (define host/blog--widget-for (fn (field) (or (get field :widget) (let ((vt (get host/blog-value-types (get field :type)))) (if vt (get vt :widget) "text"))))) ;; a type-post's declared fields (empty list if none). (define host/blog-fields-of (fn (type-slug) (or (get (host/blog-get type-slug) :fields) (list)))) ;; attach/replace a type-post's :fields (idempotent; preserves the rest of the record). (define host/blog--set-fields! (fn (slug fields) (let ((r (host/blog-get slug))) (when r (host/blog--write! slug (merge r {:fields fields})))))) ;; "name:Type, name:Type" — a one-line summary of a field list (for /meta). "—" if none. (define host/blog--fields-summary (fn (fields) (if (and fields (> (len fields) 0)) (join ", " (map (fn (f) (str (get f :name) ":" (get f :type))) fields)) "—"))) ;; ── Slice 8b: field VALUES on an instance + the generic, type-driven form ── ;; An instance carries :field-values = {field-name -> value}. The fields applicable to ;; a post are the union of the fields declared by every type it is-a (deduped by name) — ;; so the SAME form is generated from the type definitions, no per-type code. This IS ;; "the editor maps onto the types": host/blog--field-inputs turns a type's fields into ;; the edit inputs; host/blog-edit-submit reads them back. Display-via-template is next. (define host/blog-field-values-of (fn (slug) (or (get (host/blog-get slug) :field-values) {}))) (define host/blog--set-field-values! (fn (slug vals) (let ((r (host/blog-get slug))) (when r (host/blog--write! slug (merge r {:field-values vals})))))) ;; the fields applicable to a post = union over its (transitive) types' fields, deduped ;; by name. One durable graph read (types-of) up front — call in a handler let, not a render. (define host/blog--fields-for-post (fn (slug) (reduce (fn (acc t) (reduce (fn (a f) (if (contains? (map (fn (g) (get g :name)) a) (get f :name)) a (concat a (list f)))) acc (host/blog-fields-of t))) (list) (host/blog-types-of slug)))) ;; 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 (fn (fields values) (map (fn (f) (let ((nm (get f :name)) (w (host/blog--widget-for f))) (let ((val (or (get values nm) ""))) (quasiquote (p (label :style "display:block;font-size:0.85em;opacity:0.7" (unquote (str nm " (" (get f :type) ")"))) (unquote (if (= w "textarea") (quasiquote (textarea :name (unquote (str "field-" nm)) :rows "3" :style "width:100%" (unquote val))) (quasiquote (input :type (unquote w) :name (unquote (str "field-" nm)) :value (unquote val) :style "width:100%"))))))))) fields))) ;; ── Slice 8c: render TEMPLATE per type (fields drive the page, not just the form) ── ;; A type may declare a :template — a parameterised SX tree (stored as source) where ;; (field "name") placeholders resolve to the instance's field-values at render. So ONE ;; field definition drives BOTH the edit form (above) AND the rendered page. The template ;; is DATA (editable, meta-circular); a type with no template renders nothing extra. See ;; plans/relations-as-posts.md ("Types define the UI"). (define host/blog-template-of (fn (type-slug) (get (host/blog-get type-slug) :template))) (define host/blog--set-template! (fn (slug template) (let ((r (host/blog-get slug))) (when r (host/blog--write! slug (merge r {:template template})))))) ;; replace every (field "name") node in a parsed template tree with values[name] ("" if ;; absent). Pure: a tree-walk over the already-parsed template + pre-fetched values. (define host/blog--instantiate (fn (node values) (if (and (= (type-of node) "list") (> (len node) 0)) (if (= (str (first node)) "field") (or (get values (first (rest node))) "") (map (fn (c) (host/blog--instantiate c values)) node)) node))) ;; the rendered typed block for a post: for each type it is-a that declares a :template, ;; parse + instantiate with the post's field-values. (div …) of the results, or "" if none. ;; Durable reads (types-of, template-of, field-values) — call in a handler let, not a render. (define host/blog--typed-block (fn (slug) (let ((values (host/blog-field-values-of slug)) (templates (reduce (fn (acc t) (let ((tpl (host/blog-template-of t))) (if tpl (concat acc (list tpl)) acc))) (list) (host/blog-types-of slug)))) (if (> (len templates) 0) (cons (quote div) (map (fn (tpl) (host/blog--instantiate (parse-safe tpl) values)) templates)) "")))) ;; every element tag in a parsed content tree, recursively (the heads of nested ;; lists) — so "requires h1" matches an h1 even inside an article/section wrapper. (define host/blog--all-tags (fn (tree) (if (and (= (type-of tree) "list") (> (len tree) 0)) (concat (list (str (first tree))) (reduce (fn (acc c) (concat acc (host/blog--all-tags c))) (list) (rest tree))) (list)))) ;; the :msg of each required :block a schema asks for but the content lacks. (define host/blog--schema-issues (fn (schema content) (let ((tags (host/blog--all-tags (parse-safe content)))) (reduce (fn (acc rule) (if (contains? tags (get rule :block)) acc (concat acc (list (get rule :msg))))) (list) schema)))) ;; all schema issues for a post = the union over every type it is-a that carries a ;; schema. Empty = valid; vacuous (and cheap) when no type has a schema. (define host/blog-type-issues (fn (slug content) (reduce (fn (acc t) (let ((s (host/blog-schema-of t))) (if s (concat acc (host/blog--schema-issues s content)) acc))) (list) (host/blog-types-of slug)))) (define host/blog-type-valid? (fn (slug content) (= (len (host/blog-type-issues slug content)) 0))) ;; Seed a relation-post: a post that is-a `relation` and carries its metadata in a ;; :rel slot. Idempotent (the record is written once; the is-a edge is a set). (define host/blog--seed-rel! (fn (slug title symmetric label inverse-label) (begin (when (not (host/blog-exists? slug)) (host/blog--write! slug {:slug slug :title title :sx-content (str "(article (h1 \"" title "\") (p \"A relation — posts link to each other through it. Its symmetry and labels live on this post.\"))") :status "published" :rel {:symmetric symmetric :label label :inverse-label inverse-label}})) (host/blog-relate! slug "relation" "is-a")))) ;; 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 ;; to call on every boot (host/blog-seed! no-ops if present, edges are sets). (define host/blog-seed-types! (fn () (begin ;; relations are posts too — `relation` is their root; each relation-post ;; is-a relation and owns its symmetry + labels (plans/relations-as-posts.md). (host/blog-seed! "relation" "Relation" "(article (h1 \"Relation\") (p \"The root of relations. A relation is a typed edge between posts; each relation-post declares its symmetry and labels, and a type anchors its object end (which gives the picker its candidates).\"))" "published") (host/blog--seed-rel! "related" "related" true "Related posts" nil) (host/blog--seed-rel! "is-a" "is a" false "Types" "Instances") (host/blog--seed-rel! "subtype-of" "subtype of" false "Subtype of" "Subtypes") (host/blog--seed-rel! "tagged" "tagged" false "Tags" "Tagged with this") (host/blog-seed! "type" "Type" "(article (h1 \"Type\") (p \"The root type. Types are posts — so this is a post that documents the idea of a type. A post declares its types with is-a edges; types form a hierarchy with subtype-of edges.\"))" "published") (host/blog-seed! "tag" "Tag" "(article (h1 \"Tag\") (p \"A tag is a kind of type (tag subtype-of type), so anything that is-a tag is also a type. A post is tagged with a tag; a tag post documents the tag and lists what is tagged with it.\"))" "published") (host/blog-relate! "tag" "type" "subtype-of") ;; "article" — a type WITH a schema (requires a heading). Posts that is-a ;; article are validated against it on save (gradual typing in action). (host/blog-seed! "article" "Article" "(article (h1 \"Article\") (p \"A kind of post that must have a heading. A post that is-a article is checked against this type's schema on save — gradual typing: declaring the type adds the requirement, and the next edit must satisfy it.\"))" "published") (host/blog-relate! "article" "type" "subtype-of") ;; article's schema lives ON the article post now (Slice 5) — install/migrate it. (host/blog--set-schema! "article" (list {:block "h1" :msg "an article needs a heading (h1)"})) ;; article's typed FIELDS (Slice 8) — these drive the generic edit form + the render ;; template: a subtitle (plain text) and an optional hero image URL. (host/blog--set-fields! "article" (list {:name "subtitle" :type "String"} {:name "hero" :type "URL"})) ;; article's render TEMPLATE (Slice 8c) — the subtitle field shown as a standfirst ;; 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\"))") ;; 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 ;; type graph, so the candidates for a relation are exactly the posts that inherit ;; its declaration. `type` anchors is-a + subtype-of (you point at a type), `tag` ;; anchors tagged (you point at a tag). `related` has no anchor → every post. (host/blog-relate! "type" "is-a" "declares") (host/blog-relate! "type" "subtype-of" "declares") (host/blog-relate! "tag" "tagged" "declares")))) ;; ── relate picker (filterable, paginated candidate list) ──────────── ;; Candidates to relate `slug` to: every post except itself and ones already ;; related, narrowed by `q` (case-insensitive substring of title or slug), ;; title-sorted. One page is `host/blog--picker-limit` rows from `offset`. (define host/blog--picker-limit 20) ;; Down-closure: every post reachable from `roots` by walking INVERSE is-a ∪ ;; subtype-of edges (i.e. instances and subtypes, transitively), roots included. ;; This is "everything that is, transitively, an instance-or-subtype of a root". ;; BFS over direct edges (host/blog-in); `seen` makes it cycle-safe and terminating. (define host/blog--reach-down (fn (roots) (let loop ((frontier roots) (seen (list))) (if (empty? frontier) seen (let ((t (first frontier))) (if (contains? seen t) (loop (rest frontier) seen) (loop (concat (rest frontier) (concat (host/blog-in t "is-a") (host/blog-in t "subtype-of"))) (concat seen (list t))))))))) ;; The candidate POOL for relating under `kind` is DECLARATION-driven (see ;; plans/relations-as-posts.md): the down-closure of the posts that DECLARE `kind` ;; at their object end. So is-a/subtype-of (anchored by `type`) offer the whole type ;; closure — roots AND instances — and `tagged` (anchored by `tag`) offers the tags. ;; A relation with no declaration (e.g. `related`) offers every post. (define host/blog--candidate-pool (fn (kind) (let ((anchors (host/blog-in kind "declares"))) (if (empty? anchors) (host/blog-slugs) (host/blog--reach-down anchors))))) ;; Slice 3 — typed relations: a post is a valid OBJECT (target end) of `kind` iff it's ;; in the relation's declared candidate set (the down-closure of kind's declares-anchors ;; — the target-type constraint). The SAME set the picker offers, so the picker and ;; the relate endpoint agree by construction. A relation with no anchor (`related`) ;; accepts any existing post. This is what turns "candidate set" into an enforced ;; relation schema: is-a's object must be a type, tagged's must be a tag, etc. (define host/blog--valid-object? (fn (kind other) (contains? (host/blog--candidate-pool kind) other))) (define host/blog--title (fn (s) (get (host/blog-get s) :title))) ;; one durable read ;; One PAGE of candidates (records {:slug :title}) for relating `slug` under `kind`. ;; Slice 2.5 — title reads are O(page), not O(pool): the available candidate SLUGS are ;; computed + slug-sorted with NO per-candidate read; then titles are fetched only for ;; the rows actually returned. On the unfiltered path (q="" — the initial picker load ;; AND every editor server-fill) that's ~`limit` reads instead of one-per-post, which ;; was the durable-read churn under http-listen. A filter (q≠"") still resolves titles ;; across the pool, since it matches on the title — but that's the interactive path. (define host/blog--relate-candidates (fn (slug q kind offset limit) (let ((pool (host/blog--candidate-pool kind)) (already (host/blog-out slug kind)) (ql (lower (or q "")))) (let ((avail (sort (filter (fn (s) (and (not (= s slug)) (not (contains? already s)))) pool)))) (if (= ql "") ;; no filter: page by slug, then read titles for just the page (map (fn (s) {:slug s :title (host/blog--title s)}) (take (drop avail offset) limit)) ;; filter: resolve titles, match on title|slug, then page (let ((recs (map (fn (s) {:slug s :title (host/blog--title s)}) avail))) (take (drop (filter (fn (r) (or (contains? (lower (get r :title)) ql) (contains? (get r :slug) ql))) recs) offset) limit))))))) ;; One candidate row: a tiny form whose button adds the relation under `kind`. (define host/blog--picker-item (fn (slug p kind) (quasiquote (li :id (unquote (str "cand-" kind "-" (get p :slug))) :style "border-bottom:1px solid #eee" ;; AJAX relate: sx-post the relation, then sx-swap="outerHTML" re-renders the ;; WHOLE relation editor for this kind (its sx-target #rel-editor-KIND) — the ;; just-related post moves into the current-relations list and out of the ;; candidate pool, and the fresh picker re-loads its candidates. (A bare ;; delete of this row added the relation server-side but never showed it in ;; the current list; re-rendering the editor keeps BOTH lists in sync.) ;; method+action stay for the no-JS fallback (plain POST -> 303 -> reload). (form :method "post" :style "margin:0" :action (unquote (str "/" slug "/relate")) :sx-post (unquote (str "/" slug "/relate")) :sx-target (unquote (str "#rel-editor-" kind)) :sx-swap "outerHTML" (input :type "hidden" :name "other" :value (unquote (get p :slug))) (input :type "hidden" :name "kind" :value (unquote kind)) (button :type "submit" :style "width:100%;text-align:left;background:none;border:none;padding:0.5em;cursor:pointer" (unquote (get p :title)))))))) ;; The infinite-scroll "load more" sentinel: an
  • that, when scrolled into view ;; (sx-trigger "revealed"), GETs the NEXT page and replaces ITSELF (sx-swap ;; outerHTML, default self-target) with those rows + the next sentinel. This is the ;; SX-htmx engine doing the paging — no client JS. q is %-encoded back into the URL ;; so the filter is preserved across pages. (define host/blog--picker-more (fn (slug kind q next) (quasiquote (li :class "rp-more" :style "list-style:none;padding:0.5em;text-align:center;opacity:0.6" :sx-get (unquote (str "/" slug "/relate-options?kind=" kind "&q=" (dr/url-encode q) "&offset=" next)) :sx-trigger "revealed" :sx-swap "outerHTML" ;; a dropped/offline page-fetch retries with exponential backoff (1s→30s) ;; until it succeeds, so a flaky connection self-heals as you scroll. :sx-retry "exponential:1000:30000" "Loading more…")))) ;; GET //relate-options?kind=&q=&offset= — one page of candidate rows for a ;; kind as an HTML fragment, swapped into the picker by the SX-htmx engine. A full ;; page is followed by a "load more" sentinel (above); the last page is not. Public ;; read; the relate action stays guarded. (define host/blog-relate-options (fn (req) (let ((slug (dream-param req "slug")) (kind (or (dream-query-param req "kind") "related")) ;; dream's query parser does not %-decode values (its form parser does), ;; so a filter like "Item 13" arrives as "Item%2013" — decode it. (q (dr/url-decode (or (dream-query-param req "q") ""))) (offset (host/query-int req "offset" 0))) (let ((page (host/blog--relate-candidates slug q kind offset host/blog--picker-limit))) (let ((rows (join "" (map (fn (p) (render-page (host/blog--picker-item slug p kind))) page))) (more (if (= (len page) host/blog--picker-limit) (render-page (host/blog--picker-more slug kind q (+ offset host/blog--picker-limit))) ""))) (dream-html (str rows more))))))) ;; ── page shell ────────────────────────────────────────────────────── ;; A page is an SX element tree, rendered via render-page (5.1). The handler ;; 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"))) ;; An AJAX editor swap (the picker's relate / the editor's remove) vs. a plain ;; boosted form. The engine sends an SX-Target header for an sx-post form ;; (sx-target=#rel-editor-…), but NOT for a plain boosted form (the is-a-tag ;; toggle). So this tells "return the re-rendered editor fragment" apart from ;; "redirect + re-render #content" (the toggle / no-JS path). (define host/blog--editor-swap-req? (fn (req) (and (host/blog--spa-req? req) (let ((t (dream-header req "sx-target"))) (and t (not (= t ""))))))) (define host/blog--page (fn (req title body) (if (host/blog--spa-req? req) ;; SPA fragment: SX WIRE FORMAT (text/sx), not HTML. The WASM kernel parses ;; + renders it client-side into #content (the engine's handle-sx-response). ;; No server-side HTML render on the boosted path. (serialize body) ;; full SPA shell: WASM kernel + platform + boosted #content (server HTML ;; for first load / no-JS / SEO) (str "" (render-page (quasiquote (html (head (meta :charset "utf-8") (title (unquote title)) ;; content-addressed module manifest: {file -> hash}. The client's ;; loadBytecodeFile reads this and fetches each web-stack module ;; immutably from /sx/h/{hash} (localStorage-cached, never stale) ;; instead of /static/wasm/sx/*.sxbc with max-age. (script :type "application/json" :data-sx-manifest "1" (raw! (unquote (host/static-manifest-json)))) (script :src "/static/wasm/sx_browser.bc.wasm.js") (script :src "/static/wasm/sx-platform.js") ;; Visible failure state for the SX engine's .sx-error class (added ;; on a failed/offline fetch, cleared on the next success). Without ;; it a stuck retry is invisible — the picker just sits "Loading…". (style (raw! (unquote (str ".rp-more.sx-error{color:#b00}" ".rp-more.sx-error::after{content:\" — offline, retrying…\"}" ".relate-picker.sx-error .rp-results::before{" "content:\"Connection problem — retrying…\";display:block;" "padding:.5em;color:#b00;font-size:.9em}"))))) (body ;; sx-boost must be on a DESCENDANT of (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" ;; persistent top nav OUTSIDE #content, so it survives every ;; content swap; the Home link is boosted (SPA nav to /). (nav :style "padding:0.75em 0;border-bottom:1px solid #ccc;margin-bottom:1em" (a :href "/" :style "font-weight:bold;text-decoration:none" "Home")) (div :id "content" (unquote body))))))))))) ;; Wrap a host/blog--page result in a response with the matching content-type: ;; text/sx for a boosted (SPA) request (the WASM kernel renders it), text/html ;; for a full-page request. Replaces the old dream-html/-status wrappers so the ;; boosted path ships SX instead of server-rendered HTML. (define host/blog--resp (fn (req status str) (dream-response status {:content-type (if (host/blog--spa-req? req) "text/sx; charset=utf-8" "text/html; charset=utf-8")} str))) ;; ── registry-driven relation rendering (post page) ────────────────── ;; One labelled block of links from records ({:slug :title}), or "" when empty. ;; Records are pre-fetched, so the tree is built from in-memory data only. (define host/blog--edges-block (fn (records label) (if (> (len records) 0) (let ((items (map (fn (p) (quasiquote (li (a :href (unquote (str "/" (get p :slug) "/")) (unquote (get p :title)))))) records))) (quasiquote (div :style "margin-top:2em" (h3 (unquote label)) (unquote (cons (quote ul) items))))) ""))) ;; nodes -> {:slug :title} records, existence-filtered against a shared key set. (define host/blog--recs (fn (existing nodes) (map (fn (s) {:slug s :title (get (host/blog-get s) :title)}) (filter (fn (s) (contains? existing s)) (map (fn (n) (substr (symbol->string n) 5)) (filter (fn (n) (starts-with? (symbol->string n) "blog:")) nodes)))))) ;; The relation blocks shown on a POST page — a CURATED, fixed set: Related (out), ;; Tags (out), Tagged-with-this (in). PERFORMANCE: read the KV key list ONCE and ;; derive both the post set and the edges from it in memory, instead of letting ;; each host/blog-out/in re-scan the store. Every durable read is a perform routed ;; through cek_run_with_io (costly deep in the call stack), so the post page must ;; minimise them — this does ONE kv-keys plus a host/blog-get per linked post. (define host/blog--post-relation-specs (list {:kind "related" :dir "out" :label "Related posts"} {:kind "tagged" :dir "out" :label "Tags"} {:kind "tagged" :dir "in" :label "Tagged with this"})) ;; in-memory: the slug list (out: dst, in: src) for `slug` under `kind` from ;; pre-parsed edges — no perform. (define host/blog--edges-for (fn (edges slug kind dir) (reduce (fn (acc e) (if (= (get e :kind) kind) (if (= dir "out") (if (= (get e :src) slug) (concat acc (list (get e :dst))) acc) (if (= (get e :dst) slug) (concat acc (list (get e :src))) acc)) acc)) (list) edges))) ;; slug list -> {:slug :title} records (existence-filtered), one host/blog-get each. (define host/blog--recs-slugs (fn (existing slugs) (map (fn (s) {:slug s :title (get (host/blog-get s) :title)}) (filter (fn (s) (contains? existing s)) slugs)))) (define host/blog--relation-blocks (fn (slug) (let ((keys (persist/backend-kv-keys host/blog-store))) ;; ONE durable read (let ((existing (reduce (fn (acc k) (if (starts-with? k "blog:") (concat acc (list (substr k 5))) acc)) (list) keys)) (edges (filter (fn (e) (not (nil? e))) (map host/blog--parse-edge-key keys)))) (let ((blocks (reduce (fn (acc spec) (let ((b (host/blog--edges-block (host/blog--recs-slugs existing (host/blog--edges-for edges slug (get spec :kind) (get spec :dir))) (get spec :label)))) (if (= b "") acc (concat acc (list b))))) (list) host/blog--post-relation-specs))) (if (> (len blocks) 0) (cons (quote div) blocks) "")))))) ;; the relation section for the post page: the blocks, or — when empty and the ;; viewer is logged in — a subtle "add some" hint; nothing for anonymous viewers. (define host/blog--relations-or-hint (fn (slug logged-in) (let ((blocks (host/blog--relation-blocks slug))) (cond ((not (= blocks "")) blocks) (logged-in (quasiquote (p :style "margin-top:2em;font-size:0.9em;opacity:0.7" "No relations yet — " (a :href (unquote (str "/" slug "/edit")) "add some") "."))) (else ""))))) ;; Kind-aware relation editor for the edit page: current links (each with a ;; kind-scoped remove), plus a filterable picker (a declarative SX-htmx form, one ;; per kind). The picker's candidates come from the kind's registry :candidates ;; ("all" / tags / types). (define host/blog--relation-editor (fn (slug kind with-cands) ;; current edges read up front (a perform) — NOT inside the quasiquote, where ;; a perform would raise VmSuspended under http-listen. (let ((spec (host/blog--kind-spec kind)) (current (host/blog-out slug kind)) ;; results