diff --git a/lib/content/conformance.sh b/lib/content/conformance.sh index 1a7e20e0..ac8399c9 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 store crdt) +SUITES=(block doc render api store crdt sync) OUT_JSON="lib/content/scoreboard.json" OUT_MD="lib/content/scoreboard.md" @@ -44,6 +44,7 @@ run_suite() { (load "lib/content/api.sx") (load "lib/content/store.sx") (load "lib/content/crdt.sx") +(load "lib/content/sync.sx") (epoch 2) (eval "(define content-test-pass 0)") (eval "(define content-test-fail 0)") diff --git a/lib/content/scoreboard.json b/lib/content/scoreboard.json index 490e10f4..3c580915 100644 --- a/lib/content/scoreboard.json +++ b/lib/content/scoreboard.json @@ -5,9 +5,10 @@ "render": {"pass": 29, "fail": 0}, "api": {"pass": 26, "fail": 0}, "store": {"pass": 29, "fail": 0}, - "crdt": {"pass": 34, "fail": 0} + "crdt": {"pass": 34, "fail": 0}, + "sync": {"pass": 14, "fail": 0} }, - "total_pass": 196, + "total_pass": 210, "total_fail": 0, - "total": 196 + "total": 210 } diff --git a/lib/content/scoreboard.md b/lib/content/scoreboard.md index fc7fac98..7fb590aa 100644 --- a/lib/content/scoreboard.md +++ b/lib/content/scoreboard.md @@ -10,4 +10,5 @@ _Generated by `lib/content/conformance.sh`_ | api | 26 | 0 | 26 | | store | 29 | 0 | 29 | | crdt | 34 | 0 | 34 | -| **Total** | **196** | **0** | **196** | +| sync | 14 | 0 | 14 | +| **Total** | **210** | **0** | **210** | diff --git a/lib/content/sync.sx b/lib/content/sync.sx new file mode 100644 index 00000000..45b6ac60 --- /dev/null +++ b/lib/content/sync.sx @@ -0,0 +1,71 @@ +;; content-on-sx — external CMS sync via an injected adapter. +;; +;; Sync is a peripheral, not a feature. The core defines a SHAPE — an adapter is +;; a dict {:import (fn external doc-id -> doc) :export (fn doc -> external)} — and +;; delegates to it. The core knows nothing about Ghost's data model; all +;; translation lives in the adapter. Swap the adapter and the core is unchanged; +;; if Ghost goes away, nothing here does. +;; +;; Requires (loaded by harness): block.sx, doc.sx. + +;; ── generic boundary: pure delegation ── +(define + content/import + (fn (adapter external doc-id) ((get adapter :import) external doc-id))) + +(define content/export (fn (adapter doc) ((get adapter :export) doc))) + +;; round-trip a document through an adapter (export then import). +(define + content/round-trip + (fn + (adapter doc) + (content/import adapter (content/export adapter doc) (doc-id doc)))) + +;; ── a Ghost-flavoured adapter (the peripheral). Ghost knowledge is confined +;; here: a post is {:title :sections (list section)}; a section is a tagged dict +;; {:kind ...} that this adapter maps to/from content blocks. ── +(define + ghost-section->block + (fn + (sec) + (let + ((kind (get sec :kind)) (id (get sec :id))) + (cond + ((= kind "heading") + (mk-heading id (get sec :level) (get sec :text))) + ((= kind "paragraph") (mk-text id (get sec :text))) + ((= kind "image") (mk-image id (get sec :src) (get sec :alt))) + ((= kind "code") (mk-code id (get sec :language) (get sec :text))) + ((= kind "quote") (mk-quote id (get sec :cite) (get sec :text))) + ((= kind "hr") (mk-divider id)) + ((= kind "list") (mk-list id (get sec :ordered) (get sec :items))) + ((= kind "embed") (mk-embed id (get sec :url) (get sec :provider))) + (else (mk-text id (get sec :text))))))) + +(define + block->ghost-section + (fn + (b) + (let + ((t (blk-type b)) (id (blk-id b))) + (cond + ((= t "heading") {:id id :text (str (blk-send b "text")) :kind "heading" :level (blk-send b "level")}) + ((= t "text") {:id id :text (str (blk-send b "text")) :kind "paragraph"}) + ((= t "image") {:id id :src (str (blk-send b "src")) :alt (str (blk-send b "alt")) :kind "image"}) + ((= t "code") {:id id :text (str (blk-send b "text")) :kind "code" :language (str (blk-send b "language"))}) + ((= t "quote") {:cite (str (blk-send b "cite")) :id id :text (str (blk-send b "text")) :kind "quote"}) + ((= t "divider") {:id id :kind "hr"}) + ((= t "list") {:items (blk-send b "items") :id id :kind "list" :ordered (blk-send b "ordered")}) + ((= t "embed") {:id id :provider (str (blk-send b "provider")) :kind "embed" :url (str (blk-send b "url"))}) + (else {:id id :text "" :kind "paragraph"}))))) + +(define + ghost-import + (fn + (post doc-id) + (doc-new doc-id (map ghost-section->block (get post :sections))))) + +(define ghost-export (fn (doc) {:sections (map block->ghost-section (doc-blocks doc))})) + +(define ghost-adapter {:export ghost-export :import ghost-import}) diff --git a/lib/content/tests/sync.sx b/lib/content/tests/sync.sx new file mode 100644 index 00000000..fa87cb19 --- /dev/null +++ b/lib/content/tests/sync.sx @@ -0,0 +1,74 @@ +;; Phase 4 — external CMS sync via injected adapter. Import/export round-trip. + +(st-bootstrap-classes!) +(content-bootstrap-blocks!) +(content-bootstrap-doc!) +(content-bootstrap-render!) + +;; ── a Ghost post (external shape) ── +(define post {:sections (list {:id "h" :text "Hello" :kind "heading" :level 1} {:id "p" :text "World" :kind "paragraph"} {:id "i" :src "/c.png" :alt "cat" :kind "image"} {:id "d" :kind "hr"} {:items (list "a" "b") :id "l" :kind "list" :ordered true}) :title "Hello"}) + +;; ── import (delegates to adapter) ── +(define doc (content/import ghost-adapter post "post")) +(content-test "import doc-id" (doc-id doc) "post") +(content-test "import ids" (doc-ids doc) (list "h" "p" "i" "d" "l")) +(content-test + "import types" + (doc-types doc) + (list "heading" "text" "image" "divider" "list")) +(content-test + "import renders" + (content/render doc "html") + "

Hello

World

\"cat\"
  1. a
  2. b
") +(content-test + "import preserves heading level" + (blk-send (doc-find doc "h") "level") + 1) +(content-test + "import preserves list items" + (blk-send (doc-find doc "l") "items") + (list "a" "b")) + +;; ── export (delegates to adapter) ── +(define out (content/export ghost-adapter doc)) +(content-test + "export sections round-trip" + (get out :sections) + (get post :sections)) + +;; ── round-trip: export then import yields the same document ── +(define doc2 (content/round-trip ghost-adapter doc)) +(content-test "round-trip ids" (doc-ids doc2) (doc-ids doc)) +(content-test + "round-trip render" + (content/render doc2 "html") + (content/render doc "html")) + +;; ── round-trip the external form: import . export . import == import ── +(content-test + "external round-trip sections" + (get + (content/export ghost-adapter (content/import ghost-adapter post "post")) + :sections) + (get post :sections)) + +;; ── core knows nothing about Ghost: a different (stub) adapter works the same ── +(define raw-adapter {:export (fn (d) (str (blk-send (doc-find d "only") "text"))) :import (fn (ext doc-id) (doc-new doc-id (list (mk-text "only" ext))))}) +(define rdoc (content/import raw-adapter "just text" "r")) +(content-test "alt adapter import" (doc-ids rdoc) (list "only")) +(content-test + "alt adapter export" + (content/export raw-adapter rdoc) + "just text") + +;; ── code / quote / embed kinds round-trip ── +(define post2 {:sections (list {:id "c" :text "(+ 1 2)" :kind "code" :language "sx"} {:cite "Ada" :id "q" :text "to err" :kind "quote"} {:id "e" :provider "vimeo" :kind "embed" :url "https://v/1"})}) +(define d3 (content/import ghost-adapter post2 "p2")) +(content-test + "code/quote/embed types" + (doc-types d3) + (list "code" "quote" "embed")) +(content-test + "code/quote/embed round-trip" + (get (content/export ghost-adapter d3) :sections) + (get post2 :sections)) diff --git a/plans/content-on-sx.md b/plans/content-on-sx.md index 2d8904cd..d203bbc4 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` → **196/196** (Phases 1–3 complete: blocks, doc, render, api, persist op log, CRDT merge) +`bash lib/content/conformance.sh` → **210/210** (Phases 1–3 complete + Phase 4 Ghost adapter) ## Ground rules @@ -71,12 +71,20 @@ lib/content/api.sx ── (content/edit) (content/render) (content/history) ─ - [x] concurrent-edit tests (any order, double-apply → identical) ## Phase 4 — External sync + federation -- [ ] Ghost/CMS sync via injected adapter (import/export) +- [x] Ghost/CMS sync via injected adapter (import/export) - [ ] federated documents (peer-authored blocks) — trust-gated stub -- [ ] tests: round-trip import/export, conflict on concurrent external edit +- [~] tests: round-trip import/export (done), conflict on concurrent external edit (pending) ## Progress log +- 2026-06-07 — Phase 4 `sync.sx` (cb1): external CMS sync via an injected + adapter. Core defines the shape — `{:import :export}` — and delegates; + `content/import` / `content/export` / `content/round-trip` know nothing about + Ghost. A Ghost-flavoured adapter confines all format translation (post + `:sections` ↔ content blocks, all 8 kinds). Swapping in a stub `raw-adapter` + works identically. Round-trip (export∘import and import∘export) preserves ids, + types, fields, order. 14 tests; suite 210/210. Next: trust-gated federation + + concurrent-external-edit conflict (via CRDT). - 2026-06-07 — Phase 3 `crdt.sx` (**Phase 3 complete**): collaborative merge as a state-based CvRDT. Merge is a join (lub) on a semilattice → commutative, associative, idempotent by construction. Ordering = unique dense Logoot