;; 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)))) ;; ── business logic as federated composition-flows (plans/business-logic-fed-flows.md) ── ;; P0.1/P0.4: the PUBLISH-ACTIVITY contract, in the CANONICAL SEAM SHAPE ;; {:verb :actor :object :object-type :slug :category :delta :id} — :object is a ;; content-addressed REFERENCE (the CID), not an inlined dict; :id is the dedup identity; :slug + ;; :category are the domain fields the publish-DAG's ctx reads. Each runner adapter MARSHALS this ;; to its substrate (host/blog--activity->erl → next/'s proplist for the Erlang runner, RA). ;; category (the DAG's branch: newsletter/urgent/else) comes from the "category" field-value, else ;; the first tag, else "urgent". (define host/blog--post-category (fn (slug) (let ((fc (get (host/blog-field-values-of slug) "category"))) (if (and fc (not (= fc ""))) fc (let ((tags (host/blog-out slug "tagged"))) (if (> (len tags) 0) (first tags) "urgent")))))) ;; a post's primary type (its first is-a), for the activity :object-type. Default "article". (define host/blog--post-type (fn (slug) (let ((ts (host/blog-out slug "is-a"))) (if (empty? ts) "article" (first ts))))) ;; P2: a CONTENT activity (Create on first publish, Update on a subsequent published edit). :id is ;; verb-namespaced (create:/update:) over the CID — per-EVENT identity, so an update never collides ;; with the create (DEBT #1 for content; relation events use an edge-based id below). (define host/blog--content-activity (fn (slug verb) (let ((r (host/blog-get slug))) (if (nil? r) nil (let ((cid (host/blog-cid slug))) {:verb verb :actor "site" :object cid :object-type (host/blog--post-type slug) :slug slug :category (host/blog--post-category slug) :delta verb :id (str verb ":" cid)}))))) ;; publishing = a Create content activity (the on-publish behavior binds to verb "create"). (define host/blog--publish-activity (fn (slug) (host/blog--content-activity slug "create"))) ;; P2: a RELATION activity (Add/Remove). The object is the SUBJECT; the edge (kind→target) is carried ;; explicitly. :id is EDGE-based (DEBT #1) — a relation change doesn't shift the CID, so a CID-based ;; id would false-dedup across different edges on the same object. (define host/blog--relation-activity (fn (verb src kind dst) {:verb verb :actor "site" :object src :object-type (host/blog--post-type src) :relation kind :target dst :delta (str verb " " kind " " dst) :id (str verb ":" src ":" kind ":" dst)})) ;; MARSHAL the canonical activity → next/'s Erlang proplist shape, for the Erlang runner adapter ;; (RA). The seam activity is canonical; each runner adapter maps it to its substrate. Unused until ;; RA, defined + tested here so the reconcile is complete and RA has its bridge ready. (define host/blog--activity->erl (fn (a) {:type (get a :verb) :actor (get a :actor) :id (get a :id) :object {:type (get a :object-type) :slug (get a :slug) :category (get a :category)}})) ;; P0.2: the publish WORKFLOW as an EXECUTE-FOLD composition (host/execute.sx) — the SYNCHRONOUS ;; business flow. Validate, then BRANCH on category (newsletter → build a digest, urgent → notify ;; now, else skip). Content flow (effect/alt), NOT dataflow — so it's the execute-fold, not artdag. ;; Its required capabilities are {effect, branch} (host/flow--required-caps) → binds to the sync ;; execute-fold runner (which advertises {effect, branch, each}). A `wait` node would add {suspend} ;; and fail-fast against that runner (requiring the Erlang runner, RA). Runs against a ctx built ;; from the activity's object. (define host/blog--publish-dag (quote (seq (effect validate (field "slug")) (alt (when (eq "category" "newsletter") (effect digest (field "slug"))) (when (eq "category" "urgent") (effect notify (field "slug"))) (else (effect skip)))))) ;; the ctx a publish activity presents to the publish-DAG (string keys — preds read ctx by key). ;; Reads the canonical activity's top-level :category + :slug (P0.4). (define host/blog--publish-ctx (fn (activity) {"category" (get activity :category) "slug" (get activity :slug)})) ;; ── P1: types DECLARE behavior; the runner is DERIVED from the DAG's capabilities ────── ;; A type-post carries :behavior = a list of flat string-keyed bindings {"verb" "type" "dag"} (like ;; :type-relations). At boot they're gathered into a registry the trigger match consults. :dag NAMES ;; a registered behavior DAG; the runner is chosen by host/flow--select-runner over the fleet — an ;; {effect,branch} composition → exec-runner; a {suspend} DAG → RA once RA-live adds it to the fleet. (define host/blog--dag-registry {"publish" host/blog--publish-dag}) ;; name -> behavior DAG (define host/blog--dag-of (fn (name) (get host/blog--dag-registry name))) ;; the runner fleet, cheapest-first. exec-runner only until RA-live stands up a persistent kernel. (define host/blog--runner-fleet (list host/flow--exec-runner)) ;; per-type behavior declaration, stored on the type-post (string-keyed → persist-safe). (define host/blog--type-behavior (fn (type) (or (get (host/blog-get type) :behavior) (list)))) (define host/blog--set-type-behavior! (fn (type bindings) (let ((r (host/blog-get type))) (when r (host/blog--write! type (merge r {:behavior bindings})))))) ;; the behavior REGISTRY: every declared binding, gathered at boot (like load-edges!). Scans ALL ;; posts for a :behavior declaration — robust to is-type? state differences across the durable store ;; (a post without :behavior contributes nothing). (define host/blog--behaviors (list)) (define host/blog--load-behaviors! (fn () (set! host/blog--behaviors (reduce (fn (acc t) (concat acc (host/blog--type-behavior t))) (list) (host/blog-slugs))))) ;; match an activity against the registry → resolved bindings {:dag :runner}, runner DERIVED by caps. ;; (A binding whose DAG needs a capability no fleet runner has is SKIPPED — a soft bind failure.) (define host/blog--match-behaviors (fn (activity) (reduce (fn (acc b) (if (and (= (get b "verb") (get activity :verb)) (= (get b "type") (get activity :object-type))) (let ((dag (host/blog--dag-of (get b "dag")))) (let ((runner (host/flow--select-runner host/blog--runner-fleet dag))) (if (nil? runner) acc (concat acc (list {:dag dag :runner runner}))))) acc)) (list) host/blog--behaviors))) ;; ── P0.3: the seam WIRED on the live host ────────────────────────────── ;; The publish ENGINE = the execute-fold runner (flows.sx) + a local-SX on-publish trigger registry ;; + an in-process transport (the activity log = the event source) + the host driver (records each ;; effect in the flow log). Publishing a post (draft→published, fire-once) builds the activity and ;; runs it through behavior/process → the DAG's effects surface on /flows. In-memory logs for P0 ;; (durable-store backing is the follow-up). Every piece is a seam adapter — swap the runner for ;; Erlang (RA) or the transport for fed-sx (TA) and this same wiring federates, unchanged. (define host/blog--activity-log (list)) ;; every activity emitted (the event source) (define host/blog--flow-log (list)) ;; what the flows DID (the driver's effect records) (define host/blog--activitylog-key "activitylog") ;; P2: the transport LOGS every emitted activity DURABLY — a string-keyed record (verb/object/type/ ;; delta/id, dodging the keyword/persist split), so /activities survives a restart. This is the ;; federated EVENT SOURCE; TA (fed-sx transport) will additionally push these to peers. (define host/blog--transport {:emit (fn (a) (begin (set! host/blog--activity-log (concat host/blog--activity-log (list {"verb" (get a :verb) "object" (get a :object) "type" (get a :object-type) "delta" (get a :delta) "id" (get a :id)}))) (persist/backend-kv-put host/blog-store host/blog--activitylog-key host/blog--activity-log))) :deliver (fn () (list))}) ;; nothing inbound yet — synchronous, local ;; P1: the trigger match consults the behavior REGISTRY (built from types' declarations), and each ;; matched binding carries its DERIVED runner (capability selection). Was a hardcoded create+article. (define host/blog--triggers {:register! (fn (spec dag hint) nil) :match host/blog--match-behaviors}) ;; P0.3b: the flow log is DURABLE — string-keyed records (dodge the keyword/persist top-level split), ;; persisted to the blog store under one key, so /flows survives a restart. Boot-loaded via ;; host/blog-load-flowlog!. (Whole-list rewrite per effect — fine at P0 volume; cap/rotate later.) (define host/blog--flowlog-key "flowlog") (define host/blog--driver {:dispatch (fn (eff) (begin (set! host/blog--flow-log (concat host/blog--flow-log (list {"verb" (get eff :verb) "args" (get eff :args)}))) (persist/backend-kv-put host/blog-store host/blog--flowlog-key host/blog--flow-log) (list)))}) ;; record the effect (durably); no follow-up activities (P0) ;; rebuild the in-memory flow log from the durable store (call on boot, like host/blog-load-edges!). (define host/blog-load-flowlog! (fn () (let ((v (persist/backend-kv-get host/blog-store host/blog--flowlog-key))) (when (and v (= (type-of v) "list")) (set! host/blog--flow-log v))))) ;; P2: rebuild the activity log (the event source) from the durable store on boot. (define host/blog-load-activitylog! (fn () (let ((v (persist/backend-kv-get host/blog-store host/blog--activitylog-key))) (when (and v (= (type-of v) "list")) (set! host/blog--activity-log v))))) (define host/blog--publish-engine (behavior/make-engine {:triggers host/blog--triggers :runner host/flow--exec-runner :transport host/blog--transport :driver host/blog--driver :ctx-of host/blog--publish-ctx})) ;; P2: EMIT any activity through the seam — it is LOGGED (the event source, via the transport) and ;; matched against the behavior registry (firing any declared behavior). Returns the trace, or nil. (define host/blog--emit! (fn (a) (if (nil? a) nil (behavior/process host/blog--publish-engine a)))) ;; a slug's content CHANGE → the right verb: draft→published = Create (first publish); published→ ;; published = Update (a subsequent edit). Draft↔draft emits nothing (unobservable). Fire-once on the ;; create transition; an identical re-edit dedups (same verb:cid id). (define host/blog--emit-content-change! (fn (slug prev-status new-status) (cond ((and (not (= prev-status "published")) (= new-status "published")) (host/blog--emit! (host/blog--content-activity slug "create"))) ((and (= prev-status "published") (= new-status "published")) (host/blog--emit! (host/blog--content-activity slug "update"))) (else nil)))) ;; back-compat alias: the publish transition (create). Kept for the write-path call sites + tests. (define host/blog--maybe-publish! (fn (slug prev-status new-status) (host/blog--emit-content-change! slug prev-status new-status))) ;; a relation change → an Add/Remove activity (edge referenced, no CID shift). (define host/blog--emit-relation! (fn (verb src kind dst) (host/blog--emit! (host/blog--relation-activity verb src kind dst)))) ;; ── 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, KV-only) ───────────────── ;; The blog graph is the durable KV: every edge is a row "edge:||" in the ;; blog store, and ALL reads walk those rows directly (host/blog--all-edges / -out / -in / ;; --subtype-closure). It is NOT mirrored into lib/relations: relations/relate re-saturates ;; the whole Datalog ruleset on EVERY write (super-linear in the fact base — profiled at ;; 1→3→6s per edge as the graph grows), and since typing now reads direct KV edges, nothing ;; in the blog domain reads lib/relations, so the mirror was pure (very expensive) dead ;; weight. KV-only edge writes are ~20ms flat. '|' is a safe delimiter — slugs are ;; [a-z0-9-], kinds are registry names. (host/relations.sx, the relations DOMAIN service, is ;; separate: its own "type:id" nodes in lib/relations, untouched by this.) (define host/blog--edge-key (fn (src kind dst) (str "edge:" src "|" kind "|" dst))) (define host/blog--add-edge! (fn (src dst 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) (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))))) ;; No-op: the durable KV edge rows ARE the graph and every read walks them directly, so ;; there is no in-memory lib/relations graph to rebuild on boot. (Kept as a callable seam — ;; serve.sh calls it after pointing the store at the durable backend — in case a future ;; index/cache needs warming.) Previously this replayed every edge into lib/relations via ;; relations/relate, which re-saturated the Datalog ruleset per edge: O(edges²) boot cost. (define host/blog-load-edges! (fn () nil)) ;; 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. ;; ;; PERF: the subtype closure is computed HOST-SIDE by a BFS over the DIRECT subtype-of ;; edges (the edge:* KV rows), NOT via lib/relations descendants/ancestors. Each lib/ ;; relations query re-saturates the whole (CEK-interpreted) Datalog ruleset — ~seconds ;; even on a tiny graph — and typing is on the hottest path (is-a?/types-of/instances-of ;; run per post, per picker, per render), so re-saturation made the blog suite + live ;; pages CPU-bound. The closure is the SAME transitive set; one edge-store snapshot drives ;; the whole BFS (O(edges), cycle-safe). KV == relations for direct edges (host/blog-relate! ;; writes both), so this is exact, not an approximation. (define host/blog--uniq (fn (xs) (reduce (fn (acc x) (if (contains? acc x) acc (concat acc (list x)))) (list) xs))) ;; transitive closure over DIRECT subtype-of edges from `roots` (roots included), with NO ;; Datalog. dir :out = follow src->dst (the supertypes of roots); dir :in = follow dst->src ;; (the subtypes of roots). One host/blog--all-edges snapshot; BFS with a `seen` guard. (define host/blog--subtype-closure (fn (roots dir) (let ((edges (host/blog--all-edges)) (existing (host/blog-slugs))) (let ((step (fn (n) (filter (fn (s) (contains? existing s)) (reduce (fn (acc e) (if (and (= (get e :kind) "subtype-of") (= (get e (if (= dir :out) :src :dst)) n)) (concat acc (list (get e (if (= dir :out) :dst :src)))) acc)) (list) edges))))) (let loop ((frontier roots) (seen (list))) (if (empty? frontier) seen (let ((n (first frontier))) (if (contains? seen n) (loop (rest frontier) seen) (loop (concat (rest frontier) (step n)) (concat seen (list n))))))))))) (define host/blog-types-of (fn (slug) (host/blog--uniq (host/blog--subtype-closure (host/blog-out slug "is-a") :out)))) ;; 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. O(edges) over one snapshot — the ;; efficient way to enumerate a type's members (e.g. "all tags") for the picker. (define host/blog-instances-of (fn (type) (host/blog--uniq (reduce (fn (acc t) (concat acc (host/blog-in t "is-a"))) (list) (host/blog--subtype-closure (list type) :in))))) ;; 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). Used by the metamodel editor. (define host/blog-type-defs (fn () (host/blog--uniq (host/blog--subtype-closure (list "type") :in)))) ;; ── 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)))) ;; the COMPOSITION fields an object has — the fields its (transitive) types declare with ;; :type "Composition" (each edited by its own block editor). Default ["body"] if a type ;; declares none, so every object still has one root composition. This is layer 2: "types ;; declare which fields are compositions" (the schema for the object's structure). (define host/blog--composition-fields (fn (slug) (let ((cf (reduce (fn (acc f) (if (= (get f :type) "Composition") (concat acc (list (str (get f :name)))) acc)) (list) (host/blog--fields-for-post slug)))) (if (empty? cf) (list "body") cf)))) ;; the SCALAR (non-composition) fields — the generic edit form's inputs (compositions get a ;; block editor instead of a text input). (define host/blog--scalar-fields (fn (slug) (filter (fn (f) (not (= (get f :type) "Composition"))) (host/blog--fields-for-post slug)))) ;; ── type-block GRAMMAR (layer 2b): a Composition field declares which block kinds it may ;; contain. {:name "body" :type "Composition" :blocks (…card types…) :allow ("cond" "each")}. ;; :blocks absent -> every card subtype (back-compat); :allow absent -> both control blocks. -- (define host/blog--field-decl (fn (slug field) (let loop ((fs (host/blog--fields-for-post slug))) (cond ((empty? fs) nil) ((= (str (get (first fs) :name)) field) (first fs)) (else (loop (rest fs))))))) ;; the card types a field permits (its :blocks, else all subtypes of "card"). (define host/blog--allowed-blocks (fn (slug field) (let ((d (host/blog--field-decl slug field))) (if (and d (get d :blocks)) (get d :blocks) (host/blog--subtype-closure (list "card") :in))))) ;; whether a control block ("cond"/"each") is permitted in a field (its :allow, else both). (define host/blog--allows-control? (fn (slug field kind) (let ((d (host/blog--field-decl slug field))) (if (and d (get d :allow)) (contains? (get d :allow) kind) true)))) ;; whether a specific card type may be added to a field (grammar check for the add handler). (define host/blog--block-allowed? (fn (slug field ctype) (contains? (host/blog--allowed-blocks slug field) ctype))) ;; a short editor label for a card type: strip the "card-" prefix. (define host/blog--card-label (fn (ct) (if (starts-with? ct "card-") (substr ct 5) ct))) ;; grammar violations of a field's current composition (empty = valid): card nodes whose type ;; isn't permitted, control blocks that aren't allowed. Used on save/import. (define host/blog--comp-violations (fn (slug field) (reduce (fn (acc node) (let ((k (host/blog--node-kind node))) (cond ((= k "card") (let ((ct (host/blog--primary-card-type (host/blog--resolve-ref (str (first (rest node))) {"container" slug})))) (if (host/blog--block-allowed? slug field ct) acc (concat acc (list (str "block ‘" ct "’ is not allowed in :" field)))))) ((or (= k "cond") (= k "each")) (if (host/blog--allows-control? slug field k) acc (concat acc (list (str "‘" k "’ blocks are not allowed in :" field))))) (else acc)))) (list) (host/blog--comp-nodes slug field)))) ;; ── Part B: RELATIONS are governed by the type too — related / is-a / subtype-of / tagged are ;; part of the object's composition (external — NOT in the CID), and the type declares which ;; relation kinds its instances may use (:type-relations). Absent -> all kinds (back-compat). -- (define host/blog--all-rel-kinds (fn () (map (fn (s) (get s :kind)) host/blog-rel-kinds))) (define host/blog--type-relations (fn (type) (get (host/blog-get type) :type-relations))) (define host/blog--set-type-relations! (fn (type kinds) (let ((r (host/blog-get type))) (when r (host/blog--write! type (merge r {:type-relations kinds})))))) ;; the relation kinds a post may use = the union its types declare (:type-relations); if no ;; type declares any, every registered kind (so metamodel types keep full freedom by default). (define host/blog--allowed-relations (fn (slug) (let ((declared (reduce (fn (acc t) (let ((r (host/blog--type-relations t))) (if r (host/blog--uniq (concat acc r)) acc))) (list) (host/blog-types-of slug)))) (if (empty? declared) (host/blog--all-rel-kinds) declared)))) (define host/blog--relation-allowed? (fn (slug kind) (contains? (host/blog--allowed-relations slug) kind))) ;; ── 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 (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})))))) ;; ── composition objects (plans/composition-objects.md) ────────────── ;; A record may carry a :body — a composition node (seq/par/alt/each over object refs) ;; rendered by the render-fold (lib/host/compose.sx) against a context. When present it ;; supersedes :sx-content. This is fold #1; the same object renders differently per context. (define host/blog-body-of (fn (slug) (host/blog--comp-of slug "body"))) (define host/blog--set-body! (fn (slug body) (host/blog--set-comp! slug "body" body))) ;; The resolver for the composition `each` graph-query source (compose.sx asks the context ;; for "query"). `(query REL TYPE)` -> the objects related to TYPE by REL, as full records ;; so the per-item template can field them. Today the supported relation is is-a (TYPE's ;; transitive instances, via host/blog-instances-of); the dispatch leaves room for more. ;; This is the DATA-DRIVEN each source — the object's `each` is the query, the render is ;; the run over whatever the graph currently holds. (define host/blog--comp-query (fn (qargs ctx) (let ((rel (str (first qargs))) (type (str (first (rest qargs))))) (cond ((= rel "is-a") (map host/blog-get (host/blog-instances-of type))) (else (list)))))) ;; live context values, read PURELY from the request headers (no perform) so the SAME ;; object renders responsively/personalised per request — `(alt (when (eq "device" "mobile") ;; …) …)` is a responsive layout, `(when (eq "locale" "fr") …)` a localised variant. (define host/blog--device-of (fn (req) (let ((ua (str (or (dream-header req "user-agent") "")))) (if (or (contains? ua "Mobile") (contains? ua "Android") (contains? ua "iPhone")) "mobile" "desktop")))) (define host/blog--locale-of (fn (req) (let ((al (str (or (dream-header req "accept-language") "")))) (if (>= (len al) 2) (substr al 0 2) "en")))) ;; ── 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 ;; ____ (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: __. 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 (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 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 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 "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, ;; content-addressed card OBJECTS. host/blog--decompose! splits an (article …) tree into ;; one card object per top-level block (is-a the mapped card-type + its field-values), ;; links each by an ordered `contains` edge, and sets the post's :body to (seq (ref c0) ;; (ref c1) …). The render-fold then transcludes each card via its type template. This is ;; the cards-as-objects decision made real for the importer (plans/composition-objects.md). ;; the text content of a block element: its string children joined, skipping :attr pairs, ;; recursing into nested elements. Carries prose into a card field (good enough for import). (define host/blog--args-text (fn (args) (cond ((empty? args) "") ((= (type-of (first args)) "keyword") (host/blog--args-text (rest (rest args)))) (else (str (host/blog--elem-text (first args)) (host/blog--args-text (rest args))))))) (define host/blog--elem-text (fn (node) (cond ((= (type-of node) "string") node) ((and (= (type-of node) "list") (> (len node) 0)) (host/blog--args-text (rest node))) (else "")))) ;; the value of an :attr on an element (e.g. img :src), "" if absent. (define host/blog--elem-attr (fn (node key) (let loop ((args (if (= (type-of node) "list") (rest node) (list)))) (cond ((empty? args) "") ((and (= (type-of (first args)) "keyword") (= (str (first args)) key)) (if (empty? (rest args)) "" (str (first (rest args))))) ((= (type-of (first args)) "keyword") (loop (rest (rest args)))) (else (loop (rest args))))))) ;; the first child element of `node` with tag `tag` (a list head), or nil. (For a figure's ;; inner img / figcaption during decompose.) (define host/blog--find-child (fn (node tag) (let loop ((xs (if (= (type-of node) "list") (rest node) (list)))) (cond ((empty? xs) nil) ((and (= (type-of (first xs)) "list") (= (str (first (first xs))) tag)) (first xs)) (else (loop (rest xs))))))) ;; map an element tag to a card-type (the block vocabulary). Unknown tags -> text card. (define host/blog--tag->card-type (fn (tag) (cond ((or (= tag "h1") (= tag "h2") (= tag "h3") (= tag "h4")) "card-heading") ((or (= tag "img") (= tag "figure")) "card-image") ;; figure = image + figcaption ((or (= tag "iframe") (= tag "embed") (= tag "video")) "card-embed") ((= tag "blockquote") "card-quote") ((or (= tag "pre") (= tag "code")) "card-code") (else "card-text")))) ;; the field-values for a card-type extracted from the original block element. (define host/blog--block-fields (fn (orig-tag ctype block) (cond ((= ctype "card-heading") {"level" (if (>= (len orig-tag) 2) (substr orig-tag 1) "2") "text" (host/blog--elem-text block)}) ((= ctype "card-image") ;; a bare (src/alt on the block) OR a
(img child + a figcaption caption). (let ((img (or (host/blog--find-child block "img") block)) (cap (host/blog--find-child block "figcaption"))) {"src" (host/blog--elem-attr img "src") "alt" (host/blog--elem-attr img "alt") "caption" (if (nil? cap) "" (host/blog--elem-text cap))})) ((= ctype "card-embed") {"url" (host/blog--elem-attr block "src") "caption" ""}) ((= ctype "card-code") {"code" (host/blog--elem-text block) "language" ""}) ((= ctype "card-quote") {"text" (host/blog--elem-text block) "cite" ""}) (else {"text" (host/blog--elem-text block)})))) ;; decompose a post's content-tree into card objects + a contains body. Idempotent ;; (seed!/relate!/set-body! are sets; re-import overwrites the same __bN card objects). (define host/blog--decompose! (fn (post-slug content-tree) (let ((blocks (if (and (= (type-of content-tree) "list") (> (len content-tree) 0)) (filter (fn (b) (= (type-of b) "list")) (rest content-tree)) (list)))) (when (not (empty? blocks)) (let ((refs (map-indexed (fn (i block) (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 ;; renders when transcluded (typed-block ignores status). (host/blog-seed! cslug ctype "(article (h1 \"card\"))" "block") (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) (host/blog--slug->ref post-slug cslug)))))) blocks))) (host/blog--set-body! post-slug (cons (quote seq) refs))))))) ;; ── block-editor model: edit a post's :body (its composition of card refs) ─────────── ;; The body is (seq (ref c0) (ref c1) …); these ops add / remove / reorder its blocks and ;; keep the ordered `contains` edges in step. The :body seq is the ORDER authority, the ;; contains edges the membership set. Per-block FIELD editing is free: a card is an object, ;; so its fields are edited via the card's own //edit page. (composition step 6.) (define host/blog-body-refs (fn (slug) (let ((body (host/blog-body-of slug))) (if (and (= (type-of body) "list") (= (str (first body)) "seq")) (reduce (fn (acc n) (if (and (= (type-of n) "list") (= (str (first n)) "ref")) (concat acc (list (str (first (rest n))))) acc)) (list) (rest body)) (list))))) (define host/blog--set-body-refs! (fn (slug refs) (host/blog--set-body! slug (cons (quote seq) (map (fn (r) (list (quote ref) r)) refs))))) (define host/blog--next-block-idx (fn (slug) (let loop ((i 0)) (if (host/blog-exists? (str slug "__b" i)) (loop (+ i 1)) i)))) ;; 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 (host/blog--set-body-refs! slug (filter (fn (r) (not (= r cslug))) (host/blog-body-refs slug))) (host/blog-unrelate! slug cslug "contains")))) (define host/blog--nth-ref (fn (xs k) (let loop ((i 0) (ys xs)) (cond ((empty? ys) nil) ((= i k) (first ys)) (else (loop (+ i 1) (rest ys))))))) (define host/blog--ref-index (fn (xs x) (let loop ((i 0) (ys xs)) (cond ((empty? ys) -1) ((= (first ys) x) i) (else (loop (+ i 1) (rest ys))))))) (define host/blog-block-move! (fn (slug cslug dir) (let ((refs (host/blog-body-refs slug))) (let ((i (host/blog--ref-index refs cslug))) (let ((j (if (= dir "up") (- i 1) (+ i 1)))) (when (and (>= i 0) (>= j 0) (< j (len refs))) (host/blog--set-body-refs! slug (map-indexed (fn (k r) (cond ((= k i) (host/blog--nth-ref refs j)) ((= k j) (host/blog--nth-ref refs i)) (else r))) refs)))))))) ;; the card-type of a card object (its declared is-a target); "card" if none. (define host/blog--primary-card-type (fn (cslug) (let ((ts (host/blog-out cslug "is-a"))) (if (empty? ts) "card" (first ts))))) ;; a short text preview of a card's content from its field-values. (define host/blog--block-preview (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.) ;; a composition FIELD's value on an object — inline, part of the CID. A type declares which ;; of its fields are compositions (host/blog--composition-fields); an object may carry several ;; (:body, :aside, …), each edited by its own block editor. Compositions live in a STRING-KEYED ;; sub-dict :comps (string keys round-trip through persist cleanly, unlike a mix of keyword and ;; string top-level keys). The default "body" field falls back to a legacy top-level :body. (define host/blog--comps (fn (rec) (or (get rec :comps) {}))) (define host/blog--comp-of (fn (slug field) (let ((r (host/blog-get slug))) (let ((c (get (host/blog--comps r) field))) (if (nil? c) (if (= field "body") (get r :body) nil) c))))) (define host/blog--set-comp! (fn (slug field v) (let ((r (host/blog-get slug))) (when r (host/blog--write! slug (assoc r :comps (assoc (host/blog--comps r) field v))))))) (define host/blog--comp-nodes (fn (slug field) (let ((c (host/blog--comp-of slug field))) (if (and (= (type-of c) "list") (= (str (first c)) "seq")) (rest c) (list))))) (define host/blog--set-comp-nodes! (fn (slug field nodes) (host/blog--set-comp! slug field (cons (quote seq) nodes)))) ;; back-compat: the default "body" field. (define host/blog-body-nodes (fn (slug) (host/blog--comp-nodes slug "body"))) (define host/blog--set-body-nodes! (fn (slug nodes) (host/blog--set-comp-nodes! slug "body" 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 /. Returns its STORAGE SLUG ;; (____b); 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: ____ ;; -> __. 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 field node) (host/blog--set-comp-nodes! slug field (concat (host/blog--comp-nodes slug field) (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") ((= h "text") "text") ((or (= h "row") (= h "grid")) "layout") ((or (= h "field") (= h "val")) "field") ((= h "seq") "group") (else "other"))) "other"))) ;; a short human display of ANY composition node — for the editor rows. A ref becomes a ;; ✎ edit-chip; text/field/val show their content; a container shows its item count. (define host/blog--node-display (fn (slug node) (if (= (type-of node) "list") (let ((h (str (first node)))) (cond ((= h "ref") (host/blog--ref-chip slug (str (first (rest node))))) ((= h "text") (let ((t (str (first (rest node))))) (if (> (len t) 50) (str (substr t 0 50) "…") t))) ((or (= h "field") (= h "val")) (str h " " (str (first (rest node))))) ((or (= h "seq") (= h "row") (= h "grid")) (str h " (" (len (rest node)) ")")) (else h))) (str node)))) ;; the display of a conditional/repeater BRANCH — its last element (a ref, text, or group). (define host/blog--branch-display (fn (slug branch) (host/blog--node-display slug (host/blog--nth branch (- (len branch) 1))))) ;; 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