content: document metadata (meta.sx) + Ghost title plumbing + 27 tests (365/365)
Some checks failed
Test, Build, and Deploy / test-build-deploy (push) Failing after 16s
Some checks failed
Test, Build, and Deploy / test-build-deploy (push) Failing after 16s
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 markdown validate store snapshot crdt crdt-store sync md-import fed)
|
SUITES=(block doc render api meta markdown validate store snapshot crdt crdt-store sync md-import fed)
|
||||||
|
|
||||||
OUT_JSON="lib/content/scoreboard.json"
|
OUT_JSON="lib/content/scoreboard.json"
|
||||||
OUT_MD="lib/content/scoreboard.md"
|
OUT_MD="lib/content/scoreboard.md"
|
||||||
@@ -42,6 +42,7 @@ run_suite() {
|
|||||||
(load "lib/content/doc.sx")
|
(load "lib/content/doc.sx")
|
||||||
(load "lib/content/render.sx")
|
(load "lib/content/render.sx")
|
||||||
(load "lib/content/api.sx")
|
(load "lib/content/api.sx")
|
||||||
|
(load "lib/content/meta.sx")
|
||||||
(load "lib/content/markdown.sx")
|
(load "lib/content/markdown.sx")
|
||||||
(load "lib/content/validate.sx")
|
(load "lib/content/validate.sx")
|
||||||
(load "lib/content/store.sx")
|
(load "lib/content/store.sx")
|
||||||
|
|||||||
@@ -5,6 +5,9 @@
|
|||||||
;; and returns a NEW document — the input is never mutated, so any version is the
|
;; and returns a NEW document — the input is never mutated, so any version is the
|
||||||
;; head of an op stream (replay-friendly for persist + CRDT merge).
|
;; head of an op stream (replay-friendly for persist + CRDT merge).
|
||||||
;;
|
;;
|
||||||
|
;; CtDoc also carries optional metadata (title/slug/tags) — see meta.sx for the
|
||||||
|
;; ergonomic API; they default nil and do not affect block operations.
|
||||||
|
;;
|
||||||
;; Op shapes (data, not objects — they are the persist event payload):
|
;; Op shapes (data, not objects — they are the persist event payload):
|
||||||
;; {:op "insert" :block <blk> :after <id|nil>} ; after nil = prepend
|
;; {:op "insert" :block <blk> :after <id|nil>} ; after nil = prepend
|
||||||
;; {:op "update" :id <id> :field <name> :value <v>}
|
;; {:op "update" :id <id> :field <name> :value <v>}
|
||||||
@@ -16,10 +19,16 @@
|
|||||||
(fn
|
(fn
|
||||||
()
|
()
|
||||||
(begin
|
(begin
|
||||||
(st-class-define! "CtDoc" "Object" (list "id" "blocks"))
|
(st-class-define!
|
||||||
|
"CtDoc"
|
||||||
|
"Object"
|
||||||
|
(list "id" "blocks" "title" "slug" "tags"))
|
||||||
(ct-def-method! "CtDoc" "id" "id ^ id")
|
(ct-def-method! "CtDoc" "id" "id ^ id")
|
||||||
(ct-def-method! "CtDoc" "blocks" "blocks ^ blocks")
|
(ct-def-method! "CtDoc" "blocks" "blocks ^ blocks")
|
||||||
(ct-def-method! "CtDoc" "type" "type ^ #document")
|
(ct-def-method! "CtDoc" "type" "type ^ #document")
|
||||||
|
(ct-def-method! "CtDoc" "title" "title ^ title")
|
||||||
|
(ct-def-method! "CtDoc" "slug" "slug ^ slug")
|
||||||
|
(ct-def-method! "CtDoc" "tags" "tags ^ tags")
|
||||||
true)))
|
true)))
|
||||||
|
|
||||||
;; ── construction ──
|
;; ── construction ──
|
||||||
|
|||||||
53
lib/content/meta.sx
Normal file
53
lib/content/meta.sx
Normal file
@@ -0,0 +1,53 @@
|
|||||||
|
;; content-on-sx — document metadata (title / slug / tags).
|
||||||
|
;;
|
||||||
|
;; CtDoc carries optional metadata alongside its blocks (ivars declared in
|
||||||
|
;; doc.sx). Reads go through message dispatch; setters are copy-on-write
|
||||||
|
;; (functional st-iv-set!), consistent with the immutable document model.
|
||||||
|
;;
|
||||||
|
;; Requires (loaded by harness): block.sx, doc.sx.
|
||||||
|
|
||||||
|
;; ── reads ──
|
||||||
|
(define doc-title (fn (doc) (st-send doc "title" (list))))
|
||||||
|
(define doc-slug (fn (doc) (st-send doc "slug" (list))))
|
||||||
|
(define
|
||||||
|
doc-tags
|
||||||
|
(fn
|
||||||
|
(doc)
|
||||||
|
(let ((t (st-send doc "tags" (list)))) (if (= t nil) (list) t))))
|
||||||
|
|
||||||
|
(define doc-meta (fn (doc) {:slug (doc-slug doc) :id (doc-id doc) :title (doc-title doc) :tags (doc-tags doc)}))
|
||||||
|
|
||||||
|
;; ── copy-on-write setters ──
|
||||||
|
(define doc-with-title (fn (doc title) (st-iv-set! doc "title" title)))
|
||||||
|
(define doc-with-slug (fn (doc slug) (st-iv-set! doc "slug" slug)))
|
||||||
|
(define doc-with-tags (fn (doc tags) (st-iv-set! doc "tags" tags)))
|
||||||
|
|
||||||
|
(define
|
||||||
|
doc-add-tag
|
||||||
|
(fn (doc tag) (doc-with-tags doc (append (doc-tags doc) (list tag)))))
|
||||||
|
|
||||||
|
;; set several at once: meta is a dict with optional :title :slug :tags
|
||||||
|
(define
|
||||||
|
doc-with-meta
|
||||||
|
(fn
|
||||||
|
(doc meta)
|
||||||
|
(let
|
||||||
|
((d1 (if (has-key? meta :title) (doc-with-title doc (get meta :title)) doc)))
|
||||||
|
(let
|
||||||
|
((d2 (if (has-key? meta :slug) (doc-with-slug d1 (get meta :slug)) d1)))
|
||||||
|
(if (has-key? meta :tags) (doc-with-tags d2 (get meta :tags)) d2)))))
|
||||||
|
|
||||||
|
;; constructor with metadata
|
||||||
|
(define
|
||||||
|
doc-new-meta
|
||||||
|
(fn (id blocks meta) (doc-with-meta (doc-new id blocks) meta)))
|
||||||
|
|
||||||
|
;; ── content/* facade aliases ──
|
||||||
|
(define content/title doc-title)
|
||||||
|
(define content/slug doc-slug)
|
||||||
|
(define content/tags doc-tags)
|
||||||
|
(define content/meta doc-meta)
|
||||||
|
(define content/with-title doc-with-title)
|
||||||
|
(define content/with-slug doc-with-slug)
|
||||||
|
(define content/with-tags doc-with-tags)
|
||||||
|
(define content/with-meta doc-with-meta)
|
||||||
@@ -4,6 +4,7 @@
|
|||||||
"doc": {"pass": 40, "fail": 0},
|
"doc": {"pass": 40, "fail": 0},
|
||||||
"render": {"pass": 42, "fail": 0},
|
"render": {"pass": 42, "fail": 0},
|
||||||
"api": {"pass": 26, "fail": 0},
|
"api": {"pass": 26, "fail": 0},
|
||||||
|
"meta": {"pass": 27, "fail": 0},
|
||||||
"markdown": {"pass": 20, "fail": 0},
|
"markdown": {"pass": 20, "fail": 0},
|
||||||
"validate": {"pass": 17, "fail": 0},
|
"validate": {"pass": 17, "fail": 0},
|
||||||
"store": {"pass": 29, "fail": 0},
|
"store": {"pass": 29, "fail": 0},
|
||||||
@@ -14,7 +15,7 @@
|
|||||||
"md-import": {"pass": 24, "fail": 0},
|
"md-import": {"pass": 24, "fail": 0},
|
||||||
"fed": {"pass": 20, "fail": 0}
|
"fed": {"pass": 20, "fail": 0}
|
||||||
},
|
},
|
||||||
"total_pass": 338,
|
"total_pass": 365,
|
||||||
"total_fail": 0,
|
"total_fail": 0,
|
||||||
"total": 338
|
"total": 365
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ _Generated by `lib/content/conformance.sh`_
|
|||||||
| doc | 40 | 0 | 40 |
|
| doc | 40 | 0 | 40 |
|
||||||
| render | 42 | 0 | 42 |
|
| render | 42 | 0 | 42 |
|
||||||
| api | 26 | 0 | 26 |
|
| api | 26 | 0 | 26 |
|
||||||
|
| meta | 27 | 0 | 27 |
|
||||||
| markdown | 20 | 0 | 20 |
|
| markdown | 20 | 0 | 20 |
|
||||||
| validate | 17 | 0 | 17 |
|
| validate | 17 | 0 | 17 |
|
||||||
| store | 29 | 0 | 29 |
|
| store | 29 | 0 | 29 |
|
||||||
@@ -17,4 +18,4 @@ _Generated by `lib/content/conformance.sh`_
|
|||||||
| sync | 14 | 0 | 14 |
|
| sync | 14 | 0 | 14 |
|
||||||
| md-import | 24 | 0 | 24 |
|
| md-import | 24 | 0 | 24 |
|
||||||
| fed | 20 | 0 | 20 |
|
| fed | 20 | 0 | 20 |
|
||||||
| **Total** | **338** | **0** | **338** |
|
| **Total** | **365** | **0** | **365** |
|
||||||
|
|||||||
@@ -64,8 +64,11 @@
|
|||||||
ghost-import
|
ghost-import
|
||||||
(fn
|
(fn
|
||||||
(post doc-id)
|
(post doc-id)
|
||||||
(doc-new doc-id (map ghost-section->block (get post :sections)))))
|
(st-iv-set!
|
||||||
|
(doc-new doc-id (map ghost-section->block (get post :sections)))
|
||||||
|
"title"
|
||||||
|
(get post :title))))
|
||||||
|
|
||||||
(define ghost-export (fn (doc) {:sections (map block->ghost-section (doc-blocks doc))}))
|
(define ghost-export (fn (doc) {:sections (map block->ghost-section (doc-blocks doc)) :title (st-send doc "title" (list))}))
|
||||||
|
|
||||||
(define ghost-adapter {:export ghost-export :import ghost-import})
|
(define ghost-adapter {:export ghost-export :import ghost-import})
|
||||||
|
|||||||
79
lib/content/tests/meta.sx
Normal file
79
lib/content/tests/meta.sx
Normal file
@@ -0,0 +1,79 @@
|
|||||||
|
;; Extension — document metadata (title/slug/tags) + Ghost title plumbing.
|
||||||
|
|
||||||
|
(st-bootstrap-classes!)
|
||||||
|
(content/bootstrap!)
|
||||||
|
|
||||||
|
(define d (doc-empty "post"))
|
||||||
|
|
||||||
|
;; ── defaults ──
|
||||||
|
(content-test "default title nil" (doc-title d) nil)
|
||||||
|
(content-test "default slug nil" (doc-slug d) nil)
|
||||||
|
(content-test "default tags empty" (doc-tags d) (list))
|
||||||
|
|
||||||
|
;; ── copy-on-write setters ──
|
||||||
|
(define d2 (doc-with-title d "Hello World"))
|
||||||
|
(content-test "with-title" (doc-title d2) "Hello World")
|
||||||
|
(content-test "with-title immutable" (doc-title d) nil)
|
||||||
|
(content-test "with-title keeps id" (doc-id d2) "post")
|
||||||
|
|
||||||
|
(define d3 (doc-with-slug (doc-with-title d "T") "my-slug"))
|
||||||
|
(content-test "with-slug" (doc-slug d3) "my-slug")
|
||||||
|
(content-test "title preserved with slug" (doc-title d3) "T")
|
||||||
|
|
||||||
|
(define d4 (doc-with-tags d (list "a" "b")))
|
||||||
|
(content-test "with-tags" (doc-tags d4) (list "a" "b"))
|
||||||
|
(content-test "add-tag" (doc-tags (doc-add-tag d4 "c")) (list "a" "b" "c"))
|
||||||
|
(content-test
|
||||||
|
"add-tag from empty"
|
||||||
|
(doc-tags (doc-add-tag d "x"))
|
||||||
|
(list "x"))
|
||||||
|
|
||||||
|
;; ── batch + dict ──
|
||||||
|
(define d5 (doc-with-meta d {:slug "s" :title "T" :tags (list "t1")}))
|
||||||
|
(content-test "with-meta title" (doc-title d5) "T")
|
||||||
|
(content-test "with-meta slug" (doc-slug d5) "s")
|
||||||
|
(content-test "with-meta tags" (doc-tags d5) (list "t1"))
|
||||||
|
(content-test
|
||||||
|
"with-meta partial leaves title"
|
||||||
|
(doc-title (doc-with-meta d {:slug "only"}))
|
||||||
|
nil)
|
||||||
|
(content-test "doc-meta dict" (doc-meta d5) {:slug "s" :id "post" :title "T" :tags (list "t1")})
|
||||||
|
|
||||||
|
;; ── constructor with metadata ──
|
||||||
|
(define d6 (doc-new-meta "p2" (list (mk-text "x" "hi")) {:title "Post 2"}))
|
||||||
|
(content-test "new-meta title" (doc-title d6) "Post 2")
|
||||||
|
(content-test "new-meta blocks" (doc-ids d6) (list "x"))
|
||||||
|
|
||||||
|
;; ── facade aliases ──
|
||||||
|
(content-test "content/title" (content/title d5) "T")
|
||||||
|
(content-test
|
||||||
|
"content/with-title"
|
||||||
|
(content/title (content/with-title d "Z"))
|
||||||
|
"Z")
|
||||||
|
(content-test "content/meta" (content/meta d5) (doc-meta d5))
|
||||||
|
|
||||||
|
;; ── metadata coexists with block ops ──
|
||||||
|
(define
|
||||||
|
d7
|
||||||
|
(doc-append
|
||||||
|
(doc-with-title (doc-empty "x") "Titled")
|
||||||
|
(mk-text "p" "body")))
|
||||||
|
(content-test "meta + blocks coexist" (doc-ids d7) (list "p"))
|
||||||
|
(content-test "meta survives append" (doc-title d7) "Titled")
|
||||||
|
(content-test
|
||||||
|
"meta survives edit"
|
||||||
|
(doc-title (doc-update d7 "p" "text" "changed"))
|
||||||
|
"Titled")
|
||||||
|
|
||||||
|
;; ── Ghost adapter now carries title ──
|
||||||
|
(define post {:sections (list {:id "h" :text "Hi" :kind "heading" :level 1}) :title "My Post"})
|
||||||
|
(define gd (content/import ghost-adapter post "post"))
|
||||||
|
(content-test "ghost import title" (doc-title gd) "My Post")
|
||||||
|
(content-test
|
||||||
|
"ghost export title"
|
||||||
|
(get (content/export ghost-adapter gd) :title)
|
||||||
|
"My Post")
|
||||||
|
(content-test
|
||||||
|
"ghost title round-trip"
|
||||||
|
(doc-title (content/round-trip ghost-adapter gd))
|
||||||
|
"My Post")
|
||||||
@@ -19,7 +19,7 @@ injected adapter, not core.
|
|||||||
|
|
||||||
## Status (rolling)
|
## Status (rolling)
|
||||||
|
|
||||||
`bash lib/content/conformance.sh` → **338/338** (Phases 1–4 COMPLETE + extensions: HTML/SX escaping, Markdown render+import, durable CRDT replication, validation, snapshot cache)
|
`bash lib/content/conformance.sh` → **365/365** (Phases 1–4 COMPLETE + extensions: HTML/SX escaping, Markdown render+import, durable CRDT replication, validation, snapshot cache, doc metadata)
|
||||||
|
|
||||||
## Ground rules
|
## Ground rules
|
||||||
|
|
||||||
@@ -83,9 +83,16 @@ lib/content/api.sx ── (content/edit) (content/render) (content/history) ─
|
|||||||
- [x] document validation (`validate.sx`: ids, per-type fields, duplicate ids)
|
- [x] document validation (`validate.sx`: ids, per-type fields, duplicate ids)
|
||||||
- [x] Markdown import adapter (`md-import.sx`: text → blocks, round-trips export)
|
- [x] Markdown import adapter (`md-import.sx`: text → blocks, round-trips export)
|
||||||
- [x] snapshot cache over replay (`snapshot.sx`: cache-not-primary, transparent)
|
- [x] snapshot cache over replay (`snapshot.sx`: cache-not-primary, transparent)
|
||||||
|
- [x] document metadata (`meta.sx`: title/slug/tags + Ghost title plumbing)
|
||||||
|
|
||||||
## Progress log
|
## Progress log
|
||||||
|
|
||||||
|
- 2026-06-07 — Extension: document metadata (`meta.sx`). CtDoc gained optional
|
||||||
|
title/slug/tags ivars (declared in doc.sx, default nil/empty, no effect on
|
||||||
|
block ops). Reads via message dispatch; copy-on-write setters
|
||||||
|
(`doc-with-title/slug/tags`, `doc-add-tag`, `doc-with-meta`, `doc-new-meta`)
|
||||||
|
and `content/*` aliases; `doc-meta` returns the metadata dict. Ghost adapter
|
||||||
|
now carries `:title` through import/export/round-trip. 27 tests; suite 365/365.
|
||||||
- 2026-06-07 — Extension: snapshot cache over op-log replay (`snapshot.sx`).
|
- 2026-06-07 — Extension: snapshot cache over op-log replay (`snapshot.sx`).
|
||||||
Snapshots are a cache, never primary state — the log stays the source of truth.
|
Snapshots are a cache, never primary state — the log stays the source of truth.
|
||||||
`content/snapshot!` stores a materialised head at a seq in the persist KV;
|
`content/snapshot!` stores a materialised head at a seq in the persist KV;
|
||||||
|
|||||||
Reference in New Issue
Block a user