diff --git a/lib/content/conformance.sh b/lib/content/conformance.sh index a25d508b..411b9394 100755 --- a/lib/content/conformance.sh +++ b/lib/content/conformance.sh @@ -15,7 +15,7 @@ if [ ! -x "$SX_SERVER" ]; then fi fi -SUITES=(block doc render api meta markdown text validate store snapshot crdt crdt-store sync md-import fed) +SUITES=(block doc render api meta markdown text section validate store snapshot crdt crdt-store sync md-import fed) OUT_JSON="lib/content/scoreboard.json" OUT_MD="lib/content/scoreboard.md" @@ -44,6 +44,7 @@ run_suite() { (load "lib/content/api.sx") (load "lib/content/meta.sx") (load "lib/content/text.sx") +(load "lib/content/section.sx") (load "lib/content/markdown.sx") (load "lib/content/validate.sx") (load "lib/content/store.sx") diff --git a/lib/content/scoreboard.json b/lib/content/scoreboard.json index 167c48e6..d59732a3 100644 --- a/lib/content/scoreboard.json +++ b/lib/content/scoreboard.json @@ -7,6 +7,7 @@ "meta": {"pass": 27, "fail": 0}, "markdown": {"pass": 20, "fail": 0}, "text": {"pass": 20, "fail": 0}, + "section": {"pass": 25, "fail": 0}, "validate": {"pass": 17, "fail": 0}, "store": {"pass": 29, "fail": 0}, "snapshot": {"pass": 20, "fail": 0}, @@ -16,7 +17,7 @@ "md-import": {"pass": 24, "fail": 0}, "fed": {"pass": 20, "fail": 0} }, - "total_pass": 385, + "total_pass": 410, "total_fail": 0, - "total": 385 + "total": 410 } diff --git a/lib/content/scoreboard.md b/lib/content/scoreboard.md index 2cfaa99d..9b570f2b 100644 --- a/lib/content/scoreboard.md +++ b/lib/content/scoreboard.md @@ -11,6 +11,7 @@ _Generated by `lib/content/conformance.sh`_ | meta | 27 | 0 | 27 | | markdown | 20 | 0 | 20 | | text | 20 | 0 | 20 | +| section | 25 | 0 | 25 | | validate | 17 | 0 | 17 | | store | 29 | 0 | 29 | | snapshot | 20 | 0 | 20 | @@ -19,4 +20,4 @@ _Generated by `lib/content/conformance.sh`_ | sync | 14 | 0 | 14 | | md-import | 24 | 0 | 24 | | fed | 20 | 0 | 20 | -| **Total** | **385** | **0** | **385** | +| **Total** | **410** | **0** | **410** | diff --git a/lib/content/section.sx b/lib/content/section.sx new file mode 100644 index 00000000..2c20f8f7 --- /dev/null +++ b/lib/content/section.sx @@ -0,0 +1,103 @@ +;; content-on-sx — nested block trees (section container). +;; +;; CtSection is a block whose ivar `children` is an ordered list of blocks (any +;; type, including nested sections → arbitrary depth). This turns the document +;; from a flat sequence into the ordered TREE of the architecture sketch. +;; +;; Self-contained: CtSection answers asHTML/asSx/asText/asMarkdown: by folding +;; its children's renderings — pure polymorphic recursion, so it composes with +;; the existing render boundary with no changes to block.sx or render.sx. (The +;; relevant per-block render bootstrap must be loaded for the children.) +;; +;; Requires (loaded by harness): block.sx, doc.sx, render.sx (asHTML/asSx); +;; markdown.sx / text.sx for those formats on children. + +(define + content-bootstrap-section! + (fn + () + (begin + (st-class-define! "CtSection" "CtBlock" (list "children")) + (ct-def-method! "CtSection" "children" "children ^ children") + (ct-def-method! "CtSection" "type" "type ^ #section") + (ct-def-method! + "CtSection" + "asHTML" + "asHTML ^ '
' , (children inject: '' into: [:a :b | a , (b asHTML)]) , '
'") + (ct-def-method! + "CtSection" + "asSx" + "asSx ^ '(section ' , (children inject: '' into: [:a :b | a , (b asSx)]) , ')'") + (ct-def-method! + "CtSection" + "asText" + "asText ^ (children inject: '' into: [:a :b | (b asText = '') ifTrue: [a] ifFalse: [(a = '' ifTrue: [b asText] ifFalse: [a , ' ' , b asText])]])") + (ct-def-method! + "CtSection" + "asMarkdown:" + "asMarkdown: nl ^ (children inject: '' into: [:a :b | a , (a = '' ifTrue: [''] ifFalse: [nl , nl]) , (b asMarkdown: nl)])") + true))) + +(define + mk-section + (fn + (id children) + (st-iv-set! + (st-iv-set! (st-make-instance "CtSection") "id" id) + "children" + children))) + +(define + section? + (fn (b) (and (st-instance? b) (= (get b :class) "CtSection")))) + +(define section-children (fn (sec) (st-send sec "children" (list)))) + +;; copy-on-write child edits (return a new section) +(define + section-with-children + (fn (sec children) (st-iv-set! sec "children" children))) +(define + section-append + (fn + (sec block) + (section-with-children sec (append (section-children sec) (list block))))) + +;; ── tree traversal (descends into nested sections) ── +(define + block-deep-find + (fn + (blocks id) + (if + (= (len blocks) 0) + nil + (let + ((b (first blocks))) + (if + (= (blk-id b) id) + b + (let + ((nested (if (section? b) (block-deep-find (section-children b) id) nil))) + (if (= nested nil) (block-deep-find (rest blocks) id) nested))))))) + +(define doc-deep-find (fn (doc id) (block-deep-find (doc-blocks doc) id))) + +(define + block-tree-ids + (fn + (blocks) + (if + (= (len blocks) 0) + (list) + (let + ((b (first blocks))) + (append + (cons + (blk-id b) + (if (section? b) (block-tree-ids (section-children b)) (list))) + (block-tree-ids (rest blocks))))))) + +(define doc-tree-ids (fn (doc) (block-tree-ids (doc-blocks doc)))) + +(define block-tree-count (fn (blocks) (len (block-tree-ids blocks)))) +(define doc-tree-count (fn (doc) (len (doc-tree-ids doc)))) diff --git a/lib/content/tests/section.sx b/lib/content/tests/section.sx new file mode 100644 index 00000000..36c1c649 --- /dev/null +++ b/lib/content/tests/section.sx @@ -0,0 +1,99 @@ +;; Extension — nested block trees (CtSection container). + +(st-bootstrap-classes!) +(content/bootstrap!) +(content-bootstrap-markdown!) +(content-bootstrap-text!) +(content-bootstrap-section!) + +(define nl (str "\n")) + +;; ── a section is a block ── +(define + sec + (mk-section + "s" + (list (mk-heading "h" 2 "Hi") (mk-text "p" "Body")))) +(content-test "section is block" (block? sec) true) +(content-test "section? yes" (section? sec) true) +(content-test "section? no on text" (section? (mk-text "x" "y")) false) +(content-test "section type" (blk-type sec) "section") +(content-test "section id" (blk-id sec) "s") +(content-test + "section children count" + (len (section-children sec)) + 2) + +;; ── recursive render ── +(content-test + "section html" + (asHTML sec) + "

Hi

Body

") +(content-test "section sx" (asSx sec) "(section (h2 \"Hi\")(p \"Body\"))") +(content-test "section text" (asText sec) "Hi Body") +(content-test + "empty section html" + (asHTML (mk-section "e" (list))) + "
") + +;; ── nested in a document ── +(define + d + (doc-append + (doc-append (doc-empty "d") (mk-heading "top" 1 "Top")) + sec)) +(content-test + "doc with section html" + (asHTML d) + "

Top

Hi

Body

") +(content-test "doc top-level ids" (doc-ids d) (list "top" "s")) + +;; ── arbitrary depth ── +(define + deep + (mk-section + "outer" + (list + (mk-text "a" "A") + (mk-section + "inner" + (list (mk-text "b" "B") (mk-heading "c" 3 "C")))))) +(content-test + "deep html" + (asHTML deep) + "

A

B

C

") +(content-test "deep text" (asText deep) "A B C") + +;; ── tree traversal descends into sections ── +(define dd (doc-append (doc-empty "d") deep)) +(content-test "deep-find nested" (blk-id (doc-deep-find dd "b")) "b") +(content-test + "deep-find deeper" + (str (blk-send (doc-deep-find dd "c") "text")) + "C") +(content-test "deep-find missing" (doc-deep-find dd "zzz") nil) +(content-test + "deep-find top-level" + (blk-id (doc-deep-find dd "outer")) + "outer") +(content-test + "tree-ids flattened" + (doc-tree-ids dd) + (list "outer" "a" "inner" "b" "c")) +(content-test "tree-count" (doc-tree-count dd) 5) +(content-test "top-level ids still flat" (doc-ids dd) (list "outer")) + +;; ── copy-on-write child edits ── +(define sec2 (section-append sec (mk-divider "dv"))) +(content-test "section-append" (len (section-children sec2)) 3) +(content-test + "section-append immutable" + (len (section-children sec)) + 2) +(content-test + "section-append renders" + (asHTML sec2) + "

Hi

Body


") + +;; ── markdown of a section (children joined by blank line) ── +(content-test "section markdown" (asMarkdown sec) (str "## Hi" nl nl "Body")) diff --git a/plans/content-on-sx.md b/plans/content-on-sx.md index b72c083b..d25236d7 100644 --- a/plans/content-on-sx.md +++ b/plans/content-on-sx.md @@ -19,7 +19,7 @@ injected adapter, not core. ## Status (rolling) -`bash lib/content/conformance.sh` → **385/385** (Phases 1–4 COMPLETE + 9 extensions: HTML/SX escaping, Markdown render+import, CRDT replication, validation, snapshot cache, doc metadata, plain-text render) +`bash lib/content/conformance.sh` → **410/410** (Phases 1–4 COMPLETE + 10 extensions: HTML/SX escaping, Markdown render+import, CRDT replication, validation, snapshot cache, doc metadata, plain-text render, nested block trees) ## Ground rules @@ -85,9 +85,18 @@ lib/content/api.sx ── (content/edit) (content/render) (content/history) ─ - [x] snapshot cache over replay (`snapshot.sx`: cache-not-primary, transparent) - [x] document metadata (`meta.sx`: title/slug/tags + Ghost title plumbing) - [x] plain-text render + excerpt (`text.sx`: asText, content/excerpt) +- [x] nested block trees (`section.sx`: CtSection container, recursive render, deep-find) ## Progress log +- 2026-06-07 — Extension: nested block trees (`section.sx`). `CtSection` is a + block whose `children` ivar is a list of blocks (incl. nested sections → + arbitrary depth), turning the flat document into the ordered TREE from the + architecture sketch. Self-contained: it answers asHTML/asSx/asText/asMarkdown: + by folding children's renderings (pure polymorphic recursion — no changes to + block.sx/render.sx). `mk-section`, `section-children`, `section-append` (cow), + and tree traversal `doc-deep-find` / `doc-tree-ids` / `doc-tree-count` that + descend into sections. 25 tests; suite 410/410. - 2026-06-07 — Extension: plain-text render + excerpts (`text.sx`). Fourth boundary format via polymorphic `asText` (heading/text/code/quote→text, image→alt, embed/divider→"", list→", "-joined); the document joins non-empty