# content-on-sx: Documents, blocks & collaborative editing on Smalltalk > **DRAFT outline.** The CMS vertical — blog, WYSIWYG editor, Ghost sync. Depends > on `persist-on-sx` (document history as an event log). Ghost/CMS sync stays a thin > external adapter (Python/FFI) until a native replacement exists. rose-ash's `blog` domain is content management: a block-based WYSIWYG editor, navigation, Ghost CMS sync. A document is a tree of live blocks; editing is a stream of operations; collaboration needs conflict-free merge. That is an object model — blocks are objects, edits are messages, and a document is the object graph responding to them. Smalltalk's "everything is an object responding to messages" maps directly to a block/WYSIWYG model, and a semilattice (CRDT) merge keeps concurrent edits conflict-free. End-state: a Smalltalk-on-SX document model (typed blocks, structural ops), operation log + CRDT merge for collaborative editing, versioning/history via the event store, and a render boundary to HTML/SX. External CMS (Ghost) sync is an 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) ## Ground rules - **Scope:** only `lib/content/**` and `plans/content-on-sx.md`. May **import** from `lib/smalltalk/`, and (once it exists) `lib/persist/`. Do not edit substrates. - **Architecture:** a document is an ordered tree of blocks (objects); an edit is a message (`insert`/`update`/`move`/`delete`); concurrent edits merge via a commutative (CRDT/semilattice) operation so order doesn't matter. History is the `persist` event stream; any version is a replay. - **Determinism:** merge must be commutative + idempotent (test: apply ops in any order / twice → same document). - **Commits:** one feature per commit. Progress log + tick boxes. ## Architecture sketch ``` Edit op Rendered document (insert block after id) ... HTML / SX tree │ ▲ ▼ │ lib/content/block.sx lib/content/render.sx — typed blocks as objects — block tree → HTML/SX — heading/text/image/embed — (reuses SX render boundary) │ ▲ ▼ │ lib/content/doc.sx lib/content/merge.sx — ordered block tree — CRDT/semilattice op merge — apply op, structural moves — concurrent-edit reconciliation │ ▲ ▼ │ lib/content/api.sx ── (content/edit) (content/render) (content/history) ──┐ │ │ ├── op log + versions → persist │ └── Ghost/CMS sync → injected external adapter (thin, non-core) ──┘ ``` ## Phase 1 — Block document model - [x] `block.sx` — typed block objects - [x] `doc.sx` — ordered tree, apply edit op, structural moves - [x] `render.sx` — block tree → HTML/SX - [x] `api.sx` + tests + scoreboard + conformance.sh ## Phase 2 — Op log + versioning - [x] edit ops as `persist` events; replay to any version - [x] `(content/history doc)`, diff between versions ## Phase 3 — Collaborative merge (CRDT) - [x] commutative/idempotent op merge - [x] concurrent-edit tests (any order, double-apply → identical) ## Phase 4 — External sync + federation - [ ] 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 ## Progress log - 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 position keys (cell = (digit actor), lexicographic); presence = OR-tombstones (remove-wins); each field = an LWW-Register keyed by logical (ts, actor). Every op contributes a PARTIAL element and per-id state is their join, so update-/delete-before-insert are not lost. `crdt-materialize` bridges back to a Phase-1 `CtDoc` (sort live elements by pos → blocks). Tests prove: ops in any order converge, double-apply is a no-op, merge commutes/associates/is idempotent, concurrent inserts order deterministically, same-field LWW by (ts,actor), disjoint fields both survive, two divergent replicas converge both ways. 34 tests; suite 196/196. - 2026-06-07 — Phase 2 `store.sx` (**Phase 2 complete**): op log + versioning over the persist event stream. `content/commit!` appends an edit op as a persist event to the doc's stream (`content:`); the log is the source of truth. `content/head` / `content/at b id seq` replay the op stream to the latest / any version (materialised doc is a cache, never primary state). `content/history` returns per-version metadata; `content/diff` / `content/diff-versions` report added/removed/changed block ids. Backend is injected via `(persist/open)` — content knows nothing about which backend. Minimal persist load (event/backend/log/kv/api). 29 tests; suite 162/162. - 2026-06-07 — Phase 1 `api.sx` (**Phase 1 complete**): `content/*` facade over block + doc + render. `content/bootstrap!` registers the hierarchy; `content/edit` applies one op or an op stream; `content/render` picks the boundary format ("html"/"sx" or keyword). Re-exports `content/new`, `content/append`, `content/insert|update|move|delete`, `content/find`, etc. `content/op?` distinguishes a single op from a list/block. 26 tests; suite 133/133. content/history deferred to Phase 2 (needs the persist op log). - 2026-06-07 — Phase 1 `render.sx`: render boundary as polymorphic message dispatch. Every block and `CtDoc` answers `asHTML` / `asSx`; the document folds children via Smalltalk `inject:into:` (works on raw SX lists), so `(asHTML doc)` / `(asSx doc)` are pure sends with zero type-switching in SX. Lists/headings render in Smalltalk source. No HTML escaping yet (noted in render.sx — boundary concern before untrusted content). 29 tests; suite 107/107. - 2026-06-06 — Phase 1 `doc.sx`: ordered block document (`CtDoc`) as a Smalltalk object holding an ordered block sequence. Edit ops are data dicts (`insert`/`update`/`move`/`delete`) with `op-*` constructors; `doc-apply` / `doc-apply-all` interpret an op stream, each returning a NEW document (input never mutated → replay-safe). Structural moves, insert-after/at, find/index, immutability all tested. 40 tests; suite 78/78. - 2026-06-06 — Phase 1 `block.sx`: typed block objects as Smalltalk instances (`CtBlock` hierarchy: text/heading/code/quote/image/embed/divider/list). Type tag + accessors are message sends (polymorphic dispatch); fields are immutable copy-on-write via functional `st-iv-set!` (history-safe). Added `mk-*` constructors, `block?` predicate, `lib/content/conformance.sh` + scoreboard. 38/38. ## Blockers - Smalltalk-only load chain (tokenizer/parser/runtime/eval) does **not** load `lib/r7rs.sx`/`spec/stdlib.sx`, so r7rs aliases (`car`/`cdr`/`null?`) are absent. Use base SX primitives (`first`/`rest`/`(= (len x) 0)`) in `lib/content/**`. Not a substrate bug — just the load surface.