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
|
||||
|
||||
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_MD="lib/content/scoreboard.md"
|
||||
@@ -42,6 +42,7 @@ run_suite() {
|
||||
(load "lib/content/doc.sx")
|
||||
(load "lib/content/render.sx")
|
||||
(load "lib/content/api.sx")
|
||||
(load "lib/content/meta.sx")
|
||||
(load "lib/content/markdown.sx")
|
||||
(load "lib/content/validate.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
|
||||
;; 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 "insert" :block <blk> :after <id|nil>} ; after nil = prepend
|
||||
;; {:op "update" :id <id> :field <name> :value <v>}
|
||||
@@ -16,10 +19,16 @@
|
||||
(fn
|
||||
()
|
||||
(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" "blocks" "blocks ^ blocks")
|
||||
(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)))
|
||||
|
||||
;; ── 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},
|
||||
"render": {"pass": 42, "fail": 0},
|
||||
"api": {"pass": 26, "fail": 0},
|
||||
"meta": {"pass": 27, "fail": 0},
|
||||
"markdown": {"pass": 20, "fail": 0},
|
||||
"validate": {"pass": 17, "fail": 0},
|
||||
"store": {"pass": 29, "fail": 0},
|
||||
@@ -14,7 +15,7 @@
|
||||
"md-import": {"pass": 24, "fail": 0},
|
||||
"fed": {"pass": 20, "fail": 0}
|
||||
},
|
||||
"total_pass": 338,
|
||||
"total_pass": 365,
|
||||
"total_fail": 0,
|
||||
"total": 338
|
||||
"total": 365
|
||||
}
|
||||
|
||||
@@ -8,6 +8,7 @@ _Generated by `lib/content/conformance.sh`_
|
||||
| doc | 40 | 0 | 40 |
|
||||
| render | 42 | 0 | 42 |
|
||||
| api | 26 | 0 | 26 |
|
||||
| meta | 27 | 0 | 27 |
|
||||
| markdown | 20 | 0 | 20 |
|
||||
| validate | 17 | 0 | 17 |
|
||||
| store | 29 | 0 | 29 |
|
||||
@@ -17,4 +18,4 @@ _Generated by `lib/content/conformance.sh`_
|
||||
| sync | 14 | 0 | 14 |
|
||||
| md-import | 24 | 0 | 24 |
|
||||
| fed | 20 | 0 | 20 |
|
||||
| **Total** | **338** | **0** | **338** |
|
||||
| **Total** | **365** | **0** | **365** |
|
||||
|
||||
@@ -64,8 +64,11 @@
|
||||
ghost-import
|
||||
(fn
|
||||
(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})
|
||||
|
||||
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)
|
||||
|
||||
`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
|
||||
|
||||
@@ -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] Markdown import adapter (`md-import.sx`: text → blocks, round-trips export)
|
||||
- [x] snapshot cache over replay (`snapshot.sx`: cache-not-primary, transparent)
|
||||
- [x] document metadata (`meta.sx`: title/slug/tags + Ghost title plumbing)
|
||||
|
||||
## 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`).
|
||||
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;
|
||||
|
||||
Reference in New Issue
Block a user