diff --git a/lib/content/conformance.sh b/lib/content/conformance.sh index f529800a..3549ad04 100755 --- a/lib/content/conformance.sh +++ b/lib/content/conformance.sh @@ -15,7 +15,7 @@ if [ ! -x "$SX_SERVER" ]; then fi fi -SUITES=(block doc render api markdown validate store snapshot crdt crdt-store sync md-import fed) +SUITES=(block doc render api meta markdown validate store snapshot crdt crdt-store sync md-import fed) OUT_JSON="lib/content/scoreboard.json" OUT_MD="lib/content/scoreboard.md" @@ -42,6 +42,7 @@ run_suite() { (load "lib/content/doc.sx") (load "lib/content/render.sx") (load "lib/content/api.sx") +(load "lib/content/meta.sx") (load "lib/content/markdown.sx") (load "lib/content/validate.sx") (load "lib/content/store.sx") diff --git a/lib/content/doc.sx b/lib/content/doc.sx index 90e9bede..5675d35b 100644 --- a/lib/content/doc.sx +++ b/lib/content/doc.sx @@ -5,6 +5,9 @@ ;; and returns a NEW document — the input is never mutated, so any version is the ;; head of an op stream (replay-friendly for persist + CRDT merge). ;; +;; CtDoc also carries optional metadata (title/slug/tags) — see meta.sx for the +;; ergonomic API; they default nil and do not affect block operations. +;; ;; Op shapes (data, not objects — they are the persist event payload): ;; {:op "insert" :block :after } ; after nil = prepend ;; {:op "update" :id :field :value } @@ -16,10 +19,16 @@ (fn () (begin - (st-class-define! "CtDoc" "Object" (list "id" "blocks")) + (st-class-define! + "CtDoc" + "Object" + (list "id" "blocks" "title" "slug" "tags")) (ct-def-method! "CtDoc" "id" "id ^ id") (ct-def-method! "CtDoc" "blocks" "blocks ^ blocks") (ct-def-method! "CtDoc" "type" "type ^ #document") + (ct-def-method! "CtDoc" "title" "title ^ title") + (ct-def-method! "CtDoc" "slug" "slug ^ slug") + (ct-def-method! "CtDoc" "tags" "tags ^ tags") true))) ;; ── construction ── diff --git a/lib/content/meta.sx b/lib/content/meta.sx new file mode 100644 index 00000000..a38afe33 --- /dev/null +++ b/lib/content/meta.sx @@ -0,0 +1,53 @@ +;; content-on-sx — document metadata (title / slug / tags). +;; +;; CtDoc carries optional metadata alongside its blocks (ivars declared in +;; doc.sx). Reads go through message dispatch; setters are copy-on-write +;; (functional st-iv-set!), consistent with the immutable document model. +;; +;; Requires (loaded by harness): block.sx, doc.sx. + +;; ── reads ── +(define doc-title (fn (doc) (st-send doc "title" (list)))) +(define doc-slug (fn (doc) (st-send doc "slug" (list)))) +(define + doc-tags + (fn + (doc) + (let ((t (st-send doc "tags" (list)))) (if (= t nil) (list) t)))) + +(define doc-meta (fn (doc) {:slug (doc-slug doc) :id (doc-id doc) :title (doc-title doc) :tags (doc-tags doc)})) + +;; ── copy-on-write setters ── +(define doc-with-title (fn (doc title) (st-iv-set! doc "title" title))) +(define doc-with-slug (fn (doc slug) (st-iv-set! doc "slug" slug))) +(define doc-with-tags (fn (doc tags) (st-iv-set! doc "tags" tags))) + +(define + doc-add-tag + (fn (doc tag) (doc-with-tags doc (append (doc-tags doc) (list tag))))) + +;; set several at once: meta is a dict with optional :title :slug :tags +(define + doc-with-meta + (fn + (doc meta) + (let + ((d1 (if (has-key? meta :title) (doc-with-title doc (get meta :title)) doc))) + (let + ((d2 (if (has-key? meta :slug) (doc-with-slug d1 (get meta :slug)) d1))) + (if (has-key? meta :tags) (doc-with-tags d2 (get meta :tags)) d2))))) + +;; constructor with metadata +(define + doc-new-meta + (fn (id blocks meta) (doc-with-meta (doc-new id blocks) meta))) + +;; ── content/* facade aliases ── +(define content/title doc-title) +(define content/slug doc-slug) +(define content/tags doc-tags) +(define content/meta doc-meta) +(define content/with-title doc-with-title) +(define content/with-slug doc-with-slug) +(define content/with-tags doc-with-tags) +(define content/with-meta doc-with-meta) diff --git a/lib/content/scoreboard.json b/lib/content/scoreboard.json index c1af9deb..1624aad3 100644 --- a/lib/content/scoreboard.json +++ b/lib/content/scoreboard.json @@ -4,6 +4,7 @@ "doc": {"pass": 40, "fail": 0}, "render": {"pass": 42, "fail": 0}, "api": {"pass": 26, "fail": 0}, + "meta": {"pass": 27, "fail": 0}, "markdown": {"pass": 20, "fail": 0}, "validate": {"pass": 17, "fail": 0}, "store": {"pass": 29, "fail": 0}, @@ -14,7 +15,7 @@ "md-import": {"pass": 24, "fail": 0}, "fed": {"pass": 20, "fail": 0} }, - "total_pass": 338, + "total_pass": 365, "total_fail": 0, - "total": 338 + "total": 365 } diff --git a/lib/content/scoreboard.md b/lib/content/scoreboard.md index e87aa5e0..3d202d4f 100644 --- a/lib/content/scoreboard.md +++ b/lib/content/scoreboard.md @@ -8,6 +8,7 @@ _Generated by `lib/content/conformance.sh`_ | doc | 40 | 0 | 40 | | render | 42 | 0 | 42 | | api | 26 | 0 | 26 | +| meta | 27 | 0 | 27 | | markdown | 20 | 0 | 20 | | validate | 17 | 0 | 17 | | store | 29 | 0 | 29 | @@ -17,4 +18,4 @@ _Generated by `lib/content/conformance.sh`_ | sync | 14 | 0 | 14 | | md-import | 24 | 0 | 24 | | fed | 20 | 0 | 20 | -| **Total** | **338** | **0** | **338** | +| **Total** | **365** | **0** | **365** | diff --git a/lib/content/sync.sx b/lib/content/sync.sx index 45b6ac60..06dd8b0f 100644 --- a/lib/content/sync.sx +++ b/lib/content/sync.sx @@ -64,8 +64,11 @@ ghost-import (fn (post doc-id) - (doc-new doc-id (map ghost-section->block (get post :sections))))) + (st-iv-set! + (doc-new doc-id (map ghost-section->block (get post :sections))) + "title" + (get post :title)))) -(define ghost-export (fn (doc) {:sections (map block->ghost-section (doc-blocks doc))})) +(define ghost-export (fn (doc) {:sections (map block->ghost-section (doc-blocks doc)) :title (st-send doc "title" (list))})) (define ghost-adapter {:export ghost-export :import ghost-import}) diff --git a/lib/content/tests/meta.sx b/lib/content/tests/meta.sx new file mode 100644 index 00000000..f104a2f3 --- /dev/null +++ b/lib/content/tests/meta.sx @@ -0,0 +1,79 @@ +;; Extension — document metadata (title/slug/tags) + Ghost title plumbing. + +(st-bootstrap-classes!) +(content/bootstrap!) + +(define d (doc-empty "post")) + +;; ── defaults ── +(content-test "default title nil" (doc-title d) nil) +(content-test "default slug nil" (doc-slug d) nil) +(content-test "default tags empty" (doc-tags d) (list)) + +;; ── copy-on-write setters ── +(define d2 (doc-with-title d "Hello World")) +(content-test "with-title" (doc-title d2) "Hello World") +(content-test "with-title immutable" (doc-title d) nil) +(content-test "with-title keeps id" (doc-id d2) "post") + +(define d3 (doc-with-slug (doc-with-title d "T") "my-slug")) +(content-test "with-slug" (doc-slug d3) "my-slug") +(content-test "title preserved with slug" (doc-title d3) "T") + +(define d4 (doc-with-tags d (list "a" "b"))) +(content-test "with-tags" (doc-tags d4) (list "a" "b")) +(content-test "add-tag" (doc-tags (doc-add-tag d4 "c")) (list "a" "b" "c")) +(content-test + "add-tag from empty" + (doc-tags (doc-add-tag d "x")) + (list "x")) + +;; ── batch + dict ── +(define d5 (doc-with-meta d {:slug "s" :title "T" :tags (list "t1")})) +(content-test "with-meta title" (doc-title d5) "T") +(content-test "with-meta slug" (doc-slug d5) "s") +(content-test "with-meta tags" (doc-tags d5) (list "t1")) +(content-test + "with-meta partial leaves title" + (doc-title (doc-with-meta d {:slug "only"})) + nil) +(content-test "doc-meta dict" (doc-meta d5) {:slug "s" :id "post" :title "T" :tags (list "t1")}) + +;; ── constructor with metadata ── +(define d6 (doc-new-meta "p2" (list (mk-text "x" "hi")) {:title "Post 2"})) +(content-test "new-meta title" (doc-title d6) "Post 2") +(content-test "new-meta blocks" (doc-ids d6) (list "x")) + +;; ── facade aliases ── +(content-test "content/title" (content/title d5) "T") +(content-test + "content/with-title" + (content/title (content/with-title d "Z")) + "Z") +(content-test "content/meta" (content/meta d5) (doc-meta d5)) + +;; ── metadata coexists with block ops ── +(define + d7 + (doc-append + (doc-with-title (doc-empty "x") "Titled") + (mk-text "p" "body"))) +(content-test "meta + blocks coexist" (doc-ids d7) (list "p")) +(content-test "meta survives append" (doc-title d7) "Titled") +(content-test + "meta survives edit" + (doc-title (doc-update d7 "p" "text" "changed")) + "Titled") + +;; ── Ghost adapter now carries title ── +(define post {:sections (list {:id "h" :text "Hi" :kind "heading" :level 1}) :title "My Post"}) +(define gd (content/import ghost-adapter post "post")) +(content-test "ghost import title" (doc-title gd) "My Post") +(content-test + "ghost export title" + (get (content/export ghost-adapter gd) :title) + "My Post") +(content-test + "ghost title round-trip" + (doc-title (content/round-trip ghost-adapter gd)) + "My Post") diff --git a/plans/content-on-sx.md b/plans/content-on-sx.md index 55ae2eac..7833fc6d 100644 --- a/plans/content-on-sx.md +++ b/plans/content-on-sx.md @@ -19,7 +19,7 @@ injected adapter, not core. ## Status (rolling) -`bash lib/content/conformance.sh` → **338/338** (Phases 1–4 COMPLETE + extensions: HTML/SX escaping, Markdown render+import, durable CRDT replication, validation, snapshot cache) +`bash lib/content/conformance.sh` → **365/365** (Phases 1–4 COMPLETE + extensions: HTML/SX escaping, Markdown render+import, durable CRDT replication, validation, snapshot cache, doc metadata) ## Ground rules @@ -83,9 +83,16 @@ lib/content/api.sx ── (content/edit) (content/render) (content/history) ─ - [x] document validation (`validate.sx`: ids, per-type fields, duplicate ids) - [x] Markdown import adapter (`md-import.sx`: text → blocks, round-trips export) - [x] snapshot cache over replay (`snapshot.sx`: cache-not-primary, transparent) +- [x] document metadata (`meta.sx`: title/slug/tags + Ghost title plumbing) ## Progress log +- 2026-06-07 — Extension: document metadata (`meta.sx`). CtDoc gained optional + title/slug/tags ivars (declared in doc.sx, default nil/empty, no effect on + block ops). Reads via message dispatch; copy-on-write setters + (`doc-with-title/slug/tags`, `doc-add-tag`, `doc-with-meta`, `doc-new-meta`) + and `content/*` aliases; `doc-meta` returns the metadata dict. Ghost adapter + now carries `:title` through import/export/round-trip. 27 tests; suite 365/365. - 2026-06-07 — Extension: snapshot cache over op-log replay (`snapshot.sx`). Snapshots are a cache, never primary state — the log stays the source of truth. `content/snapshot!` stores a materialised head at a seq in the persist KV;