diff --git a/lib/content/move.sx b/lib/content/move.sx index 374c6288..7676768f 100644 --- a/lib/content/move.sx +++ b/lib/content/move.sx @@ -1,8 +1,13 @@ -;; content-on-sx — relative block reorder. +;; content-on-sx — block reorder + reparent. ;; -;; Move a top-level block to just before / after another block by id — more -;; ergonomic than the index-based doc-move. No-op if either id is missing. -;; Immutable; composes the doc.sx list helpers. +;; Relative reorder of top-level blocks (move-before/after/to-front/to-back by +;; id) plus TREE reparenting: move a block into a section (content/move-into) or +;; promote a nested block back out to the top level (content/promote). Reparent +;; ops are tree-wide (the block may start anywhere) and cycle-safe — moving a +;; block into its own descendant is rejected (no-op), so a section can never +;; become its own ancestor. No-op if any id is missing. Immutable; composes the +;; doc.sx list + tree helpers (doc-find-deep / ct-find-id / ct-remove-id / +;; ct-replace-id / ct-insert-at). ;; ;; Requires (loaded by harness): doc.sx. @@ -67,3 +72,57 @@ (doc-with-blocks doc (append (ct-remove-id (doc-blocks doc) id) (list blk))))))) + +;; ── reparent (tree-wide) ── +;; move block `id` (from anywhere in the tree) to be a child of section +;; `section-id` at index `i`. No-op if either id is missing, if id = section-id, +;; or if section-id sits inside id's own subtree (would create a cycle). +(define + content/move-into + (fn + (doc id section-id i) + (let + ((blk (doc-find-deep doc id))) + (if + (= blk nil) + doc + (if + (= (doc-find-deep doc section-id) nil) + doc + (if + (= id section-id) + doc + (if + (= (ct-find-id (list blk) section-id) nil) + (let + ((without (ct-remove-id (doc-blocks doc) id))) + (doc-with-blocks + doc + (ct-replace-id + without + section-id + (fn + (sec) + (let + ((ch (st-iv-get sec "children"))) + (if + (list? ch) + (st-iv-set! sec "children" (ct-insert-at ch i blk)) + sec)))))) + doc))))))) + +;; promote block `id` (wherever it sits) out to the end of the top level. If it +;; is already top-level this is a move-to-back. No-op if missing. A section keeps +;; its whole subtree. +(define + content/promote + (fn + (doc id) + (let + ((blk (doc-find-deep doc id))) + (if + (= blk nil) + doc + (doc-with-blocks + doc + (append (ct-remove-id (doc-blocks doc) id) (list blk))))))) diff --git a/lib/content/scoreboard.json b/lib/content/scoreboard.json index c58da9c7..4363092f 100644 --- a/lib/content/scoreboard.json +++ b/lib/content/scoreboard.json @@ -12,7 +12,7 @@ "section": {"pass": 25, "fail": 0}, "compose": {"pass": 17, "fail": 0}, "tree-edit": {"pass": 17, "fail": 0}, - "move": {"pass": 11, "fail": 0}, + "move": {"pass": 24, "fail": 0}, "clone": {"pass": 10, "fail": 0}, "query": {"pass": 20, "fail": 0}, "toc": {"pass": 8, "fail": 0}, @@ -43,7 +43,7 @@ "md-doc": {"pass": 12, "fail": 0}, "fed": {"pass": 20, "fail": 0} }, - "total_pass": 799, + "total_pass": 812, "total_fail": 0, - "total": 799 + "total": 812 } diff --git a/lib/content/scoreboard.md b/lib/content/scoreboard.md index b641e532..96e5ece7 100644 --- a/lib/content/scoreboard.md +++ b/lib/content/scoreboard.md @@ -16,7 +16,7 @@ _Generated by `lib/content/conformance.sh`_ | section | 25 | 0 | 25 | | compose | 17 | 0 | 17 | | tree-edit | 17 | 0 | 17 | -| move | 11 | 0 | 11 | +| move | 24 | 0 | 24 | | clone | 10 | 0 | 10 | | query | 20 | 0 | 20 | | toc | 8 | 0 | 8 | @@ -46,4 +46,4 @@ _Generated by `lib/content/conformance.sh`_ | md-import | 38 | 0 | 38 | | md-doc | 12 | 0 | 12 | | fed | 20 | 0 | 20 | -| **Total** | **799** | **0** | **799** | +| **Total** | **812** | **0** | **812** | diff --git a/lib/content/tests/move.sx b/lib/content/tests/move.sx index 31595762..cc02c202 100644 --- a/lib/content/tests/move.sx +++ b/lib/content/tests/move.sx @@ -1,7 +1,8 @@ -;; Extension — relative block reorder. +;; Extension — relative block reorder + tree reparent. (st-bootstrap-classes!) (content/bootstrap!) +(content-bootstrap-section!) (define d @@ -61,3 +62,84 @@ "render after move" (asHTML (content/move-after d "a" "c")) "

B

C

A

") + +;; ── reparent: move a top-level block INTO a section ── +(define + nd + (doc-append + (doc-append (doc-empty "d") (mk-text "p" "P")) + (mk-section "s" (list (mk-text "x" "X"))))) +(content-test + "move-into: block leaves top level" + (doc-ids (content/move-into nd "p" "s" 1)) + (list "s")) +(content-test + "move-into: block lands in section at index" + (doc-tree-ids (content/move-into nd "p" "s" 1)) + (list "s" "x" "p")) +(content-test + "move-into at front of section" + (doc-tree-ids (content/move-into nd "p" "s" 0)) + (list "s" "p" "x")) +(content-test "move-into immutable" (doc-tree-ids nd) (list "p" "s" "x")) + +;; ── reparent: move a NESTED block to a different section ── +(define + two + (doc-append + (doc-append (doc-empty "d") (mk-section "s1" (list (mk-text "n" "N")))) + (mk-section "s2" (list (mk-text "y" "Y"))))) +(content-test + "move-into across sections" + (doc-tree-ids (content/move-into two "n" "s2" 1)) + (list "s1" "s2" "y" "n")) + +;; ── promote: nested block out to top level (appended last) ── +(content-test + "promote nested to top level" + (doc-tree-ids (content/promote two "n")) + (list "s1" "s2" "y" "n")) +(content-test + "promote leaves section empty shell" + (doc-ids (content/promote two "n")) + (list "s1" "s2" "n")) +(content-test + "promote a whole section keeps its subtree" + (doc-tree-ids + (content/promote + (doc-append + (doc-empty "d") + (mk-section "o" (list (mk-section "i" (list (mk-text "z" "Z")))))) + "i")) + (list "o" "i" "z")) + +;; ── cycle guard: cannot move a section into its own descendant ── +(define + nest + (doc-append + (doc-empty "d") + (mk-section + "outer" + (list (mk-section "inner" (list (mk-text "t" "T"))))))) +(content-test + "move section into its own child is a no-op" + (doc-tree-ids (content/move-into nest "outer" "inner" 0)) + (list "outer" "inner" "t")) +(content-test + "move block into itself is a no-op" + (doc-tree-ids (content/move-into nest "inner" "inner" 0)) + (list "outer" "inner" "t")) + +;; ── reparent no-ops on missing ids ── +(content-test + "move-into missing block no-op" + (doc-tree-ids (content/move-into nd "zzz" "s" 0)) + (list "p" "s" "x")) +(content-test + "move-into missing section no-op" + (doc-tree-ids (content/move-into nd "p" "zzz" 0)) + (list "p" "s" "x")) +(content-test + "promote missing no-op" + (doc-tree-ids (content/promote nd "zzz")) + (list "p" "s" "x")) diff --git a/plans/content-on-sx.md b/plans/content-on-sx.md index 66a0fa58..587992e9 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` → **799/799** (Phases 1–4 COMPLETE + ~34 extensions, hardened: HTML/SX escaping, Markdown render + import/export incl. tables & frontmatter (full round-trip), CvRDT flat + nested-tree + durable replication, tree-aware validation, snapshot cache, doc metadata, plain-text render, nested block trees + deep editing + flatten + relative reorder, doc stats + summary + multi-doc index, table + callout + media blocks, HTML page wrapper + SEO page, doc composition + id-remap, portable data + wire serialization, block query + transforms + find/replace, TOC + anchored headings + outline, normalization) +`bash lib/content/conformance.sh` → **812/812** (Phases 1–4 COMPLETE + ~34 extensions, hardened: HTML/SX escaping, Markdown render + import/export incl. tables & frontmatter (full round-trip), CvRDT flat + nested-tree + durable replication, tree-aware validation, snapshot cache, doc metadata, plain-text render, nested block trees + deep editing + flatten + relative reorder, doc stats + summary + multi-doc index, table + callout + media blocks, HTML page wrapper + SEO page, doc composition + id-remap, portable data + wire serialization, block query + transforms + find/replace, TOC + anchored headings + outline, normalization) ## Ground rules @@ -106,6 +106,7 @@ lib/content/api.sx ── (content/edit) (content/render) (content/history) ─ - [x] document outline (`outline.sx`: content/outline, nested heading tree) - [x] document flatten (`flatten.sx`: content/flatten, un-nest sections; inverse of wrap-section) - [x] relative reorder (`move.sx`: content/move-before/after/to-front/to-back by id) +- [x] tree reparent (`move.sx`: content/move-into a section + content/promote out to top level; tree-wide, cycle-safe) - [x] document normalization (`normalize.sx`: content/normalize, drop empty blocks/sections) - [x] document sanitization (`sanitize.sx`: content/sanitize, drop invalid blocks tree-wide; validate's enforcement partner) - [x] global find/replace (`find-replace.sx`: content/find-replace across text-bearing blocks) @@ -136,6 +137,19 @@ lib/content/api.sx ── (content/edit) (content/render) (content/history) ─ ## Progress log +- 2026-06-07 — Feature: tree reparent in move.sx. Until now insert/move were + positional and top-level only, so a block could never be moved *into* a section + or *out* of one — a real gap for editing nested documents. Added + `content/move-into doc id section-id i` (relocate a block, from anywhere in the + tree, to be a child of a section at index i) and `content/promote doc id` + (lift a nested block out to the end of the top level; a moved section keeps its + whole subtree). Both are pure tree transforms (consistent with the existing + move family — not new op-log ops) built on doc-find-deep / ct-find-id / + ct-remove-id / ct-replace-id. **Cycle-safe**: move-into no-ops when target is + the block itself or sits inside the block's own subtree, so a section can never + become its own ancestor. +13 move tests (into/promote/across-sections/empty- + shell/whole-section-subtree/cycle-guard/missing-id no-ops). 812/812. + - 2026-06-07 — Feature: `content/sanitize` — the enforcement counterpart to `validate`. validate *reports* id/field issues; sanitize *removes* the offending blocks (tree-wide) so federated/imported input that failed