diff --git a/lib/content/conformance.sh b/lib/content/conformance.sh index 2f233e49..44845292 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 page page-full markdown text section compose tree-edit clone stats table data wire validate store snapshot crdt crdt-store sync md-import md-doc fed) +SUITES=(block doc render api meta page page-full markdown text section compose tree-edit clone query stats table data wire validate store snapshot crdt crdt-store sync md-import md-doc fed) OUT_JSON="lib/content/scoreboard.json" OUT_MD="lib/content/scoreboard.md" @@ -48,6 +48,7 @@ run_suite() { (load "lib/content/compose.sx") (load "lib/content/tree-edit.sx") (load "lib/content/clone.sx") +(load "lib/content/query.sx") (load "lib/content/stats.sx") (load "lib/content/table.sx") (load "lib/content/data.sx") diff --git a/lib/content/query.sx b/lib/content/query.sx new file mode 100644 index 00000000..a312a45b --- /dev/null +++ b/lib/content/query.sx @@ -0,0 +1,51 @@ +;; content-on-sx — block query + table of contents. +;; +;; Collect blocks across the whole tree (descending into sections) by predicate +;; or type, and derive a table of contents from headings. Tree detection is +;; inline (class + st-iv-get) so this needs no section.sx. +;; +;; Requires (loaded by harness): block.sx, doc.sx. + +(define + qry-section? + (fn (b) (and (st-instance? b) (= (get b :class) "CtSection")))) +(define + qry-tree + (fn + (blocks) + (if + (= (len blocks) 0) + (list) + (let + ((b (first blocks))) + (append + (cons + b + (if + (qry-section? b) + (let + ((ch (st-iv-get b "children"))) + (if (list? ch) (qry-tree ch) (list))) + (list))) + (qry-tree (rest blocks))))))) + +(define + content/select + (fn (doc pred) (filter pred (qry-tree (doc-blocks doc))))) + +(define + content/select-type + (fn (doc type) (content/select doc (fn (b) (= (blk-type b) type))))) + +(define + content/count-type + (fn (doc type) (len (content/select-type doc type)))) + +(define + content/select-ids + (fn (doc pred) (map (fn (b) (blk-id b)) (content/select doc pred)))) + +;; table of contents: {:id :level :text} for every heading, in document order. +(define + content/headings + (fn (doc) (map (fn (b) {:id (blk-id b) :text (blk-get b "text") :level (blk-get b "level")}) (content/select-type doc "heading")))) diff --git a/lib/content/scoreboard.json b/lib/content/scoreboard.json index b714d62e..f21aeccc 100644 --- a/lib/content/scoreboard.json +++ b/lib/content/scoreboard.json @@ -13,6 +13,7 @@ "compose": {"pass": 17, "fail": 0}, "tree-edit": {"pass": 17, "fail": 0}, "clone": {"pass": 10, "fail": 0}, + "query": {"pass": 13, "fail": 0}, "stats": {"pass": 17, "fail": 0}, "table": {"pass": 15, "fail": 0}, "data": {"pass": 21, "fail": 0}, @@ -27,7 +28,7 @@ "md-doc": {"pass": 12, "fail": 0}, "fed": {"pass": 20, "fail": 0} }, - "total_pass": 561, + "total_pass": 574, "total_fail": 0, - "total": 561 + "total": 574 } diff --git a/lib/content/scoreboard.md b/lib/content/scoreboard.md index 4499a6ac..1f2d0463 100644 --- a/lib/content/scoreboard.md +++ b/lib/content/scoreboard.md @@ -17,6 +17,7 @@ _Generated by `lib/content/conformance.sh`_ | compose | 17 | 0 | 17 | | tree-edit | 17 | 0 | 17 | | clone | 10 | 0 | 10 | +| query | 13 | 0 | 13 | | stats | 17 | 0 | 17 | | table | 15 | 0 | 15 | | data | 21 | 0 | 21 | @@ -30,4 +31,4 @@ _Generated by `lib/content/conformance.sh`_ | md-import | 38 | 0 | 38 | | md-doc | 12 | 0 | 12 | | fed | 20 | 0 | 20 | -| **Total** | **561** | **0** | **561** | +| **Total** | **574** | **0** | **574** | diff --git a/lib/content/tests/query.sx b/lib/content/tests/query.sx new file mode 100644 index 00000000..873c82cc --- /dev/null +++ b/lib/content/tests/query.sx @@ -0,0 +1,89 @@ +;; Extension — block query + table of contents. + +(st-bootstrap-classes!) +(content/bootstrap!) +(content-bootstrap-section!) + +(define + d + (doc-append + (doc-append + (doc-append + (doc-append (doc-empty "d") (mk-heading "h1" 1 "Intro")) + (mk-text "p1" "para")) + (mk-image "img" "/a.png" "alt")) + (mk-section + "s" + (list + (mk-heading "h2" 2 "Sub") + (mk-text "p2" "more") + (mk-image "img2" "/b.png" "b"))))) + +;; ── select-type (tree-wide) ── +(content-test + "select headings ids" + (map (fn (b) (blk-id b)) (content/select-type d "heading")) + (list "h1" "h2")) +(content-test + "select images ids" + (map (fn (b) (blk-id b)) (content/select-type d "image")) + (list "img" "img2")) +(content-test + "select text ids" + (map (fn (b) (blk-id b)) (content/select-type d "text")) + (list "p1" "p2")) +(content-test + "select section ids" + (map (fn (b) (blk-id b)) (content/select-type d "section")) + (list "s")) + +;; ── count-type ── +(content-test "count headings" (content/count-type d "heading") 2) +(content-test "count images" (content/count-type d "image") 2) +(content-test "count dividers" (content/count-type d "divider") 0) + +;; ── select with custom predicate ── +(content-test + "select-ids custom" + (content/select-ids d (fn (b) (= (blk-type b) "image"))) + (list "img" "img2")) +(content-test + "select custom field" + (map + (fn (b) (blk-id b)) + (content/select + d + (fn + (b) + (if + (= (blk-type b) "heading") + (= (blk-get b "level") 2) + false)))) + (list "h2")) + +;; ── headings / TOC ── +(content-test + "headings TOC" + (content/headings d) + (list {:id "h1" :text "Intro" :level 1} {:id "h2" :text "Sub" :level 2})) +(content-test + "empty doc no headings" + (content/headings (doc-empty "e")) + (list)) + +;; ── deeply nested ── +(define + deep + (doc-append + (doc-empty "d") + (mk-section + "o" + (list (mk-section "i" (list (mk-heading "deep" 3 "Deep"))))))) +(content-test + "deep heading found" + (map (fn (b) (blk-id b)) (content/select-type deep "heading")) + (list "deep")) +(content-test + "deep toc level" + (get (first (content/headings deep)) :level) + 3) diff --git a/plans/content-on-sx.md b/plans/content-on-sx.md index 0ab3a5eb..048bffbe 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` → **561/561** (Phases 1–4 COMPLETE + extensions: HTML/SX escaping, Markdown render + import/export incl. tables & frontmatter (full round-trip), CRDT replication, tree-aware validation, snapshot cache, doc metadata, plain-text render, nested block trees + deep tree editing, doc stats, table block, HTML page wrapper + SEO page, doc composition + id-remap, portable data + wire serialization) +`bash lib/content/conformance.sh` → **574/574** (Phases 1–4 COMPLETE + extensions: HTML/SX escaping, Markdown render + import/export incl. tables & frontmatter (full round-trip), CRDT replication, tree-aware validation, snapshot cache, doc metadata, plain-text render, nested block trees + deep tree editing, doc stats, table block, HTML page wrapper + SEO page, doc composition + id-remap, portable data + wire serialization, block query + TOC) ## Ground rules @@ -94,11 +94,17 @@ lib/content/api.sx ── (content/edit) (content/render) (content/history) ─ - [x] document composition (`compose.sx`: concat/prepend/concat-all/wrap-section) - [x] deep tree editing (`tree-edit.sx`: doc-deep-update/replace/delete/insert-into) - [x] id remapping / clone (`clone.sx`: content/remap-ids + prefix-ids, collision-free compose) +- [x] block query + TOC (`query.sx`: content/select/select-type/count-type/headings) - [x] portable data serialization (`data.sx`: content/to-data + from-data, round-trips tree) - [x] wire serialization (`wire.sx`: content/to-wire + from-wire, SX-text on the wire) ## Progress log +- 2026-06-07 — Extension: block query + TOC (`query.sx`). `content/select` + (predicate) / `content/select-type` / `content/count-type` / `content/select-ids` + collect blocks across the whole tree (sections recurse); `content/headings` + derives a table of contents (`{:id :level :text}` per heading, document order). + Inline tree detection (no section.sx dep). 13 tests; suite 574/574. - 2026-06-07 — Extension: id remapping / clone (`clone.sx`). `content/remap-ids` deep-rewrites every block id across the tree (sections recurse) via a function; `content/prefix-ids` prefixes them. Enables