diff --git a/lib/content/conformance.sh b/lib/content/conformance.sh index 44845292..4697c991 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 query 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 transform 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" @@ -49,6 +49,7 @@ run_suite() { (load "lib/content/tree-edit.sx") (load "lib/content/clone.sx") (load "lib/content/query.sx") +(load "lib/content/transform.sx") (load "lib/content/stats.sx") (load "lib/content/table.sx") (load "lib/content/data.sx") diff --git a/lib/content/scoreboard.json b/lib/content/scoreboard.json index f21aeccc..9f9b976b 100644 --- a/lib/content/scoreboard.json +++ b/lib/content/scoreboard.json @@ -14,6 +14,7 @@ "tree-edit": {"pass": 17, "fail": 0}, "clone": {"pass": 10, "fail": 0}, "query": {"pass": 13, "fail": 0}, + "transform": {"pass": 12, "fail": 0}, "stats": {"pass": 17, "fail": 0}, "table": {"pass": 15, "fail": 0}, "data": {"pass": 21, "fail": 0}, @@ -28,7 +29,7 @@ "md-doc": {"pass": 12, "fail": 0}, "fed": {"pass": 20, "fail": 0} }, - "total_pass": 574, + "total_pass": 586, "total_fail": 0, - "total": 574 + "total": 586 } diff --git a/lib/content/scoreboard.md b/lib/content/scoreboard.md index 1f2d0463..e1d94b7e 100644 --- a/lib/content/scoreboard.md +++ b/lib/content/scoreboard.md @@ -18,6 +18,7 @@ _Generated by `lib/content/conformance.sh`_ | tree-edit | 17 | 0 | 17 | | clone | 10 | 0 | 10 | | query | 13 | 0 | 13 | +| transform | 12 | 0 | 12 | | stats | 17 | 0 | 17 | | table | 15 | 0 | 15 | | data | 21 | 0 | 21 | @@ -31,4 +32,4 @@ _Generated by `lib/content/conformance.sh`_ | md-import | 38 | 0 | 38 | | md-doc | 12 | 0 | 12 | | fed | 20 | 0 | 20 | -| **Total** | **574** | **0** | **574** | +| **Total** | **586** | **0** | **586** | diff --git a/lib/content/tests/transform.sx b/lib/content/tests/transform.sx new file mode 100644 index 00000000..bf1a47b1 --- /dev/null +++ b/lib/content/tests/transform.sx @@ -0,0 +1,90 @@ +;; Extension — tree-wide block transforms. + +(st-bootstrap-classes!) +(content/bootstrap!) +(content-bootstrap-section!) + +(define + d + (doc-append + (doc-append (doc-empty "d") (mk-heading "h" 1 "Top")) + (mk-section + "s" + (list (mk-text "a" "A") (mk-heading "h2" 2 "Sub"))))) + +;; ── map-type bumps heading levels everywhere ── +(define + d1 + (content/map-type + d + "heading" + (fn (b) (blk-set b "level" (+ (blk-get b "level") 1))))) +(content-test + "map-type top heading" + (blk-send (doc-deep-find d1 "h") "level") + 2) +(content-test + "map-type nested heading" + (blk-send (doc-deep-find d1 "h2") "level") + 3) +(content-test + "map-type leaves text" + (str (blk-send (doc-deep-find d1 "a") "text")) + "A") +(content-test + "map-type immutable" + (blk-send (doc-deep-find d "h") "level") + 1) +(content-test "map-type preserves tree" (doc-tree-ids d1) (doc-tree-ids d)) + +;; ── set-field-on rewrites all text blocks ── +(define d2 (content/set-field-on d "text" "text" "REDACTED")) +(content-test + "set-field nested text" + (str (blk-send (doc-deep-find d2 "a") "text")) + "REDACTED") +(content-test + "set-field count" + (len + (filter + (fn (b) (= (str (blk-get b "text")) "REDACTED")) + (list (doc-deep-find d2 "a")))) + 1) + +;; ── map-blocks with custom predicate ── +(define + d3 + (content/map-blocks + d + (fn (b) (= (blk-id b) "h2")) + (fn (b) (blk-set b "text" "Changed")))) +(content-test + "map-blocks predicate hit" + (str (blk-send (doc-deep-find d3 "h2") "text")) + "Changed") +(content-test + "map-blocks predicate miss" + (str (blk-send (doc-deep-find d3 "h") "text")) + "Top") + +;; ── image src rewrite (cdn migration) ── +(define di (doc-append (doc-empty "d") (mk-image "img" "/old.png" "x"))) +(content-test + "image src rewrite" + (str + (blk-send + (doc-find (content/set-field-on di "image" "src" "/cdn/new.png") "img") + "src")) + "/cdn/new.png") + +;; ── no matching blocks → unchanged ── +(content-test + "no match unchanged" + (asHTML (content/map-type d "embed" (fn (b) b))) + (asHTML d)) + +;; ── render after transform ── +(content-test + "render after map-type" + (asHTML d1) + "

Top

A

Sub

") diff --git a/lib/content/transform.sx b/lib/content/transform.sx new file mode 100644 index 00000000..98ce5111 --- /dev/null +++ b/lib/content/transform.sx @@ -0,0 +1,52 @@ +;; content-on-sx — tree-wide block transforms. +;; +;; The write counterpart to query: apply a function to every matching block +;; across the tree (descending into sections), returning a new document. For +;; bulk edits — rewrite image srcs, bump heading levels, sanitise text. Tree +;; detection/rebuild is inline (class + st-iv-get/set!) so this needs no +;; section.sx. Immutable. +;; +;; Requires (loaded by harness): block.sx, doc.sx. + +(define + xf-section? + (fn (b) (and (st-instance? b) (= (get b :class) "CtSection")))) + +(define + block-tree-transform + (fn + (blocks pred f) + (map + (fn + (b) + (let + ((nb (if (pred b) (f b) b))) + (if + (xf-section? nb) + (let + ((ch (st-iv-get nb "children"))) + (if + (list? ch) + (st-iv-set! nb "children" (block-tree-transform ch pred f)) + nb)) + nb))) + blocks))) + +(define + content/map-blocks + (fn + (doc pred f) + (doc-with-blocks doc (block-tree-transform (doc-blocks doc) pred f)))) + +(define + content/map-type + (fn + (doc type f) + (content/map-blocks doc (fn (b) (= (blk-type b) type)) f))) + +;; convenience: set a field on every block of a type. +(define + content/set-field-on + (fn + (doc type field value) + (content/map-type doc type (fn (b) (blk-set b field value))))) diff --git a/plans/content-on-sx.md b/plans/content-on-sx.md index 048bffbe..3fe45e26 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` → **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) +`bash lib/content/conformance.sh` → **586/586** (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 + transforms) ## Ground rules @@ -95,11 +95,18 @@ lib/content/api.sx ── (content/edit) (content/render) (content/history) ─ - [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] block transforms (`transform.sx`: content/map-blocks/map-type/set-field-on) - [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: tree-wide block transforms (`transform.sx`). The write + counterpart to query: `content/map-blocks` (predicate) / `content/map-type` / + `content/set-field-on` apply a function to every matching block across the tree + (sections rebuilt), for bulk edits (cdn src rewrites, heading-level bumps, text + sanitisation). Inline tree rebuild (no section.sx dep); immutable. 12 tests; + suite 586/586. - 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`