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
|
||||||
fi
|
fi
|
||||||
|
|
||||||
SUITES=(block doc render api store crdt)
|
SUITES=(block doc render api store crdt sync)
|
||||||
|
|
||||||
OUT_JSON="lib/content/scoreboard.json"
|
OUT_JSON="lib/content/scoreboard.json"
|
||||||
OUT_MD="lib/content/scoreboard.md"
|
OUT_MD="lib/content/scoreboard.md"
|
||||||
@@ -44,6 +44,7 @@ run_suite() {
|
|||||||
(load "lib/content/api.sx")
|
(load "lib/content/api.sx")
|
||||||
(load "lib/content/store.sx")
|
(load "lib/content/store.sx")
|
||||||
(load "lib/content/crdt.sx")
|
(load "lib/content/crdt.sx")
|
||||||
|
(load "lib/content/sync.sx")
|
||||||
(epoch 2)
|
(epoch 2)
|
||||||
(eval "(define content-test-pass 0)")
|
(eval "(define content-test-pass 0)")
|
||||||
(eval "(define content-test-fail 0)")
|
(eval "(define content-test-fail 0)")
|
||||||
|
|||||||
@@ -5,9 +5,10 @@
|
|||||||
"render": {"pass": 29, "fail": 0},
|
"render": {"pass": 29, "fail": 0},
|
||||||
"api": {"pass": 26, "fail": 0},
|
"api": {"pass": 26, "fail": 0},
|
||||||
"store": {"pass": 29, "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_fail": 0,
|
||||||
"total": 196
|
"total": 210
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -10,4 +10,5 @@ _Generated by `lib/content/conformance.sh`_
|
|||||||
| api | 26 | 0 | 26 |
|
| api | 26 | 0 | 26 |
|
||||||
| store | 29 | 0 | 29 |
|
| store | 29 | 0 | 29 |
|
||||||
| crdt | 34 | 0 | 34 |
|
| 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)
|
## 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
|
## 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)
|
- [x] concurrent-edit tests (any order, double-apply → identical)
|
||||||
|
|
||||||
## Phase 4 — External sync + federation
|
## 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
|
- [ ] 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
|
## 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
|
- 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,
|
a state-based CvRDT. Merge is a join (lub) on a semilattice → commutative,
|
||||||
associative, idempotent by construction. Ordering = unique dense Logoot
|
associative, idempotent by construction. Ordering = unique dense Logoot
|
||||||
|
|||||||
Reference in New Issue
Block a user