Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
8.1 KiB
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 → 210/210 (Phases 1–3 complete + Phase 4 Ghost adapter)
Ground rules
- Scope: only
lib/content/**andplans/content-on-sx.md. May import fromlib/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 thepersistevent 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
block.sx— typed block objectsdoc.sx— ordered tree, apply edit op, structural movesrender.sx— block tree → HTML/SXapi.sx+ tests + scoreboard + conformance.sh
Phase 2 — Op log + versioning
- edit ops as
persistevents; replay to any version (content/history doc), diff between versions
Phase 3 — Collaborative merge (CRDT)
- commutative/idempotent op merge
- 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 (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-tripknow nothing about Ghost. A Ghost-flavoured adapter confines all format translation (post:sections↔ content blocks, all 8 kinds). Swapping in a stubraw-adapterworks 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 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-materializebridges back to a Phase-1CtDoc(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:<id>); the log is the source of truth.content/head/content/at b id seqreplay the op stream to the latest / any version (materialised doc is a cache, never primary state).content/historyreturns per-version metadata;content/diff/content/diff-versionsreport 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/editapplies one op or an op stream;content/renderpicks the boundary format ("html"/"sx" or keyword). Re-exportscontent/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 andCtDocanswersasHTML/asSx; the document folds children via Smalltalkinject: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) withop-*constructors;doc-apply/doc-apply-allinterpret 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 (CtBlockhierarchy: text/heading/code/quote/image/embed/divider/list). Type tag + accessors are message sends (polymorphic dispatch); fields are immutable copy-on-write via functionalst-iv-set!(history-safe). Addedmk-*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)) inlib/content/**. Not a substrate bug — just the load surface.