content: Ghost/CMS sync via injected adapter + round-trip tests (210/210)
Some checks failed
Test, Build, and Deploy / test-build-deploy (push) Failing after 43s
Some checks failed
Test, Build, and Deploy / test-build-deploy (push) Failing after 43s
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -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)")
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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** |
|
||||
|
||||
71
lib/content/sync.sx
Normal file
71
lib/content/sync.sx
Normal file
@@ -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})
|
||||
74
lib/content/tests/sync.sx
Normal file
74
lib/content/tests/sync.sx
Normal file
@@ -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")
|
||||
"<h1>Hello</h1><p>World</p><img src=\"/c.png\" alt=\"cat\"><hr><ol><li>a</li><li>b</li></ol>")
|
||||
(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))
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user